trpc-shield
v2.0.1
Published
tRPC permissions as another layer of abstraction!
Readme
tRPC Shield
💖 Support This Project
If this tool helps you build better applications, please consider supporting its development:
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-shieldThis 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, andrace - 🛡️ 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-shieldBasic 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 accessfalse- Deny accessError- 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
Use
denyas fallback for sensitive applications:shield(permissions, { fallbackRule: deny })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 });Don't expose sensitive errors in production:
shield(permissions, { allowExternalErrors: process.env.NODE_ENV === 'development' })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 mutationsoptions- 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 debuggingfn- Rule function(ctx, type, path, input, rawInput, options) => boolean | Error | {ctx: object}
Logic Operators
and(...rules)- All rules must passor(...rules)- At least one rule must passnot(rule, error?)- Rule must failchain(...rules)- Execute rules sequentially, all must passrace(...rules)- Execute rules sequentially until one passes
Built-in Rules
allow- Always allows accessdeny- 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
- Inspired by GraphQL Shield
- Built for the amazing tRPC ecosystem
- Shield icon by Freepik from Flaticon
