- π‘οΈ Declarative rules and composable operators
- π― Strong typing for context and inputs
- π§© Global middleware or perβroute
- π‘ OpenAPIβfriendly denials (map to HTTP 403)
- β‘ Zero runtime dependencies
pnpm add orpc-shield
# or: npm i | yarn add | bun add orpc-shield
import { os } from '@orpc/server';
import { rule, allow, shield } from 'orpc-shield';
type Ctx = { user?: { id: string; role: 'admin' | 'user' } };
const isAuthed = rule<Ctx>()(({ ctx }) => !!ctx.user);
const isAdmin = rule<Ctx>()(({ ctx }) => ctx.user?.role === 'admin');
// Map denials to ORPCError('FORBIDDEN') β HTTP 403 in adapters
const permissions = shield<Ctx>(
{ users: { list: allow, profile: { get: isAuthed, delete: isAdmin } } },
{ denyErrorCode: 'FORBIDDEN' }
);
const api = os.$context<Ctx>().use(permissions);
export const router = api.router({
users: api.router({
list: api
.route({ method: 'GET', path: '/users' })
.handler(async () => [{ id: '1' }]),
profile: api.router({
get: api
.route({ method: 'GET', path: '/users/profile' })
.handler(async ({ context }) => ({
id: context.user?.id ?? 'anonymous',
})),
delete: api
.route({ method: 'DELETE', path: '/users/profile' })
.handler(async ({ context }) => ({
ok: context.user?.role === 'admin',
})),
}),
}),
});
Rules & Operators
import { rule, allow, deny, and, or, not, chain, race } from 'orpc-shield';
const canEdit = rule<Ctx>()(
({ ctx, input }) => ctx.user?.id === input.authorId
);
const canAdmin = rule<Ctx>()(({ ctx }) => ctx.user?.role === 'admin');
const canModify = and(canEdit, or(canAdmin, allow));
AdapterβFriendly Denials
shield(..., { denyErrorCode: 'FORBIDDEN' })
maps denials toORPCError('FORBIDDEN')
(HTTP 403).- Prefer global usage:
os.$context().use(permissions)
.
API Surface
rule<TContext, TInput>()(fn)
β define a rule- Builtβins:
allow
,deny
,denyWithMessage(msg)
- Operators:
and
,or
,not
,chain
,race
shield(rules, { denyErrorCode?, debug?, allowExternalErrors? })
shieldDebug(...)
β shield with debug enabled
Testing & Example
- Tests:
pnpm test
(Sandbox/CI:VITEST_POOL=threads pnpm test
). - Example app (Express + oRPC + OpenAPI): see
example/
andexample/SHIELD_TESTS.md
.
Full Documentation
import { allow, deny, denyWithMessage } from 'orpc-shield';
// Always allow access
allow;
// Always deny access
deny;
// Deny with custom message
denyWithMessage('Custom error message');
// Simple custom rule
const isOwner = rule<Context>()(async ({ ctx, path, input }) => {
return ctx.user?.id === input?.userId;
});
// Named rule (useful for debugging)
const isOwner = rule<Context>('isOwner')(async ({ ctx, input }) => {
return ctx.user?.id === input?.userId;
});
// Rule with typed input
interface UpdateInput {
userId: string;
data: any;
}
const canUpdate = rule<Context, UpdateInput>()(async ({ ctx, input }) => {
return ctx.user?.id === input.userId;
});
Rules can return different values:
// Boolean - simple allow/deny
return true; // Allow
return false; // Deny with default error
// Error object - custom error
return new Error('Custom error message');
// String - converted to error
return 'Access denied';
// Context extension - modify context for downstream procedures
return {
ctx: {
...ctx,
permissions: ['read', 'write'],
},
};
const permissions = shield({
posts: {
delete: and(isAuthenticated, isOwner, isNotArchived),
},
});
const permissions = shield({
posts: {
update: or(isAdmin, isOwner),
},
});
const permissions = shield({
auth: {
register: not(isAuthenticated), // Only unauthenticated users
},
});
const permissions = shield({
posts: {
publish: chain(isAuthenticated, hasPublishPermission, isNotRateLimited),
},
});
const permissions = shield({
posts: {
read: race(isCached, isPublic), // Use cache if available, otherwise check if public
},
});
const permissions = shield(ruleTree, {
// Fallback rule when no rule matches (default: allow)
fallbackRule: deny,
// Custom error for authorization failures
fallbackError: 'Access denied',
// Enable debug logging (default: false)
debug: true,
// Allow external errors to propagate (default: false)
allowExternalErrors: false,
});
// Or use the debug convenience function
import { shieldDebug } from 'orpc-shield';
const permissions = shieldDebug(ruleTree); // Enables debug mode
oRPC Shield works with oRPC's path-based procedure system:
// For procedure: router.api.v1.users.profile.update
// Path will be: ['api', 'v1', 'users', 'profile', 'update']
const pathBasedRule = rule<Context>()(async ({ path, ctx }) => {
// Check if path includes admin routes
if (path.includes('admin')) {
return ctx.user?.role === 'admin';
}
// Check API version
if (path[0] === 'api' && path[1] === 'v2') {
return ctx.user?.hasV2Access;
}
return true;
});
Shield supports arbitrarily nested router structures:
const permissions = shield({
api: {
v1: {
users: {
list: allow,
create: isAdmin,
profile: {
get: isAuthenticated,
update: isOwner,
settings: {
read: isOwner,
write: and(isOwner, hasSettingsPermission),
},
},
},
posts: {
list: allow,
create: isAuthenticated,
categories: {
list: allow,
manage: isAdmin,
},
},
},
v2: {
// Different rules for v2 API
users: {
list: isAuthenticated, // v2 requires auth for listing
},
},
},
public: {
health: allow,
status: allow,
},
});
// Custom error with details
const detailedErrorRule = rule<Context>()(async ({ ctx }) => {
if (!ctx.user) {
return new Error('Authentication required. Please log in.');
}
if (!ctx.user.emailVerified) {
return new Error('Email verification required.');
}
if (ctx.user.suspended) {
return new Error('Account suspended. Contact support.');
}
return true;
});
// Safe async operations
const safeAsyncRule = rule<Context>()(async ({ ctx }) => {
try {
const permissions = await getUserPermissions(ctx.user.id);
return permissions.includes('write');
} catch (error) {
console.error('Permission check failed:', error);
return new Error('Permission check failed');
}
});
// Factory function for reusable rules
const hasRole = (role: string) =>
rule<Context>(`hasRole:${role}`)(async ({ ctx }) => ctx.user?.role === role);
const hasPermission = (permission: string) =>
rule<Context>(`hasPermission:${permission}`)(async ({ ctx }) =>
ctx.user?.permissions?.includes(permission)
);
// Usage
const permissions = shield({
admin: {
users: hasRole('admin'),
reports: hasPermission('view_reports'),
},
});
const enrichContext = rule<Context>()(async ({ ctx }) => {
if (ctx.user?.role === 'admin') {
return {
ctx: {
...ctx,
permissions: ['read', 'write', 'delete'],
adminFeatures: true,
},
};
}
return true;
});
// The enriched context will be available in your procedure
const router = os.router({
adminAction: os.procedure
.use(shield({ adminAction: enrichContext }))
.mutation(async ({ ctx }) => {
// ctx now has permissions and adminFeatures
console.log(ctx.permissions); // ['read', 'write', 'delete']
console.log(ctx.adminFeatures); // true
}),
});
Enable debug mode to see detailed rule execution:
import { shieldDebug } from 'orpc-shield';
// Option 1: Use convenience function
const permissions = shieldDebug(ruleTree);
// Option 2: Enable debug in options
const permissions = shield(ruleTree, { debug: true });
Debug output includes:
- π Rule execution path
- β±οΈ Execution time
- β /β Rule results
- π Error details
- π€οΈ Path information
Example debug output:
[oRPC Shield] Processing path: users.profile.update
[oRPC Shield] Rule result for users.profile.update: true (12ms)
[oRPC Shield] β
Access granted
oRPC Shield provides full type safety:
interface MyContext {
user?: {
id: string;
role: 'admin' | 'user';
permissions: string[];
};
session: {
id: string;
expiresAt: Date;
};
}
interface PostInput {
id: string;
title: string;
authorId: string;
}
// Fully typed rule with context and input inference
const canEditPost = rule<MyContext, PostInput>()(async ({ ctx, input }) => {
// ctx and input are fully typed here
return ctx.user?.id === input.authorId || ctx.user?.role === 'admin';
});
// Type-safe shield configuration
const permissions = shield<MyContext>({
posts: {
edit: canEditPost, // TypeScript ensures rule compatibility
},
});
oRPC Shield is built for performance:
- β‘ Lazy Evaluation - Rules execute only when needed
- π Short-circuiting -
and
/or
operators stop at first decisive result - πΊοΈ Efficient Path Lookup - O(1) rule resolution for most cases
- π¦ Minimal Overhead - Lightweight middleware with fast execution
- π³ Tree Shaking - Only import what you use
β Simple rule evaluation: ~0.01ms
β Complex nested rules: ~0.05ms
β Rule tree lookup: ~0.001ms
β Context extension: ~0.02ms
// β
Good - Single responsibility
const isAuthenticated = rule<Context>()(async ({ ctx }) => {
return !!ctx.user;
});
const isAdmin = rule<Context>()(async ({ ctx }) => {
return ctx.user?.role === 'admin';
});
// β Avoid - Too much logic in one rule
const complexRule = rule<Context>()(async ({ ctx, input }) => {
// Validating input, checking permissions, logging, etc.
// This should be broken down into smaller rules
});
// β
Good - Clear intent
const canDeleteOwnPost = rule<Context>('canDeleteOwnPost')(async ({
ctx,
input,
}) => {
return ctx.user?.id === input.authorId;
});
// β
Good - Compose for readability
const permissions = shield({
posts: {
delete: or(isAdmin, canDeleteOwnPost),
},
});
// β
Good - Graceful error handling
const safePermissionCheck = rule<Context>()(async ({ ctx }) => {
try {
if (!ctx.user) return false;
const permissions = await getPermissions(ctx.user.id);
return permissions?.includes('admin') ?? false;
} catch (error) {
console.error('Permission check failed:', error);
return false; // Fail closed for security
}
});
// β
Good - Reusable and testable
const isPostOwner = rule<Context>()(async ({ ctx, input }) => {
return ctx.user?.id === input.authorId;
});
const canModifyPost = or(isAdmin, isPostOwner);
const permissions = shield({
posts: {
update: and(isAuthenticated, canModifyPost),
delete: and(isAuthenticated, canModifyPost),
},
});
- oRPC - The RPC framework this library is built for
- tRPC Shield - Authorization for tRPC (inspiration)
- GraphQL Shield - Original GraphQL authorization library
We welcome contributions! Please open issues or pull requests. Follow Conventional Commits and ensure a clean run of lint, typecheck, and tests.
git clone https://github.com/omar-dulaimi/orpc-shield
cd orpc-shield
pnpm i
pnpm typecheck && pnpm lint && pnpm test
β Star on GitHub β’ π Documentation β’ π Report Issues
Made with β€οΈ by Omar Dulaimi
- License: MIT β see the
LICENSE
file. - Copyright Β© 2025 Omar Dulaimi.