Skip to content
Β 
Β 

Latest commit

Β 

History

110 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

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!

pnpm add 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

# pnpm (recommended)
pnpm add trpc-shield

# npm
npm install trpc-shield

# yarn
yarn add trpc-shield

The package ships both module formats, so it works either way:

import { shield, rule } from 'trpc-shield'; // ES modules
const { shield, rule } = require('trpc-shield'); // CommonJS

Both are named exports; there is no default export. Types are published for both, so require and import resolve correctly under moduleResolution node10, node16 and bundler.

Upgrading from 1.x or 2.0.x? Those releases could not be loaded at all: 1.x shipped without any build output, and 2.0.0 and 2.0.1 declared "type": "module" over a CommonJS build. Upgrade to the latest 2.x; no code changes are needed.

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 v3.0.0+ βœ… Recommended
v11.x v1.0.0 - v2.0.1 ❌ Do not use, see below
v10.x v0.2.0 - v0.4.x ⚠️ Legacy
v9.x v0.1.2 and below ❌ Deprecated

Upgrade from 1.x or 2.x. Every version from 1.0.0 through 2.0.1 was unloadable: the package declared "type": "module" over a CommonJS build, so both require() and import threw. 3.0.0 ships a dual CommonJS and ESM build behind an exports map and is the first release in that line that loads at all.

πŸ†• What's New in 3.0.0

  • The package loads. Fixes the "type": "module" mismatch that broke every 1.x and 2.x release.
  • Deeply nested routers now resolve correctly, so admin.user.findMany and admin.post.findMany no longer collapse onto one rule. Thanks to @zitrusgelb for the fix.
  • Three ways a rule could be silently ignored are gone, each of which failed open: subscription rules resolved nothing, shield(myRule) with a bare rule resolved nothing despite the type signature allowing it, and a malformed tree fell through to allow.
  • Breaking: a rule tree whose keys cannot resolve now throws at startup instead of being ignored. If that stops your application, those procedures were running unprotected.
  • 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
pnpm install
pnpm build
pnpm 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

Stars

433 stars

Watchers

2 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages