| name | authorization-framework |
| description | Kilpi is an open-source TypeScript authorization library designed for developers who need flexible, powerful, and intuitive authorization in full-stack applications. Kilpi provides a comprehensive solution for implementing fine-grained access control using both Role-Based Access Control (RBAC) and Attribute-Based Access Control (ABAC) patterns. |
Introduction
The framework is organized as a monorepo containing four primary packages: @kilpi/core for server-side authorization logic, @kilpi/client for client-side authorization checks with intelligent batching and caching, @kilpi/react-server for React Server Components integration, and @kilpi/react-client for React hooks and components. Kilpi's architecture emphasizes type safety, developer experience through fluent APIs, and production-ready features including audit logging, protected queries, and extensibility through a robust plugin system.
Core APIs and Functions
createKilpi() - Initialize Authorization System
Factory function that creates the core Kilpi instance with policies, subject resolution, and plugins. This is the primary entry point for server-side authorization.
import { createKilpi, Grant, Deny, EndpointPlugin } from "@kilpi/core";
import { ReactServerPlugin } from "@kilpi/react-server";
type Article = {
id: string;
userId: string;
title: string;
isPublished: boolean;
};
const Kilpi = createKilpi({
async getSubject() {
const session = await auth.getSession();
if (!session) return null;
return session.user;
},
policies: {
async authed(subject) {
if (!subject) return Deny({ message: "Not authenticated" });
return Grant(subject);
},
articles: {
read(subject, article: Article) {
if (article.isPublished) return Grant(subject);
if (!subject) return Deny({ message: "Not authenticated" });
if (subject.id === article.userId) return Grant(subject);
return Deny({ message: "Not authorized to read this article" });
},
create(subject) {
if (!subject) return Deny({ message: "Must be signed in" });
return Grant(subject);
},
update(subject, article: Article) {
if (!subject) return Deny({ message: "Not authenticated" });
if (subject.id === article.userId) return Grant(subject);
return Deny({ message: "Not the article owner" });
},
delete(subject, article: Article) {
if (!subject) return Deny({ message: "Not authenticated" });
if (subject.role === "admin" || subject.id === article.userId) {
return Grant(subject);
}
return Deny({ message: "Not authorized" });
}
}
},
onUnauthorizedAssert(decision) {
throw new Error(`Unauthorized: ${decision.message}`);
},
plugins: [
EndpointPlugin({ secret: "my-secret-key" }),
ReactServerPlugin()
]
});
export { Kilpi };
Policy Authorization - Check Access Permissions
Evaluate authorization using the fluent API to check if a subject has permission to perform an action.
import { Kilpi } from "./kilpi.server";
const article = {
id: "123",
userId: "user-456",
title: "My Article",
isPublished: false
};
const decision = await Kilpi.articles.read(article).authorize();
if (decision.granted) {
console.log("Access granted", decision.subject);
} else {
console.log("Access denied", decision.message);
}
try {
const { subject } = await Kilpi.articles.update(article).authorize().assert();
console.log("Authorized user:", subject.id);
} catch (error) {
console.error("Unauthorized:", error.message);
}
const authCheck = await Kilpi.authed().authorize();
if (authCheck.granted) {
console.log("User is authenticated");
}
$query() - Protected Database Queries
Wrap data fetching functions with automatic authorization checks to ensure users only access data they're permitted to see.
import { Kilpi } from "./kilpi.server";
import { db } from "./database";
const listArticles = Kilpi.$query(
async (query: { userId?: string; isAdmin?: boolean }) => {
const sql = `
SELECT articles.*, user.name as authorName
FROM articles
INNER JOIN user ON articles.userId = user.id
WHERE articles.isPublished = 1
OR articles.userId = $userId
OR ${query.isAdmin ? "1=1" : "0=1"}
`;
const articles = await db.query(sql).all({
$userId: query.userId || null
});
return articles;
},
{
async authorize({ output: articles, subject }) {
for (const article of articles) {
await Kilpi.articles.read(article).authorize().assert();
}
return articles;
}
}
);
const getArticleById = Kilpi.$query(
async (id: string) => {
const article = await db.query(
"SELECT * FROM articles WHERE id = $id"
).get({ $id: id });
return article;
},
{
async authorize({ output: article }) {
if (article) {
await Kilpi.articles.read(article).authorize().assert();
}
return article;
}
}
);
async function getArticlesForCurrentUser() {
const subject = await Kilpi.$getSubject();
const articles = await listArticles.authorized({
userId: subject?.id,
isAdmin: subject?.role === "admin"
});
return articles;
}
async function getArticle(id: string) {
try {
const article = await getArticleById.authorized(id);
return article;
} catch (error) {
console.error("Access denied");
return null;
}
}
Authorize Component - Server-Side Conditional Rendering
React Server Component for conditional rendering based on authorization checks with built-in loading and error states.
import { createKilpi } from "@kilpi/core";
import { ReactServerPlugin } from "@kilpi/react-server";
const Kilpi = createKilpi({
plugins: [ReactServerPlugin()]
});
export const { Authorize } = Kilpi.$createReactServerComponents();
import { Authorize, Kilpi } from "@/kilpi.server";
import { ArticleService } from "@/article-service";
export default async function ArticlePage({
params
}: {
params: { articleId: string }
}) {
const article = await ArticleService.getArticleById.authorized(
params.articleId
);
if (!article) {
return <div>Article not found</div>;
}
return (
<div>
<h1>{article.title}</h1>
<p>{article.content}</p>
{/* Conditionally render update form based on authorization */}
<Authorize
policy={Kilpi.articles.update(article)}
Pending={<div>Checking permissions...</div>}
Unauthorized={(decision) => (
<div>Cannot edit: {decision?.message}</div>
)}
>
<UpdateArticleForm article={article} />
</Authorize>
{/* Delete button with authorization */}
<Authorize
policy={Kilpi.articles.delete(article)}
Pending={<button disabled>Loading...</button>}
Unauthorized={(decision) => (
<button disabled>Cannot delete: {decision?.message}</button>
)}
>
<form action={deleteArticleAction}>
<input type="hidden" name="id" value={article.id} />
<button type="submit">Delete Article</button>
</form>
</Authorize>
</div>
);
}
Hooks System - Authorization Lifecycle Events
Hook into authorization events for logging, caching, and custom error handling throughout the authorization lifecycle.
import { Kilpi } from "./kilpi.server";
Kilpi.$hooks.onAfterAuthorization((event) => {
console.log({
action: event.action,
granted: event.decision.granted,
subject: event.subject,
object: event.object,
timestamp: new Date()
});
});
Kilpi.$hooks.onSubjectRequestFromCache(({ context }) => {
const cached = myCache.get("current-subject");
return cached || null;
});
Kilpi.$hooks.onSubjectResolved(({ subject, fromCache, context }) => {
if (!fromCache && subject) {
myCache.set("current-subject", subject, { ttl: 300 });
}
});
Kilpi.$hooks.onUnauthorizedAssert(({ decision, action, subject, object }) => {
securityLogger.warn({
event: "unauthorized_access",
user: subject?.id,
action,
reason: decision.message,
metadata: decision.metadata
});
if (action.startsWith("admin.")) {
throw new Error("Admin access required");
}
});
AuditPlugin - Authorization Audit Logging
Plugin for comprehensive audit logging of authorization events with configurable strategies and filtering.
import { createKilpi, AuditPlugin } from "@kilpi/core";
const Kilpi = createKilpi({
plugins: [
AuditPlugin({
strategy: "immediate",
onAuditEvent: async (event) => {
await db.auditLogs.insert({
timestamp: event.timestamp,
action: event.action,
subjectId: event.subject?.id,
objectId: event.object?.id,
granted: event.decision.granted,
reason: event.decision.message,
metadata: event.decision.metadata
});
await logService.track("authorization", {
user: event.subject?.id,
action: event.action,
result: event.decision.granted ? "granted" : "denied"
});
},
shouldIncludeEvent: (event) => {
if (!event.decision.granted) return true;
if (event.action.startsWith("admin.")) return true;
return false;
},
disabled: process.env.DISABLE_AUDIT === "true"
})
]
});
await Kilpi.$audit.flush();
Kilpi.$audit.enable();
Kilpi.$audit.disable();
EndpointPlugin - Client-Server Communication
Plugin that creates an HTTP endpoint for client-side authorization checks with automatic batching and authentication.
import { createKilpi, EndpointPlugin } from "@kilpi/core";
const Kilpi = createKilpi({
plugins: [
EndpointPlugin({
secret: process.env.KILPI_SECRET!,
getContext: (req: Request) => {
const ip = req.headers.get("x-forwarded-for");
return { ip };
},
onBeforeHandleRequest: (req: Request) => {
const origin = req.headers.get("origin");
if (!allowedOrigins.includes(origin)) {
throw new Error("Invalid origin");
}
},
onBeforeProcessItem: (request) => {
console.log("Processing:", request.action);
}
})
]
});
export const POST = Kilpi.$createPostEndpoint();
export default async function handler(req, res) {
if (req.method === "POST") {
return await Kilpi.$createPostEndpoint()(req);
}
res.status(405).json({ error: "Method not allowed" });
}
createKilpiClient() - Client-Side Authorization
Initialize client SDK for making authorization checks from the browser with intelligent batching and caching.
import { createKilpiClient } from "@kilpi/client";
import { ReactClientPlugin } from "@kilpi/react-client";
import type { Kilpi } from "./kilpi.server";
const KilpiClient = createKilpiClient({
infer: {} as typeof Kilpi,
connect: {
secret: process.env.NEXT_PUBLIC_KILPI_SECRET!,
endpointUrl: process.env.NEXT_PUBLIC_KILPI_URL!
},
batching: {
batchDelayMs: 10,
jobTimeoutMs: 5000
},
plugins: [ReactClientPlugin()]
});
export const { AuthorizeClient } = KilpiClient.$createReactClientComponents();
async function checkArticleAccess(article) {
const decision = await KilpiClient.articles.read(article).authorize();
if (decision.granted) {
console.log("User can read article");
return true;
} else {
console.log("Access denied:", decision.message);
return false;
}
}
function onArticleUpdated(article) {
KilpiClient.articles.read(article).$invalidate();
KilpiClient.articles.update(article).$invalidate();
KilpiClient.articles.delete(article).$invalidate();
}
function onArticleDeleted() {
KilpiClient.articles.$invalidate();
}
async function checkMultiplePermissions(articles) {
const checks = await Promise.all(
articles.map(article =>
KilpiClient.articles.read(article).authorize()
)
);
return checks;
}
useAuthorize() Hook - React Authorization State
React hook for checking authorization in client components with loading, error, and success states.
import { KilpiClient } from "@/kilpi.client";
import type { Article } from "@/types";
function ArticleActions({ article }: { article: Article }) {
const deleteAuth = KilpiClient.articles.delete(article).useAuthorize({
isDisabled: false,
});
if (deleteAuth.isIdle) {
return null;
}
if (deleteAuth.isPending) {
return <div>Checking permissions...</div>;
}
if (deleteAuth.isError) {
return <div>Error: {deleteAuth.error?.message}</div>;
}
if (deleteAuth.granted) {
return (
<button
onClick={() => deleteArticle(article.id)}
className="btn-danger"
>
Delete Article
</button>
);
} else {
return (
<button disabled className="btn-disabled">
Cannot delete: {deleteAuth.decision.message}
</button>
);
}
}
function ArticleCard({ article }: { article: Article }) {
const canUpdate = KilpiClient.articles.update(article).useAuthorize();
const canDelete = KilpiClient.articles.delete(article).useAuthorize();
return (
<div className="article-card">
<h2>{article.title}</h2>
<p>{article.content}</p>
<div className="actions">
{canUpdate.granted && (
<button onClick={() => editArticle(article)}>Edit</button>
)}
{canDelete.granted && (
<button onClick={() => deleteArticle(article)}>Delete</button>
)}
{(!canUpdate.granted && !canDelete.granted) && (
<p>Read-only access</p>
)}
</div>
</div>
);
}
AuthorizeClient Component - Client-Side Conditional Rendering
Client component for conditional rendering based on authorization with render props and loading states.
import { AuthorizeClient, KilpiClient } from "@/kilpi.client";
import type { Article } from "@/types";
function ArticleManagement({ article }: { article: Article }) {
return (
<div className="article-management">
{/* Render update button only if authorized */}
<AuthorizeClient
policy={KilpiClient.articles.update(article)}
Pending={
<button disabled>Checking permissions...</button>
}
Unauthorized={(decision) => (
<button disabled title={decision?.message}>
Update (No Access)
</button>
)}
>
<UpdateButton article={article} />
</AuthorizeClient>
{/* Delete button with authorization */}
<AuthorizeClient
policy={KilpiClient.articles.delete(article)}
Pending={
<button disabled>Loading...</button>
}
Unauthorized={(decision) => (
<div className="error">
Cannot delete: {decision?.message}
</div>
)}
>
<form onSubmit={handleDelete}>
<button type="submit" className="btn-danger">
Delete Article
</button>
</form>
</AuthorizeClient>
{/* Using render function for more complex logic */}
<AuthorizeClient
policy={KilpiClient.articles.update(article)}
Pending={() => <Spinner />}
Unauthorized={() => null}
>
{({ decision }) => (
<div>
<p>Authorized as: {decision.subject.name}</p>
<AdminPanel article={article} />
</div>
)}
</AuthorizeClient>
</div>
);
}
function EditableArticle({ article }: { article: Article }) {
const [isEditing, setIsEditing] = useState(false);
return (
<div>
{isEditing ? (
<ArticleEditor article={article} onCancel={() => setIsEditing(false)} />
) : (
<ArticleView article={article} />
)}
<AuthorizeClient
policy={KilpiClient.articles.update(article)}
Unauthorized={() => null}
>
<button onClick={() => setIsEditing(!isEditing)}>
{isEditing ? "Cancel" : "Edit"}
</button>
</AuthorizeClient>
</div>
);
}
Custom Plugin Development - Extend Functionality
Create custom plugins to extend Kilpi's core and client functionality with type-safe APIs.
import { createKilpiPlugin, type AnyKilpiCore } from "@kilpi/core";
import { createKilpiClientPlugin, type KilpiClient } from "@kilpi/client";
function RateLimitPlugin(options: { maxRequests: number; windowMs: number }) {
const requestCounts = new Map<string, { count: number; reset: number }>();
return createKilpiPlugin((Kilpi: AnyKilpiCore) => {
Kilpi.$hooks.onAfterAuthorization((event) => {
const subjectId = event.subject?.id || "anonymous";
const now = Date.now();
const record = requestCounts.get(subjectId);
if (!record || now > record.reset) {
requestCounts.set(subjectId, {
count: 1,
reset: now + options.windowMs
});
} else {
record.count++;
if (record.count > options.maxRequests) {
throw new Error("Rate limit exceeded");
}
}
});
return {
extendCore() {
return {
$getRateLimitStatus(subjectId: string) {
return requestCounts.get(subjectId);
},
$resetRateLimit(subjectId: string) {
requestCounts.delete(subjectId);
}
};
}
};
});
}
function ClientMetricsPlugin() {
const metrics = {
totalRequests: 0,
cacheHits: 0,
cacheMisses: 0
};
return createKilpiClientPlugin((Client: KilpiClient) => {
return {
extendClient() {
return {
$getMetrics() {
return { ...metrics };
},
$resetMetrics() {
metrics.totalRequests = 0;
metrics.cacheHits = 0;
metrics.cacheMisses = 0;
}
};
},
extendPolicy(policy) {
const originalAuthorize = policy.authorize;
return {
async authorize() {
metrics.totalRequests++;
const result = await originalAuthorize.call(policy);
return result;
}
};
}
};
});
}
const Kilpi = createKilpi({
plugins: [
RateLimitPlugin({ maxRequests: 100, windowMs: 60000 })
]
});
const status = Kilpi.$getRateLimitStatus("user-123");
Kilpi.$resetRateLimit("user-123");
const KilpiClient = createKilpiClient({
plugins: [ClientMetricsPlugin()]
});
const metrics = KilpiClient.$getMetrics();
console.log("Cache hit rate:", metrics.cacheHits / metrics.totalRequests);
Summary and Integration
Kilpi provides a comprehensive solution for implementing authorization in TypeScript applications with support for both server-side and client-side authorization checks. The framework excels in full-stack scenarios where you need to enforce authorization at multiple layers: protecting API endpoints and database queries on the server while also providing responsive UI that reflects user permissions. Common use cases include multi-tenant SaaS applications where different users have varying access levels to resources, content management systems with complex permission hierarchies, and collaborative platforms where access control is based on both roles and resource ownership.
The integration pattern follows a clear separation between server and client implementations. On the server, you define your authorization policies in createKilpi() with the getSubject() function connecting to your authentication provider. These policies evaluate permissions based on the subject (authenticated user) and optionally a resource object. The server exposes an HTTP endpoint via EndpointPlugin which the client SDK communicates with. On the client side, createKilpiClient() provides the same policy interface but makes requests to the server endpoint, intelligently batching multiple checks together and caching results. React integrations provide both server components (<Authorize />) and client hooks (useAuthorize()) for conditional rendering, creating a seamless authorization experience across the full stack. The plugin architecture allows extending both core and client functionality for custom requirements like audit logging, rate limiting, or specialized caching strategies.