Feature Policies API
Build declarative business rules with MakerKit's Feature Policies API. Validate team invitations, enforce subscription limits, and create custom authorization flows.
The Feature Policy API isolates validation and authorization logic from application code so every feature can reuse consistent, auditable policies.
Makerkit is built for extensibility: customers should expand features without patching internals unless they are opting into special cases. The Feature Policy API delivers that promise by turning customization into additive policies instead of edits to core flows.
Important: Feature Policies operates at the API and application surface level. It orchestrates business logic, user experience flows, and feature access decisions. For data integrity and security enforcement, continue using database constraints, Supabase RLS policies, and transactional safeguards as your source of truth.
What It's For
- Application logic: User flows, feature access, business rule validation
- API orchestration: Request processing, workflow coordination, conditional routing
- User experience: Dynamic UI behavior, progressive disclosure, personalization
- Integration patterns: Third-party service coordination, webhook processing
What It's NOT For
- Data integrity: Use database constraints and foreign keys
- Security enforcement: Use Supabase RLS policies and authentication
- Performance-critical paths: Use database indexes and query optimization
- Transactional consistency: Use database transactions and ACID guarantees
Key Benefits
- Apply nuanced rules without coupling them to route handlers or services
- Share policy logic across server actions, mutations, and background jobs
- Test policies in isolation while keeping runtime orchestration predictable
- Layer customer-specific extensions on top of Makerkit defaults
How We Use It Today
Makerkit uses the Feature Policy API in two team-account flows:
- Team invitations - validates when a team can send invitations. The registry lives in
packages/features/team-accounts/src/server/policies/policies.tsand is evaluated bycreateInvitationsPolicyEvaluator()ininvitation-policies.tsin the same folder. - Team account creation - validates when a user can create a new team account. The registry lives in
packages/features/team-accounts/src/server/policies/create-account-policies.server.tsand is evaluated bycreateAccountCreationPolicyEvaluator()increate-account-policy-evaluator.server.ts.
Both registries ship empty: no policy is registered by default, so invitations and team creation are allowed until you register your own rules. The kit defines one ready-made invitation policy (subscriptionRequiredInvitationsPolicy) that you can opt into with a single line (see Real-World Example).
While supporting customers implement various flows for invitations, it was clear that the SaaS Starter Kit could not assume what rules you want to apply to invitations.
- Some customers wanted to validate the email address of the invited user (ex. validate they all shared the same domain)
- A set of customers wanted only users on a specific plan to be able to invite users (ex. only Pro users can invite users)
- Others simply wanted to limit how many invitations can be sent on a per-plan basis (ex. only 5 invitations can be sent on a free plan, 20 on a paid plan, etc.)
These rules required a more declarative approach - which is why we created the Policies API - so that users can layer their own requirements without the need to rewrite internals.
Additional features can opt in to the same registry pattern to unlock the shared orchestration and extension tooling.
Why Feature Policies?
A SaaS starter kit must adapt to diverse customer requirements without creating divergent forks.
Imperative checks embedded in controllers quickly become brittle: every variation requires new conditionals, feature flags, or early returns scattered across files.
The Feature Policy API keeps the rule set declarative and centralized, so product teams can swap, reorder, or extend policies without rewriting the baseline flow.
Registries turn policy changes into configuration instead of refactors, making it safer for customers to customize logic while continuing to receive upstream updates from Makerkit.
Overview
The Feature Policy API provides:
- Feature-specific registries for organized policy management per feature
- Configuration support so policies can accept typed configuration objects
- Stage-aware evaluation enabling policies to be filtered by execution stage
- Immutable contexts that keep policy execution safe and predictable
- Perfect DX through a unified API that just works
Quick Start
All building blocks are exported from @kit/policies (source in packages/policies/src). The examples in this section are policies you add yourself - they are not part of the kit.
1. Define Policies in a Feature Registry
The simplest way to add an invitation rule is to define it in the kit's existing invitation registry file and register it there:
packages/features/team-accounts/src/server/policies/policies.ts
import * as z from 'zod';import { allow, createPolicyRegistry, definePolicy, deny } from '@kit/policies';import type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';// Already exported by the kitexport const invitationPolicyRegistry = createPolicyRegistry();// Example policy you add yourselfexport const emailValidationPolicy = definePolicy<FeaturePolicyInvitationContext>({ id: 'email-validation', stages: ['submission'], evaluate: async ({ invitations }) => { if (invitations.some((invitation) => !invitation.email.includes('@'))) { return deny({ code: 'INVALID_EMAIL_FORMAT', message: 'Invalid email format', remediation: 'Please provide a valid email address', }); } return allow(); }, });// Example configurable policy you add yourselfexport const maxInvitationsPolicy = definePolicy< FeaturePolicyInvitationContext, { maxInvitations: number }>({ id: 'max-invitations', stages: ['submission'], configSchema: z.object({ maxInvitations: z.number().positive(), }), evaluate: async ({ invitations }, config = { maxInvitations: 5 }) => { if (invitations.length > config.maxInvitations) { return deny({ code: 'MAX_INVITATIONS_EXCEEDED', message: `Cannot invite more than ${config.maxInvitations} members`, remediation: `Reduce invitations to ${config.maxInvitations} or fewer`, }); } return allow(); },});// register policies below to apply theminvitationPolicyRegistry .registerPolicy(emailValidationPolicy) .registerPolicy(maxInvitationsPolicy);registerPolicy() returns the registry, so calls can be chained. Registering two policies with the same id throws an error.
About configuration: evaluator.evaluate(registry, ...) calls each policy without a config object, so a configurable policy evaluated through the registry always uses the default in its evaluate signature ({ maxInvitations: 5 } above). To pass a specific config, load the policy with createPoliciesFromRegistry() (see Passing Configuration).
The invitation context (FeaturePolicyInvitationContext, in feature-policy-invitation-context.ts) gives policies accountSlug, accountId, subscription (with status, provider, active, trial dates and items), currentMemberCount, invitations ({ email, role }[]) and invitingUser.
2. Create a Feature Policy Evaluator
The kit already ships this evaluator for invitations in packages/features/team-accounts/src/server/policies/invitation-policies.ts:
packages/features/team-accounts/src/server/policies/invitation-policies.ts
import { createPoliciesEvaluator } from '@kit/policies';import type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';import { invitationPolicyRegistry } from './policies';export function createInvitationsPolicyEvaluator() { const evaluator = createPoliciesEvaluator<FeaturePolicyInvitationContext>(); return { async hasPoliciesForStage(stage: 'preliminary' | 'submission') { return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage); }, async canInvite( context: FeaturePolicyInvitationContext, stage: 'preliminary' | 'submission', ) { return evaluator.evaluate( invitationPolicyRegistry, context, 'ALL', stage, ); }, };}Follow the same shape when you add a registry for a new feature.
3. Use the Policy Evaluator
This is how the kit evaluates invitation policies inside the createInvitationsFunction server function (packages/features/team-accounts/src/server/functions/team-invitations.functions.ts):
// Excerpt from team-invitations.functions.tsimport { getSupabaseServerClient } from '@kit/supabase/server-client';import { createInvitationContextBuilder } from '../policies/invitation-context-builder';import { createInvitationsPolicyEvaluator } from '../policies/invitation-policies';async function evaluateInvitationsPolicies( params: z.output<typeof InviteMembersSchema> & { accountSlug: string }, user: JWTUserData, accountId: string,) { const evaluator = createInvitationsPolicyEvaluator(); const hasPolicies = await evaluator.hasPoliciesForStage('submission'); // No policies to evaluate, skip if (!hasPolicies) { return { allowed: true, reasons: [], }; } const client = getSupabaseServerClient(); const builder = createInvitationContextBuilder(client); const context = await builder.buildContextWithAccountId( params, user, accountId, ); return evaluator.canInvite(context, 'submission');}When allowed is false, the server function returns { success: false, reasons } instead of sending the invitations.
Error Handling
The deny() helper supports both simple strings and structured errors.
String Errors (Simple)
return deny('Email validation failed');// with optional metadatareturn deny('Email validation failed', { fieldName: 'email' });Structured Errors (Enhanced)
return deny({ code: 'INVALID_EMAIL_FORMAT', message: 'Email validation failed', remediation: 'Please provide a valid email address', metadata: { fieldName: 'email' },});The message becomes the result's reason; code, remediation and any extra metadata are merged into the result's metadata. The invite-members dialog renders the first reason with Trans, so you can use an i18n key (like teams.policyErrors.subscriptionRequired) as the message.
Accessing Error Details
const result = await evaluator.canInvite(context, 'submission');if (!result.allowed) { console.log('Reasons:', result.reasons); result.results.forEach((policyResult) => { if (!policyResult.allowed && policyResult.metadata) { console.log('Policy:', policyResult.metadata.policyId); console.log('Error code:', policyResult.metadata.code); console.log('Remediation:', policyResult.metadata.remediation); } });}Policies defined with definePolicy() add policyId and stage to each result's metadata. If a policy throws, the error is caught and returned as a denial with the code POLICY_EVALUATION_ERROR.
Performance Optimizations
1. Lazy Context Building
Only build expensive context when policies exist. With no policies registered (the kit default), hasPoliciesForStage() returns false and the database queries in createInvitationContextBuilder() never run:
const hasPolicies = await evaluator.hasPoliciesForStage('submission');if (!hasPolicies) { return; // Skip expensive operations}// Build context now that policies need to runconst context = await builder.buildContextWithAccountId(params, user, accountId);const result = await evaluator.canInvite(context, 'submission');2. Stage-Aware Evaluation
Policies only run for the stages listed in their stages array (a policy without stages runs in every stage). The kit uses two stages for invitations:
preliminary- evaluated when the invite-members dialog opens, by theGEThandler inapps/web/src/routes/_authenticated/settings/members/policies.ts. It builds the context with an emptyinvitationsarray, so use it for account-level checks (subscription, plan, member count).submission- evaluated bycreateInvitationsFunctionwith the actual invitations.
// Fast preliminary checks (dialog open)const prelimResult = await evaluator.canInvite(context, 'preliminary');// Full submission validationconst submitResult = await evaluator.canInvite(context, 'submission');3. AND/OR Logic
Control evaluation behavior:
const evaluator = createPoliciesEvaluator();// ALL: Every policy must pass (default)const allResult = await evaluator.evaluate(registry, context, 'ALL', stage);// ANY: At least one policy must passconst anyResult = await evaluator.evaluate(registry, context, 'ANY', stage);With ANY and no matching policies, the result is allowed: false with the reason No policies matched the criteria.
Passing Configuration
To run registered policies with a specific config, convert them into policy functions with createPoliciesFromRegistry() and evaluate them with evaluatePolicies(). Each entry is either a policy ID or a [policyId, config] tuple; the config is validated against the policy's configSchema:
import { createPoliciesEvaluator, createPoliciesFromRegistry,} from '@kit/policies';import type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';import { invitationPolicyRegistry } from './policies';const policies = await createPoliciesFromRegistry<FeaturePolicyInvitationContext>( invitationPolicyRegistry, ['email-validation', ['max-invitations', { maxInvitations: 20 }]], );const evaluator = createPoliciesEvaluator<FeaturePolicyInvitationContext>();const result = await evaluator.evaluatePolicies( policies, context, 'ALL', 'submission',);Real-World Example: Team Invitations
The kit's invitation registry file defines the registry and one ready-made policy, subscriptionRequiredInvitationsPolicy. The policy is defined but not registered, so it does not run until you register it:
packages/features/team-accounts/src/server/policies/policies.ts
import { allow, definePolicy, deny } from '@kit/policies';import { createPolicyRegistry } from '@kit/policies';import type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';/** * Feature-specific registry for invitation policies */export const invitationPolicyRegistry = createPolicyRegistry();/** * Subscription required policy * Checks if the account has an active subscription */export const subscriptionRequiredInvitationsPolicy = definePolicy<FeaturePolicyInvitationContext>({ id: 'subscription-required', stages: ['preliminary', 'submission'], evaluate: async ({ subscription }) => { if (!subscription || !subscription.active) { return deny({ code: 'SUBSCRIPTION_REQUIRED', message: 'teams.policyErrors.subscriptionRequired', remediation: 'teams.policyRemediation.subscriptionRequired', }); } return allow(); }, });// register policies below to apply them////To require an active subscription before a team can invite members, add this line under the // register policies below to apply them comment:
invitationPolicyRegistry.registerPolicy(subscriptionRequiredInvitationsPolicy);Because the policy runs in the preliminary stage, the invite-members dialog shows the translated teams.policyErrors.subscriptionRequired message as soon as it opens, and the submission stage blocks the server function as well. Both translation keys already exist in packages/i18n/src/messages/en/teams.json.
Team Account Creation
The same pattern applies to team account creation. The registry in create-account-policies.server.ts is empty (// Register policies here). Policies receive a FeaturePolicyCreateAccountContext with userId and accountName (an empty string during the preliminary check). The example below is one you add yourself:
packages/features/team-accounts/src/server/policies/create-account-policies.server.ts
import { allow, createPolicyRegistry, definePolicy, deny } from '@kit/policies';import type { FeaturePolicyCreateAccountContext } from './feature-policy-create-account-context';/** * Feature-specific registry for create account policies. */export const createAccountPolicyRegistry = createPolicyRegistry();// Example policy you add yourselfconst reservedNamesPolicy = definePolicy<FeaturePolicyCreateAccountContext>({ id: 'reserved-team-names', stages: ['submission'], evaluate: async ({ accountName }) => { const reserved = ['admin', 'support', 'billing']; if (reserved.includes(accountName.trim().toLowerCase())) { return deny({ code: 'RESERVED_TEAM_NAME', message: 'This team name is reserved', remediation: 'Choose a different team name', }); } return allow(); },});// Register policies herecreateAccountPolicyRegistry.registerPolicy(reservedNamesPolicy);The submission stage runs inside the create-team server function (create-team-account.functions.ts); the preliminary stage runs in apps/web/src/lib/server/compose-user-workspace.server.ts to decide whether the user can create a team at all.
Customer Extension Pattern
Customers can extend policies by adding to the kit's registries (shown above), creating their own registries, or composing policy evaluators. The examples below are code you add yourself.
Method 1: Own Registry
customer-invitation-policies.ts
import { allow, createPoliciesEvaluator, createPolicyRegistry, definePolicy, deny,} from '@kit/policies';import type { FeaturePolicyInvitationContext } from '@kit/team-accounts/policies';const customerInvitationRegistry = createPolicyRegistry();customerInvitationRegistry.registerPolicy( definePolicy<FeaturePolicyInvitationContext>({ id: 'custom-domain-check', stages: ['submission'], evaluate: async ({ invitations }) => { const allowedDomains = ['company.com', 'partner.com']; for (const invitation of invitations) { const domain = invitation.email.split('@')[1] ?? ''; if (!allowedDomains.includes(domain)) { return deny({ code: 'DOMAIN_NOT_ALLOWED', message: `Email domain ${domain} is not allowed`, remediation: 'Use an email from an approved domain', }); } } return allow(); }, }),);export function createCustomInvitationPolicyEvaluator() { const evaluator = createPoliciesEvaluator<FeaturePolicyInvitationContext>(); return { async validateCustomRules( context: FeaturePolicyInvitationContext, stage: 'preliminary' | 'submission', ) { return evaluator.evaluate( customerInvitationRegistry, context, 'ALL', stage, ); }, };}Method 2: Compose Policy Evaluators
// Use both built-in and custom policiesimport { type FeaturePolicyInvitationContext, createInvitationsPolicyEvaluator,} from '@kit/team-accounts/policies';import { createCustomInvitationPolicyEvaluator } from './customer-invitation-policies';async function validateInvitations( context: FeaturePolicyInvitationContext, stage: 'preliminary' | 'submission',) { const builtinEvaluator = createInvitationsPolicyEvaluator(); const customEvaluator = createCustomInvitationPolicyEvaluator(); // Run built-in policies const builtinResult = await builtinEvaluator.canInvite(context, stage); if (!builtinResult.allowed) { throw new Error(builtinResult.reasons.join(', ')); } // Run custom policies const customResult = await customEvaluator.validateCustomRules(context, stage); if (!customResult.allowed) { throw new Error(customResult.reasons.join(', ')); }}The kit's server function only calls createInvitationsPolicyEvaluator(), so a separate registry runs only where you call it yourself. Adding policies to invitationPolicyRegistry in policies.ts is the way to have them enforced by the built-in invitation flow.
Complex Group Evaluation
For advanced scenarios requiring complex business logic with multiple decision paths, use createPolicy() and evaluateGroups(). The context below is a hypothetical example you would define for your own feature:
Example: Multi-Stage Enterprise Validation
import { type PolicyContext, type PolicyGroup, allow, createPoliciesEvaluator, createPolicy, deny,} from '@kit/policies';// Example context you define yourselfinterface FeatureContext extends PolicyContext { userId?: string; email?: string; permissions: string[]; role: string; subscription?: { plan: string; active: boolean }; trial?: { type: string; daysRemaining: number }; adminOverride?: { enabled: boolean }; memberCount: number; maxMembers: number; complianceStatus: 'approved' | 'pending';}// Complex scenario: (Authentication AND Email) AND (Subscription OR Trial) AND Final Validationasync function validateEnterpriseFeatureAccess(context: FeatureContext) { const evaluator = createPoliciesEvaluator<FeatureContext>(); // Stage 1: Authentication Requirements (ALL must pass) const authenticationGroup: PolicyGroup<FeatureContext> = { operator: 'ALL', policies: [ createPolicy<FeatureContext>(async (ctx) => ctx.userId ? allow({ step: 'authenticated' }) : deny('Authentication required'), ), createPolicy<FeatureContext>(async (ctx) => ctx.email?.includes('@') ? allow({ step: 'email-valid' }) : deny('Valid email required'), ), createPolicy<FeatureContext>(async (ctx) => ctx.permissions.includes('enterprise-features') ? allow({ step: 'permissions' }) : deny('Enterprise permissions required'), ), ], }; // Stage 2: Billing Validation (ANY sufficient - flexible payment options) const billingGroup: PolicyGroup<FeatureContext> = { operator: 'ANY', policies: [ createPolicy<FeatureContext>(async (ctx) => ctx.subscription?.plan === 'enterprise' && ctx.subscription.active ? allow({ billing: 'enterprise-subscription' }) : deny('Enterprise subscription required'), ), createPolicy<FeatureContext>(async (ctx) => ctx.trial?.type === 'enterprise' && ctx.trial.daysRemaining > 0 ? allow({ billing: 'enterprise-trial', daysLeft: ctx.trial.daysRemaining, }) : deny('Active enterprise trial required'), ), createPolicy<FeatureContext>(async (ctx) => ctx.adminOverride?.enabled && ctx.role === 'super-admin' ? allow({ billing: 'admin-override' }) : deny('Admin override not available'), ), ], }; // Stage 3: Final Constraints (ALL must pass) const constraintsGroup: PolicyGroup<FeatureContext> = { operator: 'ALL', policies: [ createPolicy<FeatureContext>(async (ctx) => ctx.memberCount <= ctx.maxMembers ? allow({ constraint: 'team-size-valid' }) : deny('Team size exceeds plan limits'), ), createPolicy<FeatureContext>(async (ctx) => ctx.complianceStatus === 'approved' ? allow({ constraint: 'compliance-approved' }) : deny('Organization compliance approval required'), ), ], }; // Execute all groups sequentially - ALL groups must pass const result = await evaluator.evaluateGroups( [authenticationGroup, billingGroup, constraintsGroup], context, ); return { allowed: result.allowed, reasons: result.reasons, metadata: { authenticationPassed: result.results.some( (r) => r.metadata?.step === 'authenticated', ), billingMethod: result.results.find((r) => r.metadata?.billing)?.metadata ?.billing, constraintsValidated: result.results.some((r) => r.metadata?.constraint), }, };}Group Evaluation Flow
- Sequential Group Processing: Groups are evaluated in order
- All Groups Must Pass: If any group fails, entire evaluation fails
- Short-Circuiting: Stops on first group failure for performance
- Metadata Preservation: All policy results and metadata are collected
evaluateGroups(groups, context, stage?) also accepts an optional stage, which is passed to each policy function as its second argument.
Group Operators
ALL(AND logic): All policies in the group must pass- Short-circuits on first failure for performance
- Use for mandatory requirements where every condition must be met
ANY(OR logic): At least one policy in the group must pass- Short-circuits on first success for performance
- Use for flexible requirements where multiple options are acceptable
Performance Considerations
- Order groups by criticality: Put fast, critical checks first
- Group by evaluation cost: Separate expensive operations
- Monitor evaluation time: Track performance for optimization
API Reference
All exports come from @kit/policies (packages/policies/src/index.ts).
Core Functions
createPolicyRegistry()- Create a feature-specific registrydefinePolicy({ id, stages?, configSchema?, evaluate })- Define a policy with metadata and optional configurationcreatePoliciesEvaluator<TContext>(options?)- Create a policy evaluator instance (options.maxCacheSizedefaults to 100)createPolicy(evaluate)- Create a plain policy function for groupscreatePolicyFromRegistry(registry, policyId, config?)- Convert a registered policy into a policy functioncreatePoliciesFromRegistry(registry, specs)- Convert several registered policies; each spec is an ID or a[id, config]tupleallow(metadata?)- Return a success result with optional metadatadeny(reason, metadata?)ordeny({ code, message, remediation?, metadata? })- Return a failure result
Policy Registry Methods
registry.registerPolicy(definition)- Register a policy (chainable; throws on duplicate IDs)registry.getPolicy(id)- Get a policy definition (throws if not registered)registry.hasPolicy(id)- Check if a policy is registeredregistry.listPolicies()- List registered policy IDs in registration order
Policy Evaluator Methods
evaluator.evaluate(registry, context, operator?, stage?)- Evaluate registry policies (operatordefaults to'ALL')evaluator.evaluatePolicies(policies, context, operator?, stage?)- Evaluate an array of policy functionsevaluator.evaluateGroup(group, context, stage?)- Evaluate a single groupevaluator.evaluateGroups(groups, context, stage?)- Evaluate complex group logicevaluator.hasPoliciesForStage(registry, stage?)- Check if policies exist for a stageevaluator.clearCache()- Clear the evaluator's cached policy definitions
Types
PolicyContext- Base context interface (timestamp, optionalmetadata)PolicyResult- Policy evaluation result (allowed,reason?,requiresManualReview?,metadata?)PolicyStage- Execution stage (anystring, such as'preliminary'or'submission')EvaluationResult- Containsallowed,reasons, andresultsarraysPolicyGroup- Group configuration withoperatorandpoliciesPolicyFunction- A policy function(context, stage?) => Promise<PolicyResult>PolicyRegistry- The registry interfaceFeaturePolicyDefinition- The object returned bydefinePolicy()PolicyErrorCode- Structured error accepted bydeny()
Related documentation
- Team Account API - Team management operations
- Billing Configuration - Payment provider setup
- Row Level Security - Database-level security
- Per-seat Billing - Team-based pricing with seat limits