-
-
+ |
+
{children}
|
@@ -42,4 +39,4 @@ export const Button = ({
);
};
-export default Button;
\ No newline at end of file
+export default Button;
diff --git a/packages/email/components/footer.tsx b/packages/email/components/footer.tsx
index 5a8801c1e..747720943 100644
--- a/packages/email/components/footer.tsx
+++ b/packages/email/components/footer.tsx
@@ -1,5 +1,5 @@
-import { Hr } from "@react-email/components";
import { META } from "@captable/utils/constants";
+import { Hr } from "@react-email/components";
import { Link } from "./link";
import { Text } from "./text";
@@ -22,14 +22,14 @@ export const Footer = ({
)}
{customText ? (
-
+
{customText}
) : (
-
+
{customLinkText || META.title}
)}
@@ -37,4 +37,4 @@ export const Footer = ({
);
};
-export default Footer;
\ No newline at end of file
+export default Footer;
diff --git a/packages/email/components/heading.tsx b/packages/email/components/heading.tsx
index fe8ac8f33..a95978e19 100644
--- a/packages/email/components/heading.tsx
+++ b/packages/email/components/heading.tsx
@@ -19,4 +19,4 @@ export const Heading = ({
);
};
-export default Heading;
\ No newline at end of file
+export default Heading;
diff --git a/packages/email/components/layout.tsx b/packages/email/components/layout.tsx
index 9c76fafbd..59022a045 100644
--- a/packages/email/components/layout.tsx
+++ b/packages/email/components/layout.tsx
@@ -2,9 +2,9 @@ import {
Body,
Container,
Head,
- Html as ReactEmailHtml,
Img,
Preview,
+ Html as ReactEmailHtml,
Tailwind,
} from "@react-email/components";
import type * as React from "react";
@@ -18,33 +18,33 @@ export interface LayoutProps {
}
export const Layout = ({
- children,
- preview,
- logoUrl = "https://cdn.captableinc.com/logo/100.png", // Default logo URL
- logoAlt = "Captable Logo",
- containerClassName = "mx-auto my-[40px] max-w-[465px] border-separate rounded border border-solid border-neutral-200 p-[20px]",
+ children,
+ preview,
+ logoUrl = "https://cdn.captableinc.com/logo/100.png", // Default logo URL
+ logoAlt = "Captable Logo",
+ containerClassName = "mx-auto my-[40px] max-w-[465px] border-separate rounded border border-solid border-neutral-200 p-[20px]",
}: LayoutProps) => {
- return (
-
-
- {preview && {preview}}
-
-
-
- {/* Centered Logo */}
-
- 
-
- {children}
-
-
-
-
- );
+ return (
+
+
+ {preview && {preview}}
+
+
+
+ {/* Centered Logo */}
+
+ 
+
+ {children}
+
+
+
+
+ );
};
-export default Layout;
\ No newline at end of file
+export default Layout;
diff --git a/packages/email/components/link.tsx b/packages/email/components/link.tsx
index 8f314fd53..6baf6844a 100644
--- a/packages/email/components/link.tsx
+++ b/packages/email/components/link.tsx
@@ -21,7 +21,7 @@ export const Link = ({
variant = "primary",
}: LinkProps) => {
const baseClassName = className || variantClasses[variant];
-
+
return (
{children}
@@ -29,4 +29,4 @@ export const Link = ({
);
};
-export default Link;
\ No newline at end of file
+export default Link;
diff --git a/packages/email/components/text.tsx b/packages/email/components/text.tsx
index 7b99bc2e9..c49c48839 100644
--- a/packages/email/components/text.tsx
+++ b/packages/email/components/text.tsx
@@ -13,18 +13,10 @@ const variantClasses = {
muted: "text-[12px] leading-[24px] text-[#666666]",
};
-export const Text = ({
- children,
- className,
- variant = "body",
-}: TextProps) => {
+export const Text = ({ children, className, variant = "body" }: TextProps) => {
const baseClassName = className || variantClasses[variant];
-
- return (
-
- {children}
-
- );
+
+ return {children};
};
-export default Text;
\ No newline at end of file
+export default Text;
diff --git a/packages/email/index.ts b/packages/email/index.ts
index cd7501cb7..a0454b576 100644
--- a/packages/email/index.ts
+++ b/packages/email/index.ts
@@ -1,7 +1,10 @@
import type { ReactElement } from "react";
// Dynamic import for React Email render function to avoid ES module issues
-export async function render(component: ReactElement, options?: { pretty?: boolean; plainText?: boolean }) {
+export async function render(
+ component: ReactElement,
+ options?: { pretty?: boolean; plainText?: boolean },
+) {
const { render: reactEmailRender } = await import("@react-email/components");
return reactEmailRender(component, options);
}
diff --git a/packages/email/templates/account-verification-email.tsx b/packages/email/templates/account-verification-email.tsx
index 731c233bd..648978837 100644
--- a/packages/email/templates/account-verification-email.tsx
+++ b/packages/email/templates/account-verification-email.tsx
@@ -1,12 +1,5 @@
import { META } from "@captable/utils/constants";
-import {
- Layout,
- Heading,
- Text,
- Button,
- Link,
- Footer,
-} from "../components";
+import { Button, Footer, Heading, Layout, Link, Text } from "../components";
export interface AccountVerificationEmailProps {
verifyLink: string;
@@ -16,21 +9,17 @@ export const AccountVerificationEmail = ({
verifyLink,
}: AccountVerificationEmailProps) => (
-
- Your verification email link for {META.title}
-
-
-
-
+ Your verification email link for {META.title}
+
+
+
or copy and paste this URL into your browser:{" "}
{verifyLink}
-
+
);
diff --git a/packages/email/templates/esign-confirmation-email.tsx b/packages/email/templates/esign-confirmation-email.tsx
index 44fcd4408..59b61f160 100644
--- a/packages/email/templates/esign-confirmation-email.tsx
+++ b/packages/email/templates/esign-confirmation-email.tsx
@@ -1,10 +1,4 @@
-import {
- Layout,
- Heading,
- Text,
- Button,
- Footer,
-} from "../components";
+import { Button, Footer, Heading, Layout, Text } from "../components";
export type EsignConfirmationEmailPayloadType = {
fileUrl: string;
@@ -18,19 +12,21 @@ export type EsignConfirmationEmailPayloadType = {
recipient: { name?: string | null; email: string };
};
-export type EsignConfirmationEmailProps = Omit;
+export type EsignConfirmationEmailProps = Omit<
+ EsignConfirmationEmailPayloadType,
+ "fileUrl"
+>;
const ESignConfirmationEmail = ({
documentName,
recipient,
senderName,
- senderEmail,
company,
}: EsignConfirmationEmailProps) => {
const previewText = `${senderName ?? ""} has sent you a confirmation email with completed signed document.`;
-
+
return (
-
All parties have completed and signed the document -{" "}
- {documentName}. Please find the attached
- document.
+ {documentName}. Please find the attached document.
diff --git a/packages/email/templates/esign-email.tsx b/packages/email/templates/esign-email.tsx
index 7c043136f..f208b9e64 100644
--- a/packages/email/templates/esign-email.tsx
+++ b/packages/email/templates/esign-email.tsx
@@ -1,11 +1,4 @@
-import {
- Layout,
- Heading,
- Text,
- Button,
- Link,
- Footer,
-} from "../components";
+import { Button, Footer, Heading, Layout, Link, Text } from "../components";
export interface EsignEmailPayloadType {
documentName?: string;
@@ -36,9 +29,9 @@ const EsignEmail = ({
company,
}: EsignEmailProps) => {
const previewText = `${sender?.name ?? ""} has sent you a document to sign.`;
-
+
return (
-
{company?.name}
-
+
{sender?.name} has sent you a document{" "}
{`"${documentName}"`} to sign.
-
+
Hello {recipient?.name},
-
+
{message ? (
{message}
) : (
- {sender?.name} from{" "}
- {company?.name} has sent you{" "}
- {`"${documentName}"`}
+ {sender?.name} from {company?.name}{" "}
+ has sent you {`"${documentName}"`}
)}
-
+
or copy and paste this URL into your browser:{" "}
diff --git a/packages/email/templates/magic-link-email.tsx b/packages/email/templates/magic-link-email.tsx
index fdb444aa4..248a4610d 100644
--- a/packages/email/templates/magic-link-email.tsx
+++ b/packages/email/templates/magic-link-email.tsx
@@ -1,12 +1,5 @@
import { META } from "@captable/utils/constants";
-import {
- Layout,
- Heading,
- Text,
- Button,
- Link,
- Footer,
-} from "../components";
+import { Button, Footer, Heading, Layout, Link, Text } from "../components";
export interface MagicLinkEmailProps {
magicLink: string;
@@ -14,21 +7,17 @@ export interface MagicLinkEmailProps {
export const MagicLinkEmail = ({ magicLink }: MagicLinkEmailProps) => (
-
- Your magic link for {META.title}
-
-
-
-
+ Your magic link for {META.title}
+
+
+
or copy and paste this URL into your browser:{" "}
{magicLink}
-
+
);
diff --git a/packages/email/templates/member-invite-email.tsx b/packages/email/templates/member-invite-email.tsx
index f6d259cd7..73724a011 100644
--- a/packages/email/templates/member-invite-email.tsx
+++ b/packages/email/templates/member-invite-email.tsx
@@ -1,12 +1,5 @@
import { META } from "@captable/utils/constants";
-import {
- Layout,
- Heading,
- Text,
- Button,
- Link,
- Footer,
-} from "../components";
+import { Button, Footer, Heading, Layout, Link, Text } from "../components";
export interface MemberInviteEmailProps {
invitedBy: string;
@@ -24,21 +17,18 @@ export const MemberInviteEmail = ({
return (
- Join {companyName} on{" "}
- {META.title}
+ Join {companyName} on {META.title}
-
+
Hello,
-
+
{invitedBy} has invited you to join{" "}
{companyName} on Captable, Inc..
-
-
+
+
or copy and paste this URL into your browser:{" "}
diff --git a/packages/email/templates/password-reset-email.tsx b/packages/email/templates/password-reset-email.tsx
index be2cb05a3..8b5b99d60 100644
--- a/packages/email/templates/password-reset-email.tsx
+++ b/packages/email/templates/password-reset-email.tsx
@@ -1,12 +1,5 @@
import { META } from "@captable/utils/constants";
-import {
- Layout,
- Heading,
- Text,
- Button,
- Link,
- Footer,
-} from "../components";
+import { Button, Footer, Heading, Layout, Link, Text } from "../components";
export interface PasswordResetEmailProps {
resetLink: string;
@@ -14,21 +7,17 @@ export interface PasswordResetEmailProps {
export const PasswordResetEmail = ({ resetLink }: PasswordResetEmailProps) => (
-
- Your password reset link for {META.title}
-
-
-
-
+ Your password reset link for {META.title}
+
+
+
or copy and paste this URL into your browser:{" "}
{resetLink}
-
+
);
diff --git a/packages/email/templates/share-data-room-email.tsx b/packages/email/templates/share-data-room-email.tsx
index 932e888e6..0d694bba4 100644
--- a/packages/email/templates/share-data-room-email.tsx
+++ b/packages/email/templates/share-data-room-email.tsx
@@ -1,12 +1,5 @@
import { META } from "@captable/utils/constants";
-import {
- Layout,
- Heading,
- Text,
- Button,
- Link,
- Footer,
-} from "../components";
+import { Button, Footer, Heading, Layout, Link, Text } from "../components";
export interface ShareDataRoomEmailProps {
senderName: string;
@@ -31,17 +24,15 @@ export const ShareDataRoomEmail = ({
{companyName} - {dataRoom}
-
+
Hello {recipientFirstName},
-
+
{senderName} has shared a data room{" "}
{dataRoom} on {META.title}
-
+
or copy and paste this URL into your browser:{" "}
@@ -51,7 +42,7 @@ export const ShareDataRoomEmail = ({
-
+
Powered by
{` ${META.title}`}
diff --git a/packages/email/templates/share-update-email.tsx b/packages/email/templates/share-update-email.tsx
index 06d694cfd..787694296 100644
--- a/packages/email/templates/share-update-email.tsx
+++ b/packages/email/templates/share-update-email.tsx
@@ -1,12 +1,5 @@
import { META } from "@captable/utils/constants";
-import {
- Layout,
- Heading,
- Text,
- Button,
- Link,
- Footer,
-} from "../components";
+import { Button, Footer, Heading, Layout, Link, Text } from "../components";
export interface ShareUpdateEmailProps {
senderName: string;
@@ -31,9 +24,9 @@ export const ShareUpdateEmail = ({
{companyName} - {updateTitle}
-
+
Hello {recipientFirstName},
-
+
{senderName} has shared an update{" "}
{updateTitle} on {META.title}
@@ -51,7 +44,7 @@ export const ShareUpdateEmail = ({
-
+
Powered by
{` ${META.title}`}
diff --git a/packages/logger/biome.json b/packages/logger/biome.json
deleted file mode 100644
index 86326ab92..000000000
--- a/packages/logger/biome.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "extends": ["../config/biome.json"]
-}
diff --git a/packages/logger/index.ts b/packages/logger/index.ts
index 21b2ecbef..3f75d4f64 100644
--- a/packages/logger/index.ts
+++ b/packages/logger/index.ts
@@ -15,7 +15,7 @@ const createLogger = (): Logger => {
},
},
});
- } catch (error) {
+ } catch (_error) {
// Fallback to basic logger if pino-pretty is not available
return pino({
level: "debug",
diff --git a/packages/rbac/.gitignore b/packages/rbac/.gitignore
new file mode 100644
index 000000000..23bfe49c8
--- /dev/null
+++ b/packages/rbac/.gitignore
@@ -0,0 +1,2 @@
+dist/
+node_modules/
\ No newline at end of file
diff --git a/packages/rbac/README.md b/packages/rbac/README.md
new file mode 100644
index 000000000..a0a75ac86
--- /dev/null
+++ b/packages/rbac/README.md
@@ -0,0 +1,533 @@
+# @captable/rbac
+
+A flexible Role-Based Access Control (RBAC) library for React applications with TypeScript support.
+
+## Features
+
+- 🔒 **Type-safe** RBAC implementation with TypeScript
+- ⚛️ **React Components** for conditional rendering
+- 🎣 **React Hooks** for permission checking
+- 🖥️ **Server-side** utilities for access control
+- 🚀 **Zero dependencies** (except React for components)
+- 📦 **Tree-shakeable** with separate client/server exports
+- 🛠️ **Utility helpers** for common patterns
+- 🔧 **Generic factory pattern** with dependency injection for maximum reusability
+
+## Installation
+
+```bash
+npm install @captable/rbac
+# or
+yarn add @captable/rbac
+# or
+pnpm add @captable/rbac
+```
+
+## Core Concepts
+
+### Subject
+The subject or subject type which you want to check user action on. Usually this is a business (or domain) entity (e.g., billing, roles, members). Subjects can be added in the `@captable/rbac` package.
+
+### Action
+Explains what users are able to do in the app. User actions are typically verbs determined by how the business operates. Often, these actions will include words like create, read, update, and delete. Actions can be added in the `@captable/rbac` package.
+
+## Quick Start
+
+### 1. Define your subjects and actions
+
+The package comes with default subjects and actions, but you can extend them:
+
+```typescript
+import { SUBJECTS, ACTIONS, type TSubjects, type TActions } from "@captable/rbac/types";
+
+// Default subjects: "billing", "members", "stakeholder", etc.
+// Default actions: "create", "read", "update", "delete", "*"
+```
+
+### 2. Generic RBAC Factory (Recommended)
+
+For maximum reusability, use the generic factory with dependency injection:
+
+```typescript
+import {
+ createServerAccessControlFactory,
+ type BaseMembership,
+ type RolePermissionDependencies,
+ type ServerAccessControlDependencies
+} from "@captable/rbac/utils";
+import { type TPermission } from "@captable/rbac/types";
+
+// Define your app-specific membership type
+interface MyAppMembership extends BaseMembership {
+ id: string;
+ companyId: string;
+ userId: string;
+ role: "ADMIN" | "USER" | "CUSTOM" | null;
+ customRoleId: string | null;
+}
+
+// Define permission dependencies
+const permissionDeps: RolePermissionDependencies<"ADMIN" | "USER" | "CUSTOM", Session, Database, MyAppMembership> = {
+ adminRoleValue: "ADMIN",
+ customRoleValue: "CUSTOM",
+ adminPermissions: [
+ { subject: "billing", actions: ["*"] },
+ { subject: "members", actions: ["*"] }
+ ] as TPermission[],
+ defaultPermissions: [],
+
+ // Your app-specific database queries
+ async checkMembership(session, db) {
+ const membership = await db.query.memberships.findFirst({
+ where: eq(memberships.userId, session.user.id)
+ });
+ if (!membership) return { success: false, error: new Error("No membership") };
+ return { success: true, data: membership };
+ },
+
+ async getCustomRolePermissions({ customRoleId, companyId, db }) {
+ const permissions = await db.query.rolePermissions.findMany({
+ where: and(
+ eq(rolePermissions.roleId, customRoleId),
+ eq(rolePermissions.companyId, companyId)
+ )
+ });
+ return {
+ success: true,
+ data: permissions.map(p => ({
+ subject: p.resource,
+ actions: p.actions.split(",")
+ }))
+ };
+ }
+};
+
+// Define access control dependencies
+const accessDeps: ServerAccessControlDependencies = {
+ database: myDatabase,
+ getSession: async (headers) => await getSessionFromHeaders(headers),
+ createUnauthorizedError: () => new Error("Unauthorized"),
+ createGenericError: (error) => new Error(`Access error: ${error}`)
+};
+
+// Create the factory
+const accessControlFactory = createServerAccessControlFactory(permissionDeps, accessDeps);
+
+// Use in your app
+export const serverAccessControl = accessControlFactory.serverAccessControl;
+export const getServerPermissions = accessControlFactory.getServerPermissions;
+```
+
+### 3. Create app-specific utilities (Alternative Pattern)
+
+For simpler use cases, create thin wrapper utilities for your specific role types:
+
+```typescript
+// In your app: lib/rbac-utils.ts
+import { createStandardRoleIdMapper } from "@captable/rbac";
+import type { MyAppRoleEnum } from "./types"; // Your app's role enum
+
+// Create app-specific role utilities
+export const getRoleId = createStandardRoleIdMapper({
+ adminRoleValue: "ADMIN",
+ customRoleValue: "CUSTOM",
+});
+
+// Or create a complete utils bundle
+import { createStandardRoleUtils, ADMIN_PERMISSION, DEFAULT_PERMISSION } from "@captable/rbac";
+
+export const { getRoleId, getRolePermissions } = createStandardRoleUtils({
+ adminRoleValue: "ADMIN",
+ customRoleValue: "CUSTOM",
+ adminPermissions: ADMIN_PERMISSION,
+ defaultPermissions: DEFAULT_PERMISSION,
+});
+```
+
+## Usage Examples
+
+### tRPC Procedures
+
+```typescript
+import { withAccessControl } from "@/trpc/api/trpc";
+
+export const withAccessControlProcedure = withAccessControl
+ .input(inputSchema)
+ .meta({
+ policies: {
+ members: { allow: ["create"] },
+ },
+ })
+ .mutation(({ ctx, input }) => {
+ const { membership } = ctx;
+ return { success: true };
+ });
+```
+
+### Client-side usage with React
+
+#### Using the RolesProvider
+
+```tsx
+import { RolesProvider, AllowWithProvider } from "@captable/rbac/client";
+
+function App() {
+ const permissionsMap = new Map([
+ ["billing", ["read", "create"]],
+ ["members", ["*"]]
+ ]);
+
+ return (
+
+
+
+ );
+}
+
+function Dashboard() {
+ return (
+
+
+
+
+
+
+ {(allowed) => allowed ? "Can delete members" : "Cannot delete members"}
+
+
+ );
+}
+```
+
+#### Using the standalone Allow component
+
+```tsx
+import { Allow } from "@captable/rbac/client";
+
+function ClientComponent() {
+ const permissionsContext = {
+ permissions: new Map([["billing", ["read", "create"]]])
+ };
+
+ return (
+
+
+ Allowed
+
+
+ {/* or using render props */}
+
+ {(allow) => (allow ? "allowed" : "disallowed")}
+
+
+ );
+}
+```
+
+### Server Components
+
+```tsx
+"use server";
+
+import { serverAccessControl } from "@/lib/rbac/access-control";
+import { headers } from "next/headers";
+
+const fetchDataFromServer = async () => {
+ return { data: [] };
+};
+
+async function ServerComponent() {
+ const { allow } = await serverAccessControl({ headers: await headers() });
+
+ const canRead = !!allow(true, ["billing", "read"]);
+ const data = await allow(fetchDataFromServer(), ["billing", "read"]);
+
+ return (
+
+ {canRead ? "can read" : "cannot read"}
+ {data ? data.data : null}
+
+ );
+}
+```
+
+### Server-side API usage
+
+```typescript
+import { createServerAccessControl } from "@captable/rbac/server";
+import type { TPermission } from "@captable/rbac/types";
+
+const permissions: TPermission[] = [
+ { subject: "billing", actions: ["read", "create"] },
+ { subject: "members", actions: ["*"] }
+];
+
+const accessControl = createServerAccessControl({ permissions });
+
+// Check specific permission
+const canCreateBilling = accessControl.hasPermission("billing", "create");
+
+// Conditional execution
+const result = accessControl.allow(
+ fetchBillingData(),
+ ["billing", "read"],
+ null // fallback value
+);
+
+// Policy-based checking
+const { isAllowed } = accessControl.isPermissionsAllowed({
+ billing: { allow: ["create"] }
+});
+```
+
+### Core RBAC usage
+
+```typescript
+import { RBAC } from "@captable/rbac";
+import type { TPermission } from "@captable/rbac/types";
+
+const rbac = new RBAC();
+
+// Define policies
+rbac
+ .allow("billing", "create")
+ .allow("billing", "read")
+ .deny("billing", "delete");
+
+// Or use bulk policy definition
+rbac.addPolicies({
+ billing: { allow: ["create", "read"], deny: ["delete"] },
+ members: { allow: ["*"] }
+});
+
+// Check permissions
+const permissions: TPermission[] = [
+ { subject: "billing", actions: ["create"] }
+];
+
+const result = rbac.enforce(permissions);
+console.log(result.valid); // true/false
+console.log(result.message); // descriptive message
+```
+
+### Helper Utilities
+
+You can also use the individual utility functions:
+
+```typescript
+import {
+ createStandardRoleUtils,
+ createRoleIdResolver,
+ type RoleIdResolverDependencies
+} from "@captable/rbac/utils";
+
+// Standard role utilities
+const { getRoleId, getRolePermissions } = createStandardRoleUtils({
+ adminRoleValue: "ADMIN",
+ customRoleValue: "CUSTOM",
+ adminPermissions: [...],
+ defaultPermissions: [...]
+});
+
+// Role ID resolver with database dependency
+const roleIdResolverDeps: RoleIdResolverDependencies = {
+ findCustomRole: async (id, db) => {
+ return await db.query.customRoles.findFirst({
+ where: eq(customRoles.id, id)
+ });
+ }
+};
+
+const resolveRoleId = createRoleIdResolver(
+ {
+ adminRoleValue: "ADMIN",
+ customRoleValue: "CUSTOM",
+ adminRoleId: "admin-role-id"
+ },
+ roleIdResolverDeps
+);
+
+// Usage
+const { role, customRoleId } = await resolveRoleId({
+ id: "some-role-id",
+ db: myDatabase
+});
+```
+
+## API Reference
+
+### Core Classes
+
+#### `RBAC`
+Main RBAC engine for defining and enforcing policies.
+
+**Methods:**
+- `allow(subject, action)` - Allow an action on a subject
+- `deny(subject, action)` - Deny an action on a subject
+- `addPolicies(policies)` - Bulk add policies
+- `enforce(permissions)` - Check if permissions are valid
+- `static normalizePermissionsMap(permissions)` - Convert permissions to Map
+
+### Generic Factory Functions
+
+#### `createServerAccessControlFactory(permissionDeps, accessDeps)`
+Creates a fully generic server access control factory with dependency injection.
+
+**Parameters:**
+- `permissionDeps: RolePermissionDependencies` - Permission resolution dependencies
+- `accessDeps: ServerAccessControlDependencies` - Access control dependencies
+
+**Returns:**
+- `getServerPermissions(options)` - Get permissions for a session
+- `serverAccessControl(options)` - Get access control utilities
+
+### Utility Functions
+
+#### `createStandardRoleIdMapper(options)`
+Creates a role ID mapper for common role patterns.
+
+**Options:**
+- `adminRoleValue: TRole` - Your app's admin role value (e.g., "ADMIN")
+- `customRoleValue: TRole` - Your app's custom role value (e.g., "CUSTOM")
+- `adminRoleId?: string` - Override default admin role ID
+
+**Returns:** Function that maps `{ role, customRoleId }` to string ID
+
+#### `createStandardRolePermissionMapper(options)`
+Creates a role permission mapper for common patterns.
+
+#### `createStandardRoleUtils(options)`
+Creates both role ID mapper and permission mapper with consistent config.
+
+**Returns:** `{ getRoleId, getRolePermissions }`
+
+#### `createRoleIdResolver(config, deps)`
+Creates a role ID resolver with database dependency injection.
+
+### Client Components
+
+#### `AllowWithProvider`
+Renders children conditionally based on permissions from RolesProvider.
+
+**Props:**
+- `subject: TSubjects` - The subject to check
+- `action: TActions` - The action to check
+- `children: ReactNode | ((authorized: boolean) => ReactNode)` - Content to render
+
+#### `Allow`
+Standalone component for conditional rendering.
+
+**Props:**
+- `subject: TSubjects` - The subject to check
+- `action: TActions` - The action to check
+- `permissionsContext: PermissionsContext` - Permissions context
+- `children: ReactNode | ((authorized: boolean) => ReactNode)` - Content to render
+
+#### `RolesProvider`
+Context provider for permissions.
+
+**Props:**
+- `data: RolesData` - Object containing permissions Map and additional data
+- `children: ReactNode` - Child components
+
+### Server Utilities
+
+#### `createServerAccessControl(options)`
+Creates server-side access control utilities.
+
+**Returns:**
+- `allow(value, permission, fallback?)` - Conditional value return
+- `hasPermission(subject, action)` - Boolean permission check
+- `isPermissionsAllowed(policies)` - Policy-based checking
+- `roleMap` - Normalized permissions Map
+- `permissions` - Original permissions array
+
+## Types
+
+```typescript
+type TActions = "create" | "read" | "update" | "delete" | "*";
+type TSubjects = "billing" | "members" | "stakeholder" | "roles" | "audits" | "documents" | "company" | "developer" | "bank-accounts";
+
+interface TPermission {
+ subject: TSubjects;
+ actions: TActions[];
+}
+
+interface BaseMembership {
+ role: string | null;
+ customRoleId: string | null;
+ companyId: string;
+ [key: string]: unknown;
+}
+
+type Result =
+ | { success: true; data: T }
+ | { success: false; error: E };
+```
+
+## Best Practices
+
+### 🏗️ **Architecture Pattern**
+
+1. **Keep the RBAC package generic** - Don't tie it to specific databases or apps
+2. **Use dependency injection** - Leverage the generic factory pattern for maximum reusability
+3. **Create app-specific utilities** - Use the provided helpers to create thin wrappers
+4. **Use standard patterns** - Leverage `createStandardRoleUtils` for common cases
+5. **Type safety first** - Always use your app's specific role enum types
+
+### 📁 **Recommended Project Structure**
+
+```
+your-app/
+├── lib/
+│ ├── rbac-utils.ts # App-specific RBAC utilities
+│ └── access-control.ts # App-specific access control logic
+├── components/
+│ └── auth/ # Permission-gated components
+└── types/
+ └── roles.ts # App-specific role types
+```
+
+### 🎯 **Import Strategy**
+
+```typescript
+// ✅ Good: Specific imports for better tree-shaking
+import { RBAC } from "@captable/rbac";
+import { Allow } from "@captable/rbac/client";
+import { createServerAccessControl } from "@captable/rbac/server";
+
+// ✅ Good: Generic factory pattern
+import { createServerAccessControlFactory } from "@captable/rbac/utils";
+
+// ✅ Good: App-specific utilities
+import { getRoleId } from "./lib/rbac-utils";
+import { serverAccessControl } from "./lib/access-control";
+```
+
+### 🔧 **Dependency Injection Benefits**
+
+- **No Forced Dependencies**: Works with any database, auth system, or framework
+- **Type Safety**: Generic types adapt to your app's data structures
+- **Flexible**: Use individual utilities or the full factory
+- **Maintainable**: Clear separation between RBAC logic and app-specific code
+- **Testable**: Easy to mock dependencies for testing
+
+## Examples
+
+See the [examples directory](./examples) for complete implementation examples.
+
+## Contributing
+
+Contributions are welcome! Please read our contributing guidelines before submitting PRs.
+
+## License
+
+MIT License - see LICENSE file for details.
\ No newline at end of file
diff --git a/packages/rbac/package.json b/packages/rbac/package.json
new file mode 100644
index 000000000..7b4601224
--- /dev/null
+++ b/packages/rbac/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@captable/rbac",
+ "version": "1.0.0",
+ "type": "module",
+ "private": true,
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "require": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "./server": {
+ "import": "./dist/server/index.js",
+ "require": "./dist/server/index.js",
+ "types": "./dist/server/index.d.ts"
+ },
+ "./client": {
+ "import": "./dist/client/index.js",
+ "require": "./dist/client/index.js",
+ "types": "./dist/client/index.d.ts"
+ },
+ "./types": {
+ "import": "./dist/types/index.js",
+ "require": "./dist/types/index.js",
+ "types": "./dist/types/index.d.ts"
+ },
+ "./utils": {
+ "import": "./dist/utils.js",
+ "require": "./dist/utils.js",
+ "types": "./dist/utils.d.ts"
+ }
+ },
+ "sideEffects": false,
+ "scripts": {
+ "build": "tsc && tsc-alias",
+ "dev": "tsc -w",
+ "type-check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "tsc-alias": "^1.8.16",
+ "typescript": "5.8.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+}
diff --git a/packages/rbac/src/client/index.ts b/packages/rbac/src/client/index.ts
new file mode 100644
index 000000000..756161ea3
--- /dev/null
+++ b/packages/rbac/src/client/index.ts
@@ -0,0 +1,11 @@
+export * from "../components/allow.js";
+export * from "../components/allow-with-provider.js";
+export * from "../components/roles-provider.js";
+export * from "../hooks/use-allowed.js";
+
+// Export types for re-export convenience
+export type {
+ useAllowedOptions,
+ PermissionsContext,
+} from "../hooks/use-allowed.js";
+export type { RolesData } from "../components/roles-provider.js";
diff --git a/packages/rbac/src/components/allow-with-provider.tsx b/packages/rbac/src/components/allow-with-provider.tsx
new file mode 100644
index 000000000..6cde8e541
--- /dev/null
+++ b/packages/rbac/src/components/allow-with-provider.tsx
@@ -0,0 +1,33 @@
+import type { ReactNode } from "react";
+import type { TActions } from "../types/actions.js";
+import type { TSubjects } from "../types/subjects.js";
+import { useRoles } from "./roles-provider.js";
+
+interface AllowWithProviderProps {
+ subject: TSubjects;
+ action: TActions;
+ children: ReactNode | ((authorized: boolean) => ReactNode);
+}
+
+export const AllowWithProvider = ({
+ children,
+ action,
+ subject,
+}: AllowWithProviderProps) => {
+ const { permissions } = useRoles();
+
+ const hasSubject = permissions.has(subject);
+ const hasAction =
+ (permissions.get(subject)?.includes(action) ?? false) ||
+ (permissions.get(subject)?.includes("*") ?? false);
+
+ const isAllowed = hasSubject && hasAction;
+
+ if (isAllowed) {
+ if (typeof children === "function") {
+ return children(isAllowed);
+ }
+ return children;
+ }
+ return null;
+};
diff --git a/packages/rbac/src/components/allow.tsx b/packages/rbac/src/components/allow.tsx
new file mode 100644
index 000000000..531d24f48
--- /dev/null
+++ b/packages/rbac/src/components/allow.tsx
@@ -0,0 +1,27 @@
+import type { ReactNode } from "react";
+import {
+ type PermissionsContext,
+ useAllowed,
+ type useAllowedOptions,
+} from "../hooks/use-allowed.js";
+
+interface AllowProps extends useAllowedOptions {
+ children: ReactNode | ((authorized: boolean) => ReactNode);
+ permissionsContext: PermissionsContext;
+}
+
+export const Allow = ({
+ children,
+ permissionsContext,
+ ...rest
+}: AllowProps) => {
+ const { isAllowed } = useAllowed(rest, permissionsContext);
+
+ if (isAllowed) {
+ if (typeof children === "function") {
+ return children(isAllowed);
+ }
+ return children;
+ }
+ return null;
+};
diff --git a/packages/rbac/src/components/roles-provider.tsx b/packages/rbac/src/components/roles-provider.tsx
new file mode 100644
index 000000000..a2e5483af
--- /dev/null
+++ b/packages/rbac/src/components/roles-provider.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { type ReactNode, createContext, useContext } from "react";
+import type { TActions } from "../types/actions.js";
+import type { TSubjects } from "../types/subjects.js";
+
+export interface RolesData {
+ permissions: Map;
+ [key: string]: unknown; // Allow for additional data with unknown type
+}
+
+const RolesProviderContext = createContext(null);
+
+interface RolesProviderProps {
+ children: ReactNode;
+ data: RolesData;
+}
+
+export const RolesProvider = ({ children, data }: RolesProviderProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const useRoles = () => {
+ const data = useContext(RolesProviderContext);
+
+ if (!data) {
+ throw new Error("useRoles should be used inside RolesProvider");
+ }
+
+ return data;
+};
diff --git a/apps/captable/lib/rbac/constants.ts b/packages/rbac/src/core/constants.ts
similarity index 73%
rename from apps/captable/lib/rbac/constants.ts
rename to packages/rbac/src/core/constants.ts
index 2b94279c5..917ca91f4 100644
--- a/apps/captable/lib/rbac/constants.ts
+++ b/packages/rbac/src/core/constants.ts
@@ -1,7 +1,6 @@
-import type { TPermission } from "@/lib/rbac/schema";
-import type { RoleEnum } from "@captable/db";
-import type { TActions } from "./actions";
-import { SUBJECTS } from "./subjects";
+import type { TActions } from "../types/actions.js";
+import type { TPermission } from "../types/schema.js";
+import { SUBJECTS } from "../types/subjects.js";
export const ADMIN_PERMISSION = SUBJECTS.map((item) => ({
actions: ["*" as TActions],
@@ -38,4 +37,4 @@ export type RoleList = {
}
);
-type DefaultRoles = Exclude;
+type DefaultRoles = "ADMIN" | "USER" | "VIEWER"; // Generic role types
diff --git a/packages/rbac/src/core/index.ts b/packages/rbac/src/core/index.ts
new file mode 100644
index 000000000..57c51d8d0
--- /dev/null
+++ b/packages/rbac/src/core/index.ts
@@ -0,0 +1,2 @@
+export * from "./rbac.js";
+export * from "./constants.js";
diff --git a/apps/captable/lib/rbac/index.ts b/packages/rbac/src/core/rbac.ts
similarity index 84%
rename from apps/captable/lib/rbac/index.ts
rename to packages/rbac/src/core/rbac.ts
index 6d8709277..409728df9 100644
--- a/apps/captable/lib/rbac/index.ts
+++ b/packages/rbac/src/core/rbac.ts
@@ -1,7 +1,6 @@
-import { Ok, type Result } from "@/lib/error";
-import type { TActions } from "./actions";
-import type { TPermission } from "./schema";
-import type { TSubjects } from "./subjects";
+import type { TActions } from "../types/actions.js";
+import type { TPermission } from "../types/schema.js";
+import type { TSubjects } from "../types/subjects.js";
type Effect = "allow" | "deny";
@@ -9,6 +8,11 @@ export type addPolicyOption = Partial<
Record
>;
+interface EnforceResult {
+ valid: boolean;
+ message: string;
+}
+
export class RBAC {
private policy: Map>>;
@@ -18,13 +22,11 @@ export class RBAC {
allow(subject: TSubjects, action: TActions): RBAC {
this.register(subject, action, "allow");
-
return this;
}
deny(subject: TSubjects, action: TActions): RBAC {
this.register(subject, action, "deny");
-
return this;
}
@@ -46,16 +48,15 @@ export class RBAC {
actionSet.add(action);
}
- enforce(
- permissions: TPermission[],
- ): Result<{ valid: boolean; message: string }> {
+ enforce(permissions: TPermission[]): EnforceResult {
const permissionSubjects = new Set(permissions.map((item) => item.subject));
+
for (const subject of this.policy.keys()) {
if (!permissionSubjects.has(subject)) {
- return Ok({
+ return {
valid: false,
message: `No matching permissions found for action: ${subject}`,
- });
+ };
}
}
@@ -73,10 +74,10 @@ export class RBAC {
(deniedActions.has("*") ||
actions.some((action) => deniedActions.has(action)))
) {
- return Ok({
+ return {
valid: false,
message: `Permission denied for actions: ${actions.join(", ")}`,
- });
+ };
}
if (actions.includes("*")) {
@@ -91,16 +92,16 @@ export class RBAC {
continue;
}
- return Ok({
+ return {
valid: false,
message: `No matching permissions found for actions: ${actions.join(
", ",
)}`,
- });
+ };
}
}
- return Ok({ valid: true, message: "Permissions granted." });
+ return { valid: true, message: "Permissions granted." };
}
addPolicies(policies: addPolicyOption) {
@@ -144,8 +145,3 @@ export class RBAC {
return permissionMap;
}
}
-
-// Example usage:
-// const rbac = new RBAC();
-
-// rbac.allow("billing", "create").allow("billing", "delete");
diff --git a/packages/rbac/src/hooks/use-allowed.ts b/packages/rbac/src/hooks/use-allowed.ts
new file mode 100644
index 000000000..08484ad9e
--- /dev/null
+++ b/packages/rbac/src/hooks/use-allowed.ts
@@ -0,0 +1,28 @@
+import type { TActions } from "../types/actions.js";
+import type { TSubjects } from "../types/subjects.js";
+
+export interface useAllowedOptions {
+ subject: TSubjects;
+ action: TActions;
+}
+
+export interface PermissionsContext {
+ permissions?: Map;
+}
+
+export function useAllowed(
+ { action, subject }: useAllowedOptions,
+ permissionsContext: PermissionsContext,
+) {
+ const permissions =
+ permissionsContext?.permissions ?? new Map();
+
+ const hasSubject = permissions.has(subject);
+ const hasAction =
+ (permissions.get(subject)?.includes(action) ?? false) ||
+ (permissions.get(subject)?.includes("*") ?? false);
+
+ const isAllowed = hasSubject && hasAction;
+
+ return { isAllowed };
+}
diff --git a/packages/rbac/src/index.ts b/packages/rbac/src/index.ts
new file mode 100644
index 000000000..b48911082
--- /dev/null
+++ b/packages/rbac/src/index.ts
@@ -0,0 +1,43 @@
+// Core RBAC functionality
+export { RBAC, type addPolicyOption } from "./core/rbac.js";
+export {
+ ADMIN_PERMISSION,
+ ADMIN_ROLE_ID,
+ DEFAULT_PERMISSION,
+ DEFAULT_ADMIN_ROLE,
+ type RoleList,
+} from "./core/constants.js";
+
+// Type definitions
+export { ACTIONS, type TActions } from "./types/actions.js";
+export { SUBJECTS, type TSubjects } from "./types/subjects.js";
+export { permissionSchema, type TPermission } from "./types/schema.js";
+
+// Utilities and helpers
+export {
+ COMMON_ROLE_PATTERNS,
+ createStandardRoleIdMapper,
+ createStandardRolePermissionMapper,
+ createStandardRoleUtils,
+} from "./utils.js";
+
+// Client-side utilities (these should typically be imported from /client)
+export {
+ Allow,
+ AllowWithProvider,
+ RolesProvider,
+ useRoles,
+} from "./client/index.js";
+export type {
+ useAllowedOptions,
+ PermissionsContext,
+ RolesData,
+} from "./client/index.js";
+
+// Server-side utilities (these should typically be imported from /server)
+export {
+ createServerAccessControl,
+ createRoleIdMapper,
+ createRolePermissionMapper,
+ type AccessControlOptions,
+} from "./server/index.js";
diff --git a/packages/rbac/src/server/access-control.ts b/packages/rbac/src/server/access-control.ts
new file mode 100644
index 000000000..8b8ce843c
--- /dev/null
+++ b/packages/rbac/src/server/access-control.ts
@@ -0,0 +1,59 @@
+import { RBAC, type addPolicyOption } from "../core/rbac.js";
+import type { TActions } from "../types/actions.js";
+import type { TPermission } from "../types/schema.js";
+import type { TSubjects } from "../types/subjects.js";
+
+export interface AccessControlOptions {
+ permissions: TPermission[];
+}
+
+export function createServerAccessControl({
+ permissions,
+}: AccessControlOptions) {
+ const roleMap = RBAC.normalizePermissionsMap(permissions);
+
+ const allow = (
+ p: T,
+ permission: [TSubjects, TActions],
+ undefinedValue?: U,
+ ) => {
+ const subject = permission[0];
+ const action = permission[1];
+
+ const subjectPermissions = roleMap.get(subject);
+ const allowed =
+ !!subjectPermissions &&
+ (subjectPermissions.includes(action) || subjectPermissions.includes("*"));
+
+ if (allowed) {
+ return p;
+ }
+ return undefinedValue as U;
+ };
+
+ const isPermissionsAllowed = (policies: addPolicyOption) => {
+ const rbac = new RBAC();
+ rbac.addPolicies(policies);
+
+ const result = rbac.enforce(permissions);
+ const isAllowed = result.valid;
+
+ return { isAllowed, message: result.message };
+ };
+
+ const hasPermission = (subject: TSubjects, action: TActions): boolean => {
+ const subjectPermissions = roleMap.get(subject);
+ return (
+ !!subjectPermissions &&
+ (subjectPermissions.includes(action) || subjectPermissions.includes("*"))
+ );
+ };
+
+ return {
+ isPermissionsAllowed,
+ roleMap,
+ allow,
+ hasPermission,
+ permissions,
+ };
+}
diff --git a/packages/rbac/src/server/index.ts b/packages/rbac/src/server/index.ts
new file mode 100644
index 000000000..e2efec583
--- /dev/null
+++ b/packages/rbac/src/server/index.ts
@@ -0,0 +1,2 @@
+export * from "./access-control.js";
+export * from "./role-utils.js";
diff --git a/packages/rbac/src/server/role-utils.ts b/packages/rbac/src/server/role-utils.ts
new file mode 100644
index 000000000..1c8be8c0a
--- /dev/null
+++ b/packages/rbac/src/server/role-utils.ts
@@ -0,0 +1,68 @@
+/**
+ * Creates a role ID mapper function for converting role types to string IDs
+ * This allows apps to define their own role enum types while using a standard mapping function
+ */
+export function createRoleIdMapper(options: {
+ adminRoleId: string;
+ adminRoleValue: TRole;
+ customRoleValue: TRole;
+}) {
+ const { adminRoleId, adminRoleValue, customRoleValue } = options;
+
+ return ({
+ role,
+ customRoleId,
+ }: {
+ role: TRole | null;
+ customRoleId: string | null;
+ }): string => {
+ if (role === adminRoleValue) {
+ return adminRoleId;
+ }
+
+ if (role === customRoleValue && customRoleId) {
+ return customRoleId;
+ }
+
+ // Return empty string for other cases (could be made configurable)
+ return "";
+ };
+}
+
+/**
+ * Generic role permission mapper for converting database role data to permission objects
+ */
+export function createRolePermissionMapper<
+ TRole extends string,
+ TPermission = unknown,
+>(options: {
+ adminPermissions: TPermission[];
+ defaultPermissions: TPermission[];
+ adminRoleValue: TRole;
+ customRoleValue: TRole;
+}) {
+ const {
+ adminPermissions,
+ defaultPermissions,
+ adminRoleValue,
+ customRoleValue,
+ } = options;
+
+ return ({
+ role,
+ customPermissions,
+ }: {
+ role: TRole | null;
+ customPermissions?: TPermission[];
+ }) => {
+ if (role === adminRoleValue) {
+ return adminPermissions;
+ }
+
+ if (role === customRoleValue && customPermissions) {
+ return customPermissions;
+ }
+
+ return defaultPermissions;
+ };
+}
diff --git a/apps/captable/lib/rbac/actions.ts b/packages/rbac/src/types/actions.ts
similarity index 100%
rename from apps/captable/lib/rbac/actions.ts
rename to packages/rbac/src/types/actions.ts
diff --git a/packages/rbac/src/types/index.ts b/packages/rbac/src/types/index.ts
new file mode 100644
index 000000000..25470379f
--- /dev/null
+++ b/packages/rbac/src/types/index.ts
@@ -0,0 +1,3 @@
+export * from "./actions.js";
+export * from "./subjects.js";
+export * from "./schema.js";
diff --git a/apps/captable/lib/rbac/schema.ts b/packages/rbac/src/types/schema.ts
similarity index 70%
rename from apps/captable/lib/rbac/schema.ts
rename to packages/rbac/src/types/schema.ts
index 0f770c4e6..b5b2e4bb2 100644
--- a/apps/captable/lib/rbac/schema.ts
+++ b/packages/rbac/src/types/schema.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
-import { ACTIONS } from "./actions";
-import { SUBJECTS } from "./subjects";
+import { ACTIONS } from "./actions.js";
+import { SUBJECTS } from "./subjects.js";
export const permissionSchema = z.object({
actions: z.array(z.enum(ACTIONS)),
diff --git a/apps/captable/lib/rbac/subjects.ts b/packages/rbac/src/types/subjects.ts
similarity index 99%
rename from apps/captable/lib/rbac/subjects.ts
rename to packages/rbac/src/types/subjects.ts
index e8ad79f9f..57a6946d8 100644
--- a/apps/captable/lib/rbac/subjects.ts
+++ b/packages/rbac/src/types/subjects.ts
@@ -9,4 +9,5 @@ export const SUBJECTS = [
"developer",
"bank-accounts",
] as const;
+
export type TSubjects = (typeof SUBJECTS)[number];
diff --git a/packages/rbac/src/utils.ts b/packages/rbac/src/utils.ts
new file mode 100644
index 000000000..92ef9e87b
--- /dev/null
+++ b/packages/rbac/src/utils.ts
@@ -0,0 +1,298 @@
+import { ADMIN_ROLE_ID } from "./core/constants.js";
+import { createServerAccessControl } from "./server/access-control.js";
+import {
+ createRoleIdMapper,
+ createRolePermissionMapper,
+} from "./server/role-utils.js";
+import type { TPermission } from "./types/schema.js";
+
+/**
+ * Common role enum patterns that most apps use
+ */
+export const COMMON_ROLE_PATTERNS = {
+ ADMIN: "ADMIN",
+ USER: "USER",
+ VIEWER: "VIEWER",
+ CUSTOM: "CUSTOM",
+} as const;
+
+/**
+ * Creates a standard role ID mapper for common role patterns
+ * Most apps can use this directly if they follow the standard pattern
+ */
+export function createStandardRoleIdMapper(options: {
+ adminRoleValue: TRole;
+ customRoleValue: TRole;
+ adminRoleId?: string;
+}) {
+ return createRoleIdMapper({
+ adminRoleId: options.adminRoleId || ADMIN_ROLE_ID,
+ adminRoleValue: options.adminRoleValue,
+ customRoleValue: options.customRoleValue,
+ });
+}
+
+/**
+ * Creates a standard role permission mapper for common patterns
+ */
+export function createStandardRolePermissionMapper<
+ TRole extends string,
+ TPermission,
+>(options: {
+ adminRoleValue: TRole;
+ customRoleValue: TRole;
+ adminPermissions: TPermission[];
+ defaultPermissions: TPermission[];
+}) {
+ return createRolePermissionMapper({
+ adminRoleValue: options.adminRoleValue,
+ customRoleValue: options.customRoleValue,
+ adminPermissions: options.adminPermissions,
+ defaultPermissions: options.defaultPermissions,
+ });
+}
+
+/**
+ * Helper to create both role ID mapper and permission mapper with consistent config
+ */
+export function createStandardRoleUtils<
+ TRole extends string,
+ TPermission,
+>(options: {
+ adminRoleValue: TRole;
+ customRoleValue: TRole;
+ adminPermissions: TPermission[];
+ defaultPermissions: TPermission[];
+ adminRoleId?: string;
+}) {
+ const roleIdMapper = createStandardRoleIdMapper({
+ adminRoleValue: options.adminRoleValue,
+ customRoleValue: options.customRoleValue,
+ adminRoleId: options.adminRoleId,
+ });
+
+ const rolePermissionMapper = createStandardRolePermissionMapper({
+ adminRoleValue: options.adminRoleValue,
+ customRoleValue: options.customRoleValue,
+ adminPermissions: options.adminPermissions,
+ defaultPermissions: options.defaultPermissions,
+ });
+
+ return {
+ getRoleId: roleIdMapper,
+ getRolePermissions: rolePermissionMapper,
+ };
+}
+
+/**
+ * Result type for generic operations that can succeed or fail
+ */
+export type Result =
+ | { success: true; data: T }
+ | { success: false; error: E };
+
+/**
+ * Generic membership data that apps can extend
+ */
+export interface BaseMembership {
+ role: string | null;
+ customRoleId: string | null;
+ companyId: string;
+ [key: string]: unknown;
+}
+
+/**
+ * Dependencies that apps need to provide for role permission resolution
+ */
+export interface RolePermissionDependencies<
+ TRole,
+ TSession,
+ TDB,
+ TMembership extends BaseMembership,
+> {
+ adminRoleValue: TRole;
+ customRoleValue: TRole;
+ adminPermissions: TPermission[];
+ defaultPermissions: TPermission[];
+
+ // App-specific implementations
+ checkMembership: (session: TSession, db: TDB) => Promise>;
+ getCustomRolePermissions: (options: {
+ customRoleId: string;
+ companyId: string;
+ db: TDB;
+ }) => Promise>;
+}
+
+/**
+ * Creates a generic role permission resolver with dependency injection
+ */
+export function createRolePermissionResolver<
+ TRole,
+ TSession,
+ TDB,
+ TMembership extends BaseMembership,
+>(deps: RolePermissionDependencies) {
+ const getPermissionsForRole = (options: {
+ role: TRole | null;
+ customRoleId: string | null;
+ companyId: string;
+ db: TDB;
+ }): Promise> => {
+ const { role, customRoleId, companyId, db } = options;
+
+ if (role === deps.adminRoleValue) {
+ return Promise.resolve({ success: true, data: deps.adminPermissions });
+ }
+
+ if (!role) {
+ return Promise.resolve({ success: true, data: deps.defaultPermissions });
+ }
+
+ if (role === deps.customRoleValue && customRoleId) {
+ return deps.getCustomRolePermissions({ customRoleId, companyId, db });
+ }
+
+ return Promise.resolve({ success: true, data: deps.defaultPermissions });
+ };
+
+ const getPermissions = async (options: {
+ session: TSession;
+ db: TDB;
+ }): Promise<
+ Result<{ permissions: TPermission[]; membership: TMembership }>
+ > => {
+ const membershipResult = await deps.checkMembership(
+ options.session,
+ options.db,
+ );
+
+ if (!membershipResult.success) {
+ return { success: false, error: membershipResult.error };
+ }
+
+ const membership = membershipResult.data;
+ const permissionsResult = await getPermissionsForRole({
+ role: membership.role as TRole,
+ customRoleId: membership.customRoleId,
+ companyId: membership.companyId,
+ db: options.db,
+ });
+
+ if (!permissionsResult.success) {
+ return { success: false, error: permissionsResult.error };
+ }
+
+ return {
+ success: true,
+ data: { permissions: permissionsResult.data, membership },
+ };
+ };
+
+ return {
+ getPermissionsForRole,
+ getPermissions,
+ };
+}
+
+/**
+ * Dependencies for server access control factory
+ */
+export interface ServerAccessControlDependencies {
+ getSession: (headers: Headers) => Promise;
+ database: TDB;
+
+ // Error factories
+ createUnauthorizedError?: () => Error;
+ createGenericError?: (error: unknown) => Error;
+}
+
+/**
+ * Creates a generic server access control factory with full dependency injection
+ */
+export function createServerAccessControlFactory<
+ TRole,
+ TSession,
+ TDB,
+ TMembership extends BaseMembership,
+>(
+ permissionDeps: RolePermissionDependencies,
+ accessDeps: ServerAccessControlDependencies,
+) {
+ const permissionResolver = createRolePermissionResolver(permissionDeps);
+
+ const getServerPermissions = async ({ headers }: { headers: Headers }) => {
+ const session = await accessDeps.getSession(headers);
+
+ if (!session) {
+ throw accessDeps.createUnauthorizedError?.() || new Error("Unauthorized");
+ }
+
+ const result = await permissionResolver.getPermissions({
+ session,
+ db: accessDeps.database,
+ });
+
+ if (!result.success) {
+ throw accessDeps.createGenericError?.(result.error) || result.error;
+ }
+
+ return result.data;
+ };
+
+ const serverAccessControl = async ({ headers }: { headers: Headers }) => {
+ const { permissions } = await getServerPermissions({ headers });
+
+ // Use the RBAC package's access control with the standard TPermission type
+ const accessControl = createServerAccessControl({ permissions });
+
+ return accessControl;
+ };
+
+ return {
+ getServerPermissions,
+ serverAccessControl,
+ };
+}
+
+/**
+ * Database query dependencies for role ID resolution
+ */
+export interface RoleIdResolverDependencies {
+ findCustomRole: (id: string, db: TDB) => Promise<{ id: string } | null>;
+}
+
+/**
+ * Creates a standard role ID resolver with dependency injection
+ */
+export function createRoleIdResolver(
+ config: {
+ adminRoleValue: TRole;
+ customRoleValue: TRole;
+ adminRoleId: string;
+ },
+ deps: RoleIdResolverDependencies,
+) {
+ return async (options: {
+ id?: string | null;
+ db: TDB;
+ }): Promise<{ role: TRole | null; customRoleId: string | null }> => {
+ const { id, db } = options;
+
+ if (!id || id === "") {
+ return { role: null, customRoleId: null };
+ }
+
+ if (id === config.adminRoleId) {
+ return { role: config.adminRoleValue, customRoleId: null };
+ }
+
+ const customRole = await deps.findCustomRole(id, db);
+
+ if (!customRole) {
+ throw new Error("Custom role not found");
+ }
+
+ return { role: config.customRoleValue, customRoleId: customRole.id };
+ };
+}
diff --git a/packages/rbac/tsconfig.json b/packages/rbac/tsconfig.json
new file mode 100644
index 000000000..c704f4b96
--- /dev/null
+++ b/packages/rbac/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "@captable/config/base.json",
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2017"],
+ "jsx": "react-jsx",
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.test.tsx"]
+}
diff --git a/packages/utils/index.ts b/packages/utils/index.ts
index 18ac493f5..013c64d9d 100644
--- a/packages/utils/index.ts
+++ b/packages/utils/index.ts
@@ -3,4 +3,4 @@ export { META } from "./lib/constants";
// This allows for both:
// import { META } from "@captable/utils"
-// import { META } from "@captable/utils/constants" (more treeshakable)
\ No newline at end of file
+// import { META } from "@captable/utils/constants" (more treeshakable)
diff --git a/packages/utils/lib/constants.ts b/packages/utils/lib/constants.ts
index 761fb10f2..f3655274d 100644
--- a/packages/utils/lib/constants.ts
+++ b/packages/utils/lib/constants.ts
@@ -18,4 +18,4 @@ export const META = {
url: "https://github.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF",
},
},
-};
\ No newline at end of file
+};
diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json
index cab2b186d..21f908518 100644
--- a/packages/utils/tsconfig.json
+++ b/packages/utils/tsconfig.json
@@ -20,4 +20,4 @@
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "dist"]
-}
\ No newline at end of file
+}
diff --git a/tsconfig.json b/tsconfig.json
index 12d4e2bed..5d23bf565 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -21,4 +21,4 @@
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", ".react-email", "dist"]
-}
\ No newline at end of file
+}
diff --git a/turbo.jsonc b/turbo.jsonc
index 38de44e0d..98369b88b 100644
--- a/turbo.jsonc
+++ b/turbo.jsonc
@@ -1,144 +1,140 @@
{
- "$schema": "https://turborepo.com/schema.json",
- "envMode": "loose",
- "globalDependencies": [".env", ".env.local", ".env.production"],
- "globalEnv": ["NODE_ENV", "DATABASE_URL", "NEXTAUTH_SECRET"],
- "remoteCache": {
- "signature": true
- },
- // "ui": "tui",
- "tasks": {
- // Build tasks optimized for parallelization
- "build": {
- "dependsOn": ["^build"],
- "inputs": [
- "$TURBO_DEFAULT$",
- ".env*",
- "next.config.js",
- "tailwind.config.js",
- "tsconfig.json"
- ],
- "outputs": [".next/**", "!.next/cache/**", "dist/**"],
- "env": ["NODE_ENV", "ANALYZE", "SENTRY_*"]
- },
-
- // Independent linting - no need to wait for builds
- "lint": {
- "inputs": [
- "**/*.{ts,tsx,js,jsx,json}",
- "biome.json",
- ".biomejs.json",
- "eslint.config.js",
- ".eslintrc*"
- ],
- "outputs": []
- },
-
- // Type checking can run independently and in parallel
- "check-types": {
- "inputs": [
- "**/*.{ts,tsx}",
- "tsconfig.json",
- "next-env.d.ts"
- ],
- "outputs": []
- },
-
- // Development - optimized for hot reloading
- "dev": {
- "cache": false,
- "persistent": true,
- "dependsOn": [],
- "inputs": []
- },
-
- // Formatting is independent
- "format": {
- "inputs": [
- "**/*.{ts,tsx,js,jsx,json,md}",
- "biome.json",
- ".biomejs.json"
- ],
- "outputs": []
- },
-
- // Database tasks with proper dependencies
- "db:generate": {
- "dependsOn": [],
- "cache": false,
- "inputs": [
- "drizzle.config.ts",
- "src/db/schema/**/*.ts",
- "packages/db/src/**/*.ts"
- ],
- "outputs": ["drizzle/**", "src/db/migrations/**"]
- },
-
- "db:migrate": {
- "dependsOn": ["db:generate"],
- "cache": false,
- "inputs": [
- "drizzle/**/*.sql",
- "src/db/migrations/**/*.sql"
- ]
- },
-
- "db:seed": {
- "dependsOn": ["db:migrate"],
- "cache": false,
- "inputs": [
- "src/db/seed/**/*.ts",
- "packages/db/src/seed/**/*.ts"
- ]
- },
-
- "db:studio": {
- "cache": false,
- "persistent": true,
- "dependsOn": []
- },
-
- // Email development tasks
- "email:dev": {
- "cache": false,
- "persistent": true,
- "dependsOn": []
- },
-
- "email:build": {
- "dependsOn": [],
- "inputs": [
- "emails/**/*.{ts,tsx}",
- "packages/email/**/*.{ts,tsx}",
- "tailwind.config.js"
- ],
- "outputs": ["out/**", "dist/**"]
- },
-
- "email:export": {
- "dependsOn": ["email:build"],
- "inputs": [
- "emails/**/*.{ts,tsx}",
- "packages/email/**/*.{ts,tsx}"
- ],
- "outputs": ["out/**"]
- },
-
- // Additional optimization tasks
- "test": {
- "dependsOn": [],
- "inputs": [
- "**/*.{ts,tsx,js,jsx}",
- "**/*.test.{ts,tsx,js,jsx}",
- "vitest.config.ts",
- "jest.config.js"
- ],
- "outputs": ["coverage/**"]
- },
-
- "clean": {
- "cache": false,
- "dependsOn": []
- }
- }
+ "$schema": "https://turborepo.com/schema.json",
+ "envMode": "loose",
+ "globalDependencies": [".env", ".env.local", ".env.production"],
+ "globalEnv": ["NODE_ENV", "DATABASE_URL", "NEXTAUTH_SECRET"],
+ "remoteCache": {
+ "signature": true
+ },
+ // "ui": "tui",
+ "tasks": {
+ // Build tasks optimized for parallelization
+ "build": {
+ "dependsOn": ["^build"],
+ "inputs": [
+ "$TURBO_DEFAULT$",
+ ".env*",
+ "next.config.js",
+ "tailwind.config.js",
+ "tsconfig.json"
+ ],
+ "outputs": [".next/**", "!.next/cache/**", "dist/**"],
+ "env": ["NODE_ENV", "ANALYZE", "SENTRY_*"]
+ },
+
+ "start": {
+ "dependsOn": ["^build"],
+ "inputs": [
+ "$TURBO_DEFAULT$",
+ ".env*",
+ "next.config.js",
+ "tailwind.config.js",
+ "tsconfig.json"
+ ],
+ "outputs": [".next/**", "!.next/cache/**", "dist/**"],
+ "env": ["NODE_ENV", "ANALYZE", "SENTRY_*"]
+ },
+
+ // Independent linting - no need to wait for builds
+ "lint": {
+ "inputs": [
+ "**/*.{ts,tsx,js,jsx,json}",
+ "biome.json",
+ ".biomejs.json",
+ "eslint.config.js",
+ ".eslintrc*"
+ ],
+ "outputs": []
+ },
+
+ // Type checking can run independently and in parallel
+ "check-types": {
+ "inputs": ["**/*.{ts,tsx}", "tsconfig.json", "next-env.d.ts"],
+ "outputs": []
+ },
+
+ // Development - optimized for hot reloading
+ "dev": {
+ "cache": false,
+ "persistent": true,
+ "dependsOn": [],
+ "inputs": []
+ },
+
+ // Formatting is independent
+ "format": {
+ "inputs": ["**/*.{ts,tsx,js,jsx,json,md}", "biome.json", ".biomejs.json"],
+ "outputs": []
+ },
+
+ // Database tasks with proper dependencies
+ "db:generate": {
+ "dependsOn": [],
+ "cache": false,
+ "inputs": [
+ "drizzle.config.ts",
+ "src/db/schema/**/*.ts",
+ "packages/db/src/**/*.ts"
+ ],
+ "outputs": ["drizzle/**", "src/db/migrations/**"]
+ },
+
+ "db:migrate": {
+ "dependsOn": ["db:generate"],
+ "cache": false,
+ "inputs": ["drizzle/**/*.sql", "src/db/migrations/**/*.sql"]
+ },
+
+ "db:seed": {
+ "dependsOn": ["db:migrate"],
+ "cache": false,
+ "inputs": ["src/db/seed/**/*.ts", "packages/db/src/seed/**/*.ts"]
+ },
+
+ "db:studio": {
+ "cache": false,
+ "persistent": true,
+ "dependsOn": []
+ },
+
+ // Email development tasks
+ "email:dev": {
+ "cache": false,
+ "persistent": true,
+ "dependsOn": []
+ },
+
+ "email:build": {
+ "dependsOn": [],
+ "inputs": [
+ "emails/**/*.{ts,tsx}",
+ "packages/email/**/*.{ts,tsx}",
+ "tailwind.config.js"
+ ],
+ "outputs": ["out/**", "dist/**"]
+ },
+
+ "email:export": {
+ "dependsOn": ["email:build"],
+ "inputs": ["emails/**/*.{ts,tsx}", "packages/email/**/*.{ts,tsx}"],
+ "outputs": ["out/**"]
+ },
+
+ // Additional optimization tasks
+ "test": {
+ "dependsOn": [],
+ "inputs": [
+ "**/*.{ts,tsx,js,jsx}",
+ "**/*.test.{ts,tsx,js,jsx}",
+ "vitest.config.ts",
+ "jest.config.js"
+ ],
+ "outputs": ["coverage/**"]
+ },
+
+ "clean": {
+ "cache": false,
+ "dependsOn": []
+ }
+ }
}
|