Structured RBAC for Node.js and React. Permission keys with meaning, role inheritance that handles diamonds and cycles, UI-aware mappings, multi-tenant support, and a headless React SDK — works with any HTTP framework, and any database (MongoDB via Mongoose, PostgreSQL / MySQL / SQLite / SQL Server via Prisma).
npm install @permx/core # backend (zero deps)
npm install @permx/react # frontend — React SDK
npm install @permx/prisma @prisma/client # Prisma adapter (PostgreSQL, MySQL, SQLite)
# or:
npm install @permx/core mongoose # Mongoose adapter (MongoDB)
Most RBAC libraries give you flat string permissions ("read:users") or abstract policy languages. When your app grows, you end up with:
- Hundreds of unstructured permission strings with no consistent naming
- Authorization logic scattered across middleware, controllers, and frontend code
- No connection between backend permissions and what the UI should show or hide
- SaaS subscription tiers bolted on as a separate system from role permissions
- Middleware locked to Express, unusable with Fastify, Hono, or Next.js
PermX solves these with structured permission keys (module.resource:field.action.scope), UI mappings baked into each permission, a three-layer model (roles + subscriptions + feature flags), and a framework-agnostic core with zero dependencies.
| Capability | CASL | Casbin | Permit.io | PermX |
|---|---|---|---|---|
Structured keys (module.resource:field.action.scope) |
No | No | No | Yes |
| UI mappings (routes/components/fields) | No | No | No | Yes |
| 3-layer model (regular + subscription + flags) | No | No | Partial | Yes |
| Role inheritance with DFS + cycle protection | No | Policy | Managed | Yes |
| Framework-agnostic (Express, Hono, Fastify, Koa) | Express | Yes | SaaS | Yes |
| DB-agnostic with adapter pattern | No | Yes | SaaS | Yes |
| React SDK (components, hooks, zero-dep store) | <Can> only |
No | No | Full suite |
npm install @permx/core mongoose expressimport mongoose from 'mongoose';
import { createPermX } from '@permx/core/mongoose';
await mongoose.connect('mongodb://localhost:27017/myapp');
const permx = createPermX({
connection: mongoose.connection,
});
// Create collections and indexes
await permx.migrate();Before authorize() can return true, you need a module, a permission, a role, and a user-role assignment. PermX ships a declarative, idempotent seeder — safe to run on every app boot:
import { syncFromConfig } from '@permx/core/mongoose';
await syncFromConfig(permx.models, {
modules: [
{ name: 'Projects', slug: 'projects' },
],
permissions: [
{ moduleSlug: 'projects', name: 'View Tasks', key: 'projects.tasks.view.all' },
{ moduleSlug: 'projects', name: 'Create Tasks', key: 'projects.tasks.create.all' },
],
roles: [
{
name: 'Project Viewer',
slug: 'project-viewer',
permissionKeys: ['projects.tasks.view.all'],
},
{
name: 'Project Editor',
slug: 'project-editor',
permissionKeys: ['projects.tasks.create.all'],
inheritsFrom: ['project-viewer'], // inherits view permissions too
},
],
});
// Assign the role to a user (also idempotent)
import { ensureUserRole } from '@permx/core/mongoose';
const role = await permx.models.Role.findOne({ slug: 'project-editor' });
await ensureUserRole(permx.models, userId, role._id.toString());All helpers are upsert-based and cross-reference by slug / key — no id wiring needed. For one-off operations use upsertModule, upsertPermission, upsertRole, and ensureUserRole individually.
import { createPermXMiddleware } from '@permx/core/express';
const auth = createPermXMiddleware(permx, {
extractUserId: (req) => req.user?.id,
});
app.get('/projects', auth.authorize('projects.tasks.view.all'), listProjects);
app.post('/projects', auth.authorize('projects.tasks.create.all'), createProject);
app.delete('/users/:id', auth.authorize('admin.users.delete.all'), deleteUser);// Direct check
const result = await permx.authorize(userId, 'projects.tasks.view.all');
// → { authorized: true }
// Get all effective permissions (for frontend)
const perms = await permx.getUserPermissions(userId);
// → { permissions: [...], ui_mappings: { routes, components, fields }, modules }PermX is auth-agnostic — it only needs a userId. Bring your own JWT / session / cookie layer; plug the user ID into one route:
// backend: Express route that the React SDK calls
app.get('/api/permissions/my', async (req, res) => {
try {
const perms = await permx.getUserPermissions(req.user.id, {
tenantId: req.headers['x-tenant-id'] as string | undefined,
});
res.json(perms); // { permissions, ui_mappings, modules }
} catch (error) {
res.status(500).json({ error: 'Failed to load permissions' });
}
});That's it — every frontend gate below consumes this single endpoint.
npm install @permx/reactWrap your app once, then gate components, fields, and routes declaratively:
import { PermXProvider, Can, CanField, RouteGuard, FeatureGate } from '@permx/react';
function App() {
return (
<PermXProvider
fetchPermissions={() => fetch('/api/permissions/my').then((r) => r.json())}
superAdmin={user.role === 'super-admin'}
fallback={<Spinner />}
>
{/* Component-level gate */}
<Can componentId="edit-project-btn">
<EditButton />
</Can>
{/* Field-level gate (hides form field) */}
<CanField fieldId="salary">
<SalaryInput />
</CanField>
{/* Route-level gate (pair with router redirect) */}
<RouteGuard routeId="/admin" fallback={<NoAccess />}>
<AdminPage />
</RouteGuard>
{/* Feature gate with upgrade-prompt overlay */}
<FeatureGate
permission="subscription.sso"
renderOverlay={() => <UpgradeBanner plan="pro" />}
>
<SSOSettings />
</FeatureGate>
</PermXProvider>
);
}Or use hooks for programmatic checks:
import { useHasPermission, useHasRoute, usePermXReady } from '@permx/react';
function Toolbar() {
const ready = usePermXReady();
const canEdit = useHasPermission('projects.tasks.update.own');
const canViewAdmin = useHasRoute('/admin');
if (!ready) return <Spinner />;
return (
<nav>
{canEdit && <EditButton />}
{canViewAdmin && <a href="/admin">Admin</a>}
</nav>
);
}The React SDK is ~5 KB, has zero runtime dependencies (built on React's useSyncExternalStore), works with React 18 and 19, and is router-agnostic. See packages/react/README.md for the full API reference.
Permissions follow the format: {module}.{resource}:{field}.{action}.{scope}
import { buildDerivedKey, parsePermissionKey } from '@permx/core';
// Field-level access control: only certain roles can see revenue data
buildDerivedKey({
module: 'analytics',
resource: 'reports',
action: 'view',
scope: 'own',
field: 'revenue',
});
// → "analytics.reports:revenue.view.own"
parsePermissionKey('analytics.reports:revenue.view.own');
// → { module: 'analytics', resource: 'reports', field: 'revenue', action: 'view', scope: 'own' }Actions: view, create, update, delete, manage
Scopes: all, own, team, department, self, public, admin
Define permissions once and get autocomplete + literal types everywhere — no magic strings:
import { definePermissions, type PermissionKeyOf } from '@permx/core';
export const P = definePermissions({
projectsView: { module: 'projects', resource: 'tasks', action: 'view', scope: 'all' },
projectsEdit: { module: 'projects', resource: 'tasks', action: 'update', scope: 'own' },
viewSalary: { module: 'people', resource: 'employees', action: 'view', scope: 'own', field: 'salary' },
} as const);
// Inferred as the literal string "projects.tasks.view.all"
P.projectsView;
// A union of every key you defined — use it to type middleware args
export type AppPermission = PermissionKeyOf<typeof P>;
await permx.authorize(userId, P.projectsView); // fully typed, autocomplete works
app.get('/tasks', auth.authorize(P.projectsView), listTasks);Benefits: refactor a permission in one place, get a compile error everywhere it was used; IDE autocomplete across the app.
Roles can inherit permissions from parent roles. PermX uses DFS with diamond and cycle protection:
Owner (full access)
└── Admin (inherits Owner's management permissions)
├── Editor (inherits Admin — can edit content)
└── Billing Manager (inherits Admin — can manage payments)
└── Viewer (inherits from both — diamond handled)
Each permission can map to routes, components, and fields — enabling frontend gating without hardcoding access rules in your UI:
// Permission "billing.invoices.view.all" maps to:
{
ui_mappings: [
{ type: 'route', identifier: '/billing' },
{ type: 'component', identifier: 'invoice-table' },
{ type: 'field', identifier: 'payment-amount' },
]
}
// Frontend receives pre-computed arrays:
const perms = await permx.getUserPermissions(userId);
perms.ui_mappings.routes; // ['/billing', '/dashboard', '/settings']
perms.ui_mappings.components; // ['invoice-table', 'export-btn', 'user-list']
perms.ui_mappings.fields; // ['payment-amount', 'api-key', 'revenue']For SaaS apps, permissions come from three independent sources:
Effective = Regular Roles ∪ Subscription Roles ∪ Feature Flags
Regular Roles: Job-function access (per-user assignment)
Subscription: Tenant plan features (per-tenant, shared by all users)
Feature Flags: Gradual rollout capabilities (per-tenant)
Example: A user with the "Editor" role on the "Pro" plan gets:
- Editor permissions (create/update posts, manage media)
- Pro plan features (analytics dashboard, API access, custom domains)
- Feature flags (beta AI assistant, new editor UI)
Wiring to Stripe (or any billing provider):
// 1. Create a "subscription" role per plan tier (idempotent seed)
await syncFromConfig(permx.models, {
roles: [
{ name: 'Free Plan', slug: 'plan-free', role_type: 'subscription',
permissionKeys: ['posts.content.view.all'] },
{ name: 'Pro Plan', slug: 'plan-pro', role_type: 'subscription',
permissionKeys: ['posts.content.view.all', 'analytics.dashboards.view.all'] },
],
});
// 2. Tell PermX how to resolve a tenant's active plan at request time
const permx = createPermX({
connection: mongoose.connection,
subscriptionResolver: async (tenantId) => {
const tenant = await TenantModel.findById(tenantId).lean();
const planRole = await permx.models.Role.findOne({ slug: tenant.planSlug }).lean();
return planRole ? [planRole._id.toString()] : [];
},
});
// 3. When Stripe sends a webhook (checkout.session.completed, customer.subscription.updated),
// just update the tenant's planSlug — PermX picks it up on the next request.
await TenantModel.updateOne({ _id: tenantId }, { $set: { planSlug: 'plan-pro' } });The resolver is called per authorization; wrap it with the built-in cache (cache: { ttl: 15_000 }) to avoid per-request DB hits.
| Import | Purpose | Dependencies |
|---|---|---|
@permx/core |
Core types, engine, utilities | Zero |
@permx/core/mongoose |
MongoDB adapter + schema factory | mongoose |
@permx/core/express |
Express middleware | express |
@permx/prisma |
Prisma adapter (PostgreSQL, MySQL, SQLite, SQL Server) | @prisma/client (peer) |
@permx/react |
React components, hooks, and zero-dep store | react (peer) |
import {
// Key utilities
buildDerivedKey,
parsePermissionKey,
definePermissions,
type PermissionKeyOf,
// Engine (for custom adapters)
resolveRolePermissions,
detectCircularInheritance,
matchPathPattern,
createPermXCore,
// Framework-agnostic authorization
handleAuthorization,
handleApiAuthorization,
type AuthorizationRequest,
type AuthorizationOutcome,
// Cache
TtlCache,
// Errors
PermXError,
PermissionDeniedError,
CircularInheritanceError,
// Types
type Permission,
type Role,
type Module,
type UserRole,
type PermXDataProvider,
type PermXConfig,
type EffectivePermissions,
type AuthResult,
// Constants
PERMISSION_ACTIONS,
PERMISSION_SCOPES,
ROLE_TYPES,
} from '@permx/core';import {
createPermX,
createPermXSchemas,
MongooseDataProvider,
tenantPlugin,
// Idempotent seed helpers (safe to run on every boot)
syncFromConfig,
upsertModule,
upsertPermission,
upsertRole,
ensureUserRole,
type MongoosePermXConfig,
type MongoosePermXInstance,
type SchemaFactoryConfig,
type PermXModels,
} from '@permx/core/mongoose';Drop-in adapter for PostgreSQL, MySQL, SQLite, SQL Server, or MongoDB via Prisma. Copy the models from the shipped schema.prisma, run prisma migrate, and wire it up in 3 lines:
import { PrismaClient } from '@prisma/client';
import { createPermX, syncFromConfig } from '@permx/prisma';
const prisma = new PrismaClient();
const permx = createPermX({
prisma,
cache: { ttl: 15_000 },
tenancy: { enabled: true },
superAdmin: { check: (userId) => userId === 'admin' },
});
await syncFromConfig(prisma, {
modules: [{ name: 'Projects', slug: 'projects' }],
permissions: [
{ moduleSlug: 'projects', name: 'View', key: 'projects.tasks.view.all' },
],
roles: [{ name: 'Viewer', slug: 'viewer', permissionKeys: ['projects.tasks.view.all'] }],
});
await permx.authorize(userId, 'projects.tasks.view.all');Full exports (PrismaDataProvider, syncFromConfig, upsertModule, upsertPermission, upsertRole, ensureUserRole, setRolePermissions, setRoleInheritance, PrismaClientLike) — see packages/prisma/README.md.
import {
createPermXMiddleware,
type PermXMiddleware,
type PermXMiddlewareConfig,
} from '@permx/core/express';import {
// Provider
PermXProvider,
// Gate components (headless — no CSS bundled)
Can,
CanField,
RouteGuard,
FeatureGate,
// Hooks
useHasPermission,
useHasRoute,
useHasComponent,
useHasField,
usePermissions,
usePermXReady,
// Advanced (custom providers, testing, SSR hydration)
createPermissionStore,
PermissionStore,
// Types
type PermXProviderProps,
type CanProps,
type CanFieldProps,
type RouteGuardProps,
type FeatureGateProps,
type PermissionState,
} from '@permx/react';import { createPermX } from '@permx/core/mongoose';
const permx = createPermX({
// Required: your Mongoose connection
connection: mongoose.connection,
// Optional: rename collections (default: PermX_Module, PermX_Permission, etc.)
collections: {
module: 'acl_modules',
permission: 'acl_permissions',
role: 'acl_roles',
userRole: 'acl_user_roles',
},
// Optional: extend schemas with custom fields
extend: {
role: { department: { type: String } },
userRole: { notes: { type: String } },
},
// Optional: multi-tenancy
tenancy: {
enabled: true,
tenantIdField: 'tenantId', // default
exemptModels: ['module', 'permission'], // global (not per-tenant)
},
// Optional: subscription-based permissions (SaaS)
subscriptionResolver: async (tenantId) => {
const tenant = await TenantModel.findById(tenantId);
return tenant?.planPermissionIds ?? [];
},
// Optional: super-admin bypass
superAdmin: {
check: (userId) => userId === 'admin-user-id',
},
// Optional: API permission map cache
cache: { ttl: 15_000 },
});import { createPermXMiddleware } from '@permx/core/express';
const auth = createPermXMiddleware(permx, {
// Required: how to get user ID from the request
extractUserId: (req) => req.user?.id,
// Optional: tenant context for multi-tenant apps
extractTenantId: (req) => req.headers['x-tenant-id'] as string,
// Optional: service-to-service bypass
isServiceCall: (req) => req.headers['x-api-key'] === process.env.SERVICE_KEY,
// Optional: super-admin bypass at middleware level
isSuperAdmin: (req) => req.user?.role === 'super-admin',
// Optional: custom denied response
onDenied: (req, res, permissionKey) => {
res.status(403).json({
error: 'Forbidden',
required_permission: permissionKey,
});
},
// Optional: custom error response
onError: (req, res, error) => {
res.status(500).json({ error: 'Authorization service unavailable' });
},
});
// Per-route authorization
router.get('/projects', auth.authorize('projects.tasks.view.all'), handler);
router.post('/billing/invoices', auth.authorize('billing.invoices.create.all'), handler);
// Gateway-style API mapping authorization
router.use(auth.authorizeApi('project-service'));The core package exports handleAuthorization and handleApiAuthorization — pure async functions that work with any HTTP framework (Hono, Fastify, Koa, Next.js, etc.):
import {
handleAuthorization,
handleApiAuthorization,
type AuthorizationRequest,
type AuthorizationOutcome,
} from '@permx/core';
// 1. Map your framework's request to AuthorizationRequest
const request: AuthorizationRequest = {
userId: getUserIdFromYourFramework(),
tenantId: getTenantIdFromYourFramework(),
isServiceCall: false,
isSuperAdmin: false,
};
// 2. Call the handler
const outcome = await handleAuthorization(permx, request, 'projects.tasks.view.all');
// 3. Map the outcome to your framework's response
if (outcome.action === 'allow') { /* next() */ }
if (outcome.action === 'deny') { /* 403 response */ }
if (outcome.action === 'error') { /* 500 response */ }The Express middleware (@permx/core/express) is a thin wrapper around these functions. See examples/hono-adapter.ts for a complete Hono adapter in ~20 lines.
PermX runs in Next.js Route Handlers, Server Components, and Middleware. Full reference: examples/nextjs-app-router.ts.
Route Handler (app/api/permissions/my/route.ts) — the endpoint the React SDK calls:
import { NextResponse } from 'next/server';
import { permx } from '@/lib/permx';
import { getUserIdFromSession } from '@/lib/auth';
export async function GET(req: Request) {
const userId = await getUserIdFromSession(req);
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const perms = await permx.getUserPermissions(userId);
return NextResponse.json(perms);
}Server Component — gate pages server-side (no client-side flash):
// app/admin/page.tsx
import { redirect } from 'next/navigation';
import { permx } from '@/lib/permx';
import { getUserIdFromSession } from '@/lib/auth';
export default async function AdminPage() {
const userId = await getUserIdFromSession();
const { authorized } = await permx.authorize(userId, 'admin.users.view.all');
if (!authorized) redirect('/403');
return <AdminDashboard />;
}Middleware — block unauthorized routes before the page renders:
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { handleAuthorization } from '@permx/core';
import { permx } from '@/lib/permx';
export async function middleware(req: NextRequest) {
const userId = req.cookies.get('userId')?.value;
if (!userId) return NextResponse.redirect(new URL('/login', req.url));
const outcome = await handleAuthorization(permx, { userId }, 'admin.dashboard.view.all');
if (outcome.action === 'deny') return NextResponse.redirect(new URL('/403', req.url));
return NextResponse.next();
}
export const config = { matcher: ['/admin/:path*'] };Client Component — @permx/react works unchanged inside a "use client" boundary; just point fetchPermissions at the Route Handler above.
PermX caches effective permissions per user (and per tenant) for the configured TTL. When an admin changes a user's role, call the invalidation API so the next authorize() sees fresh data — don't wait for the TTL:
const permx = createPermX({
connection: mongoose.connection,
cache: { ttl: 60_000 }, // 60-second TTL
});
// Admin changes a user's role:
await ensureUserRole(permx.models, targetUserId, newRoleId);
permx.invalidateUser(targetUserId); // clear that user's cache
// or with multi-tenancy:
permx.invalidateUser(targetUserId, { tenantId: 'acme' });
// Bulk permission / role schema change:
permx.invalidateAll(); // nuke user + api-map cachesTelling the frontend to refetch — the React SDK caches the /api/permissions/my response. After a role change, either:
- Call
usePermXRefresh()and invoke the returnedrefresh()function, or - Reload the page / re-mount
<PermXProvider>, or - Push an SSE / WebSocket event from your backend after
invalidateUser()and triggerrefresh()from the listener.
import { usePermXRefresh, usePermXError } from '@permx/react';
function RoleEditor({ userId }: { userId: string }) {
const refresh = usePermXRefresh();
const error = usePermXError();
async function onSave(roleId: string) {
await fetch(`/api/users/${userId}/roles`, { method: 'POST', body: JSON.stringify({ roleId }) });
await refresh(); // pick up new permissions without reload
}
return <>{error && <ErrorBanner error={error} onRetry={refresh} />}...</>;
}For multi-instance deployments, pair invalidateUser() with a pub/sub broadcast (Redis, NATS) so every app node clears its local cache.
PermX emits lifecycle events via a built-in EventEmitter. Subscribe to pipe them into your logging, metrics, or audit system:
permx.emitter?.on('authorize', ({ userId, permissionKey, authorized, duration_ms }) => {
metrics.histogram('permx.authorize.duration', duration_ms, { outcome: authorized ? 'allow' : 'deny' });
});
permx.emitter?.on('authorize.denied', ({ userId, permissionKey }) => {
auditLog.write({ kind: 'authz_denied', userId, permissionKey, at: new Date() });
});
permx.emitter?.on('cache.hit', ({ key }) => metrics.counter('permx.cache.hit').inc({ key }));
permx.emitter?.on('cache.miss', ({ key }) => metrics.counter('permx.cache.miss').inc({ key }));Available events: authorize, authorize.denied, authorize.error, cache.hit, cache.miss. Listeners run asynchronously via queueMicrotask — throwing inside a listener never breaks the authorization path.
Pass your known permission keys to dev.knownKeys and PermX will log a one-time warning any time authorize() is called with a key that isn't in the set — catching typos before they become silent denials.
import { definePermissions } from '@permx/core';
export const P = definePermissions({
viewTasks: { module: 'projects', resource: 'tasks', action: 'view', scope: 'all' },
} as const);
const permx = createPermX({
connection: mongoose.connection,
dev: process.env.NODE_ENV !== 'production'
? { knownKeys: Object.values(P) }
: undefined,
});
// If your code accidentally calls:
await permx.authorize(userId, 'projects.tasks.view.typo');
// → console.warn: [permx] authorize() called with unknown permission key 'projects.tasks.view.typo'Warnings are deduplicated per key. Omit dev.knownKeys in production for zero overhead.
| Task | CASL | Casbin | PermX |
|---|---|---|---|
| Define a permission | defineAbility((can) => can('read', 'Post')) |
policy CSV: p, alice, data, read |
definePermissions({ viewPost: { module: 'posts', resource: 'items', action: 'view', scope: 'all' }}) |
| Check a permission | ability.can('read', 'Post') |
enforcer.enforce(user, 'data', 'read') |
permx.authorize(userId, 'posts.items.view.all') |
| Field-level access | custom field conditions | policy + matcher | module.resource:field.action.scope — built in |
| Multi-tenancy | DIY | DIY | tenancy: { enabled: true } |
| Subscription tiers | DIY | DIY | subscriptionResolver(tenantId) |
| Gate a React component | <Can I="read" a="Post"> |
none | <Can componentId="post-editor"> |
| Gate a route | DIY | DIY | <RouteGuard routeId="/admin"> |
The mental shift: CASL encodes subject + verb (read Post); PermX encodes location in the app (posts.items.view.all) so UI mappings and role inheritance work uniformly across backend and frontend.
PermX's core engine is database-agnostic. To use a different database, implement the PermXDataProvider interface:
import { createPermXCore, type PermXDataProvider } from '@permx/core';
class PrismaDataProvider implements PermXDataProvider {
async getUserRoles(userId: string) { /* Prisma queries */ }
async getRoleForResolution(roleId: string) { /* Prisma queries */ }
async getPermissionsByIds(ids: string[]) { /* Prisma queries */ }
async getModulesByIds(ids: string[]) { /* Prisma queries */ }
async getApiPermissionMap() { /* Prisma queries */ }
}
const permx = createPermXCore(new PrismaDataProvider(), {
cache: { ttl: 15_000 },
superAdmin: { check: (userId) => userId === 'admin' },
});This is a monorepo with two published packages:
packages/core/ → @permx/core (zero deps)
├── types/ 8 type definition files
├── engine/ Permission key parser, DFS resolver, circular detector, path matcher
├── middleware/
│ └── handler.ts Framework-agnostic handleAuthorization + handleApiAuthorization
├── cache/ Generic TTL cache
├── errors.ts Error class hierarchy
├── permx.ts createPermXCore() factory
├── mongoose/ (peer: mongoose) Schema factory, data provider, tenant plugin
└── middleware/ (peer: express) Thin Express wrapper over handler.ts
packages/react/ → @permx/react (peer: react, zero runtime deps)
├── store.ts PermissionStore class with useSyncExternalStore-compatible API
├── context.ts React context + internal usePermXStore hook
├── provider.tsx <PermXProvider> — fetches on mount, hydrates store
├── components/ <Can>, <CanField>, <RouteGuard>, <FeatureGate> (headless)
└── hooks/ useHasPermission, useHasRoute, useHasComponent, useHasField,
usePermissions, usePermXReady
# Install dependencies for the whole workspace
bun install
# Run tests for all packages (261 tests total)
bun run test
# Run just one package
bun run test:core # @permx/core — 204 tests
bun run test:react # @permx/react — 57 tests
# Build all packages (dual CJS/ESM)
bun run build
# Type check and lint the whole monorepo
bun run typecheck
bun run lintMIT