Feature Policies API + Invitations Policies (#375)

- Added Feature Policy API: a declarative system to enable/disable/modify default behavior in the SaaS kit
- Team invitation policies with pre-checks using the Feature Policy API: Invite Members dialog now shows loading, errors, and clear reasons when invitations are blocked
- Version bump to 2.16.0 and widespread dependency updates (Supabase, React types, react-i18next, etc.).
- Added comprehensive docs for the new policy system and orchestrators.
- Subscription cancellations now trigger immediate invoicing explicitly
This commit is contained in:
Giancarlo Buomprisco
2025-09-30 12:36:19 +08:00
committed by GitHub
parent 3c13b5ec1e
commit 1dd6fdad22
53 changed files with 3908 additions and 1128 deletions

684
packages/policies/AGENTS.md Normal file
View File

@@ -0,0 +1,684 @@
# FeaturePolicy API - Registry-Based Policy System
A unified, registry-based foundation for implementing business rules across all Makerkit features.
## Overview
The FeaturePolicy API provides:
- **Registry-based architecture** - centralized policy management with IDs
- **Configuration support** - policies can accept typed configuration objects
- **Stage-aware evaluation** - policies can be filtered by execution stage
- **Immutable contexts** for safe policy evaluation
- **Customer extensibility** - easy to add custom policies without forking
## Quick Start
### 1. Register Policies
```typescript
import { z } from 'zod';
import { allow, createPolicyRegistry, definePolicy, deny } from '@kit/policies';
const registry = createPolicyRegistry();
// Register a basic policy
registry.registerPolicy(
definePolicy({
id: 'email-validation',
stages: ['preliminary', 'submission'],
evaluate: async (context) => {
if (!context.userEmail?.includes('@')) {
return deny({
code: 'INVALID_EMAIL_FORMAT',
message: 'Invalid email format',
remediation: 'Please provide a valid email address',
});
}
return allow();
},
}),
);
// Register a configurable policy
registry.registerPolicy(
definePolicy({
id: 'max-invitations',
configSchema: z.object({
maxInvitations: z.number().positive(),
}),
evaluate: async (context, config = { maxInvitations: 5 }) => {
if (context.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();
},
}),
);
```
### 2. Use Policies from Registry
```typescript
import {
createPoliciesFromRegistry,
createPolicyEvaluator,
createPolicyRegistry,
} from '@kit/policies';
const registry = createPolicyRegistry();
// Load policies from registry
const policies = await createPoliciesFromRegistry(registry, [
'email-validation',
'subscription-required',
['max-invitations', { maxInvitations: 5 }], // with configuration
]);
const evaluator = createPolicyEvaluator();
const result = await evaluator.evaluatePolicies(policies, context, 'ALL');
if (!result.allowed) {
console.log('Failed reasons:', result.reasons);
}
```
### 3. Group Policies with Complex Logic
```typescript
// Basic group example
const preliminaryGroup = {
operator: 'ALL' as const,
policies: [emailValidationPolicy, authenticationPolicy],
};
const billingGroup = {
operator: 'ANY' as const,
policies: [subscriptionPolicy, trialPolicy],
};
// Evaluate groups in sequence
const result = await evaluator.evaluateGroups(
[preliminaryGroup, billingGroup],
context,
);
```
## Complex Group Flows
### Real-World Multi-Stage Team Invitation Flow
```typescript
import { createPolicy, createPolicyEvaluator } from '@kit/policies';
// Complex business logic: (Authentication AND Email Validation) AND (Subscription OR Trial) AND Billing Limits
async function validateTeamInvitation(context: InvitationContext) {
const evaluator = createPolicyEvaluator();
// Stage 1: Authentication Requirements (ALL must pass)
const authenticationGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.userId
? allow({ step: 'authenticated' })
: deny('Authentication required'),
),
createPolicy(async (ctx) =>
ctx.email.includes('@')
? allow({ step: 'email-valid' })
: deny('Valid email required'),
),
createPolicy(async (ctx) =>
ctx.permissions.includes('invite')
? allow({ step: 'permissions' })
: deny('Insufficient permissions'),
),
],
};
// Stage 2: Subscription Validation (ANY sufficient - flexible billing)
const subscriptionGroup = {
operator: 'ANY' as const,
policies: [
createPolicy(async (ctx) =>
ctx.subscription?.active && ctx.subscription.plan === 'enterprise'
? allow({ billing: 'enterprise' })
: deny('Enterprise subscription required'),
),
createPolicy(async (ctx) =>
ctx.subscription?.active && ctx.subscription.plan === 'pro'
? allow({ billing: 'pro' })
: deny('Pro subscription required'),
),
createPolicy(async (ctx) =>
ctx.trial?.active && ctx.trial.daysRemaining > 0
? allow({ billing: 'trial', daysLeft: ctx.trial.daysRemaining })
: deny('Active trial required'),
),
],
};
// Stage 3: Final Constraints (ALL must pass)
const constraintsGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.team.memberCount < ctx.subscription?.maxMembers
? allow({ constraint: 'member-limit' })
: deny('Member limit exceeded'),
),
createPolicy(async (ctx) =>
ctx.invitations.length <= 10
? allow({ constraint: 'batch-size' })
: deny('Cannot invite more than 10 members at once'),
),
],
};
// Execute all groups sequentially - ALL groups must pass
const result = await evaluator.evaluateGroups(
[authenticationGroup, subscriptionGroup, constraintsGroup],
context,
);
return {
allowed: result.allowed,
reasons: result.reasons,
metadata: {
stagesCompleted: result.results.length,
authenticationPassed: result.results.some(
(r) => r.metadata?.step === 'authenticated',
),
billingType: result.results.find((r) => r.metadata?.billing)?.metadata
?.billing,
constraintsChecked: result.results.some((r) => r.metadata?.constraint),
},
};
}
```
### Middleware-Style Policy Chain
```typescript
// Simulate middleware pattern: Auth → Rate Limiting → Business Logic
async function processApiRequest(context: ApiContext) {
const evaluator = createPoliciesEvaluator();
// Layer 1: Security (ALL required)
const securityLayer = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.apiKey && ctx.apiKey.length > 0
? allow({ security: 'api-key-valid' })
: deny('API key required'),
),
createPolicy(async (ctx) =>
ctx.rateLimitRemaining > 0
? allow({ security: 'rate-limit-ok' })
: deny('Rate limit exceeded'),
),
createPolicy(async (ctx) =>
!ctx.blacklisted
? allow({ security: 'not-blacklisted' })
: deny('Client is blacklisted'),
),
],
};
// Layer 2: Authorization (ANY sufficient - flexible access levels)
const authorizationLayer = {
operator: 'ANY' as const,
policies: [
createPolicy(async (ctx) =>
ctx.user.role === 'admin'
? allow({ access: 'admin' })
: deny('Admin access denied'),
),
createPolicy(async (ctx) =>
ctx.user.permissions.includes(ctx.requestedResource)
? allow({ access: 'resource-specific' })
: deny('Resource access denied'),
),
createPolicy(async (ctx) =>
ctx.user.subscription?.includes('api-access')
? allow({ access: 'subscription-based' })
: deny('Subscription access denied'),
),
],
};
// Layer 3: Business Rules (ALL required)
const businessLayer = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.request.size <= ctx.maxRequestSize
? allow({ business: 'size-valid' })
: deny('Request too large'),
),
createPolicy(async (ctx) =>
ctx.user.dailyQuota > ctx.user.dailyUsage
? allow({ business: 'quota-available' })
: deny('Daily quota exceeded'),
),
],
};
return evaluator.evaluateGroups(
[securityLayer, authorizationLayer, businessLayer],
context,
);
}
```
### Complex Nested Logic with Short-Circuiting
```typescript
// Complex scenario: (Premium User OR (Basic User AND Low Usage)) AND Security Checks
async function validateFeatureAccess(context: FeatureContext) {
const evaluator = createPoliciesEvaluator();
// Group 1: User Tier Logic - demonstrates complex OR conditions
const userTierGroup = {
operator: 'ANY' as const,
policies: [
// Premium users get immediate access
createPolicy(async (ctx) =>
ctx.user.plan === 'premium'
? allow({ tier: 'premium', reason: 'premium-user' })
: deny('Not premium user'),
),
// Enterprise users get immediate access
createPolicy(async (ctx) =>
ctx.user.plan === 'enterprise'
? allow({ tier: 'enterprise', reason: 'enterprise-user' })
: deny('Not enterprise user'),
),
// Basic users need additional validation (sub-group logic)
createPolicy(async (ctx) => {
if (ctx.user.plan !== 'basic') {
return deny('Not basic user');
}
// Simulate nested AND logic for basic users
const basicUserRequirements = [
ctx.user.monthlyUsage < 1000,
ctx.user.accountAge > 30, // days
!ctx.user.hasViolations,
];
const allBasicRequirementsMet = basicUserRequirements.every(
(req) => req,
);
return allBasicRequirementsMet
? allow({ tier: 'basic', reason: 'low-usage-basic-user' })
: deny('Basic user requirements not met');
}),
],
};
// Group 2: Security Requirements (ALL must pass)
const securityGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.user.emailVerified
? allow({ security: 'email-verified' })
: deny('Email verification required'),
),
createPolicy(async (ctx) =>
ctx.user.twoFactorEnabled || ctx.user.plan === 'basic'
? allow({ security: '2fa-compliant' })
: deny('Two-factor authentication required for premium plans'),
),
createPolicy(async (ctx) =>
!ctx.user.suspiciousActivity
? allow({ security: 'activity-clean' })
: deny('Suspicious activity detected'),
),
],
};
return evaluator.evaluateGroups([userTierGroup, securityGroup], context);
}
```
### Dynamic Policy Composition
```typescript
// Dynamically compose policies based on context
async function createContextAwarePolicyFlow(context: DynamicContext) {
const evaluator = createPoliciesEvaluator();
const groups = [];
// Always include base security
const baseSecurityGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.isAuthenticated ? allow() : deny('Authentication required'),
),
],
};
groups.push(baseSecurityGroup);
// Add user-type specific policies
if (context.user.type === 'admin') {
const adminGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.user.adminLevel >= ctx.requiredAdminLevel
? allow({ admin: 'level-sufficient' })
: deny('Insufficient admin level'),
),
createPolicy(async (ctx) =>
ctx.user.lastLogin > Date.now() - 24 * 60 * 60 * 1000 // 24 hours
? allow({ admin: 'recent-login' })
: deny('Admin must have logged in within 24 hours'),
),
],
};
groups.push(adminGroup);
}
// Add feature-specific policies based on requested feature
if (context.feature.requiresBilling) {
const billingGroup = {
operator: 'ANY' as const,
policies: [
createPolicy(async (ctx) =>
ctx.subscription?.active
? allow({ billing: 'subscription' })
: deny('Active subscription required'),
),
createPolicy(async (ctx) =>
ctx.credits && ctx.credits > ctx.feature.creditCost
? allow({ billing: 'credits' })
: deny('Insufficient credits'),
),
],
};
groups.push(billingGroup);
}
// Add rate limiting for high-impact features
if (context.feature.highImpact) {
const rateLimitGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.rateLimit.remaining > 0
? allow({ rateLimit: 'within-limits' })
: deny('Rate limit exceeded for high-impact features'),
),
],
};
groups.push(rateLimitGroup);
}
return evaluator.evaluateGroups(groups, context);
}
```
### Performance-Optimized Large Group Evaluation
```typescript
// Handle large numbers of policies efficiently
async function validateComplexBusinessRules(context: BusinessContext) {
const evaluator = createPoliciesEvaluator({ maxCacheSize: 200 });
// Group policies by evaluation cost and criticality
const criticalFastGroup = {
operator: 'ALL' as const,
policies: [
// Fast critical checks first
createPolicy(async (ctx) =>
ctx.isActive ? allow() : deny('Account inactive'),
),
createPolicy(async (ctx) =>
ctx.hasPermission ? allow() : deny('No permission'),
),
createPolicy(async (ctx) =>
!ctx.isBlocked ? allow() : deny('Account blocked'),
),
],
};
const businessLogicGroup = {
operator: 'ANY' as const,
policies: [
// Complex business rules
createPolicy(async (ctx) => {
// Simulate complex calculation
const score = await calculateRiskScore(ctx);
return score < 0.8
? allow({ risk: 'low' })
: deny('High risk detected');
}),
createPolicy(async (ctx) => {
// Simulate external API call
const verification = await verifyWithThirdParty(ctx);
return verification.success
? allow({ external: 'verified' })
: deny('External verification failed');
}),
],
};
const finalValidationGroup = {
operator: 'ALL' as const,
policies: [
// Final checks after complex logic
createPolicy(async (ctx) =>
ctx.complianceCheck ? allow() : deny('Compliance check failed'),
),
],
};
// Use staged evaluation for better performance
const startTime = Date.now();
const result = await evaluator.evaluateGroups(
[
criticalFastGroup, // Fast critical checks first
businessLogicGroup, // Complex logic only if critical checks pass
finalValidationGroup, // Final validation
],
context,
);
const evaluationTime = Date.now() - startTime;
return {
...result,
performance: {
evaluationTimeMs: evaluationTime,
groupsEvaluated: result.results.length > 0 ? 3 : 1,
},
};
}
// Helper functions for complex examples
async function calculateRiskScore(context: any): Promise<number> {
// Simulate complex risk calculation
await new Promise((resolve) => setTimeout(resolve, 10));
return Math.random();
}
async function verifyWithThirdParty(
context: any,
): Promise<{ success: boolean }> {
// Simulate external API call
await new Promise((resolve) => setTimeout(resolve, 5));
return { success: Math.random() > 0.2 };
}
```
## Advanced Usage
### Configurable Policies
```typescript
// Create policy factories for configuration
const createMaxInvitationsPolicy = (maxInvitations: number) =>
createPolicy(async (context) => {
if (context.invitations.length > maxInvitations) {
return deny({
code: 'MAX_INVITATIONS_EXCEEDED',
message: `Cannot invite more than ${maxInvitations} members`,
remediation: `Reduce invitations to ${maxInvitations} or fewer`,
});
}
return allow();
});
// Use with different configurations
const strictPolicy = createMaxInvitationsPolicy(1);
const standardPolicy = createMaxInvitationsPolicy(5);
const permissivePolicy = createMaxInvitationsPolicy(25);
```
### Feature-Specific evaluators
```typescript
// Create feature-specific evaluator with preset configurations
export function createInvitationevaluator(
preset: 'strict' | 'standard' | 'permissive',
) {
const configs = {
strict: { maxInvitationsPerBatch: 1 },
standard: { maxInvitationsPerBatch: 5 },
permissive: { maxInvitationsPerBatch: 25 },
};
const config = configs[preset];
return {
async validateInvitations(context: InvitationContext) {
const policies = [
emailValidationPolicy,
createMaxInvitationsPolicy(config.maxInvitationsPerBatch),
subscriptionRequiredPolicy,
paddleBillingPolicy,
];
const evaluator = createPoliciesEvaluator();
return evaluator.evaluatePolicies(policies, context, 'ALL');
},
};
}
// Usage
const evaluator = createInvitationevaluator('standard');
const result = await evaluator.validateInvitations(context);
```
### Error Handling
```typescript
const result = await evaluator.evaluate();
if (!result.allowed) {
result.reasons.forEach((reason) => {
console.log(`Policy ${reason.policyId} failed:`);
console.log(` Code: ${reason.code}`);
console.log(` Message: ${reason.message}`);
if (reason.remediation) {
console.log(` Fix: ${reason.remediation}`);
}
});
}
```
### 1. Register Complex Policy with Configuration
```typescript
import { createPolicyRegistry, definePolicy } from '@kit/policies';
const registry = createPolicyRegistry();
const customConfigurablePolicy = definePolicy({
id: 'custom-domain-check',
configSchema: z.object({
allowedDomains: z.array(z.string()),
strictMode: z.boolean(),
}),
evaluate: async (context, config) => {
const emailDomain = context.userEmail?.split('@')[1];
if (config?.strictMode && !config.allowedDomains.includes(emailDomain)) {
return deny({
code: 'DOMAIN_NOT_ALLOWED',
message: `Email domain ${emailDomain} is not in the allowed list`,
remediation: 'Use an email from an approved domain',
});
}
return allow();
},
});
registry.registerPolicy(customConfigurablePolicy);
```
## Key Concepts
### 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
- Example: Authentication AND permissions AND rate limiting
- **`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
- Example: Premium subscription OR trial access OR admin override
### Group Evaluation Flow
1. **Sequential Group Processing**: Groups are evaluated in order
2. **All Groups Must Pass**: If any group fails, entire evaluation fails
3. **Short-Circuiting**: Stops on first group failure for performance
4. **Metadata Preservation**: All policy results and metadata are collected
### Performance Considerations
- **Order groups by criticality**: Put fast, critical checks first
- **Use caching**: Configure `maxCacheSize` for frequently used policies
- **Group by evaluation cost**: Separate expensive operations
- **Monitor evaluation time**: Track performance for optimization
## Stage-Aware Evaluation
Policies can be filtered by execution stage. This is useful for running a subset of policies depending on the situation:
```typescript
// Only run preliminary checks
const prelimResult = await evaluator.evaluate(
registry,
context,
'ALL',
'preliminary',
);
// Run submission validation
const submitResult = await evaluator.evaluate(
registry,
context,
'ALL',
'submission',
);
// Run all applicable policies
const fullResult = await evaluator.evaluate(registry, context, 'ALL');
```

684
packages/policies/CLAUDE.md Normal file
View File

@@ -0,0 +1,684 @@
# FeaturePolicy API - Registry-Based Policy System
A unified, registry-based foundation for implementing business rules across all Makerkit features.
## Overview
The FeaturePolicy API provides:
- **Registry-based architecture** - centralized policy management with IDs
- **Configuration support** - policies can accept typed configuration objects
- **Stage-aware evaluation** - policies can be filtered by execution stage
- **Immutable contexts** for safe policy evaluation
- **Customer extensibility** - easy to add custom policies without forking
## Quick Start
### 1. Register Policies
```typescript
import { z } from 'zod';
import { allow, createPolicyRegistry, definePolicy, deny } from '@kit/policies';
const registry = createPolicyRegistry();
// Register a basic policy
registry.registerPolicy(
definePolicy({
id: 'email-validation',
stages: ['preliminary', 'submission'],
evaluate: async (context) => {
if (!context.userEmail?.includes('@')) {
return deny({
code: 'INVALID_EMAIL_FORMAT',
message: 'Invalid email format',
remediation: 'Please provide a valid email address',
});
}
return allow();
},
}),
);
// Register a configurable policy
registry.registerPolicy(
definePolicy({
id: 'max-invitations',
configSchema: z.object({
maxInvitations: z.number().positive(),
}),
evaluate: async (context, config = { maxInvitations: 5 }) => {
if (context.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();
},
}),
);
```
### 2. Use Policies from Registry
```typescript
import {
createPoliciesFromRegistry,
createPolicyEvaluator,
createPolicyRegistry,
} from '@kit/policies';
const registry = createPolicyRegistry();
// Load policies from registry
const policies = await createPoliciesFromRegistry(registry, [
'email-validation',
'subscription-required',
['max-invitations', { maxInvitations: 5 }], // with configuration
]);
const evaluator = createPolicyEvaluator();
const result = await evaluator.evaluatePolicies(policies, context, 'ALL');
if (!result.allowed) {
console.log('Failed reasons:', result.reasons);
}
```
### 3. Group Policies with Complex Logic
```typescript
// Basic group example
const preliminaryGroup = {
operator: 'ALL' as const,
policies: [emailValidationPolicy, authenticationPolicy],
};
const billingGroup = {
operator: 'ANY' as const,
policies: [subscriptionPolicy, trialPolicy],
};
// Evaluate groups in sequence
const result = await evaluator.evaluateGroups(
[preliminaryGroup, billingGroup],
context,
);
```
## Complex Group Flows
### Real-World Multi-Stage Team Invitation Flow
```typescript
import { createPolicy, createPolicyEvaluator } from '@kit/policies';
// Complex business logic: (Authentication AND Email Validation) AND (Subscription OR Trial) AND Billing Limits
async function validateTeamInvitation(context: InvitationContext) {
const evaluator = createPolicyEvaluator();
// Stage 1: Authentication Requirements (ALL must pass)
const authenticationGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.userId
? allow({ step: 'authenticated' })
: deny('Authentication required'),
),
createPolicy(async (ctx) =>
ctx.email.includes('@')
? allow({ step: 'email-valid' })
: deny('Valid email required'),
),
createPolicy(async (ctx) =>
ctx.permissions.includes('invite')
? allow({ step: 'permissions' })
: deny('Insufficient permissions'),
),
],
};
// Stage 2: Subscription Validation (ANY sufficient - flexible billing)
const subscriptionGroup = {
operator: 'ANY' as const,
policies: [
createPolicy(async (ctx) =>
ctx.subscription?.active && ctx.subscription.plan === 'enterprise'
? allow({ billing: 'enterprise' })
: deny('Enterprise subscription required'),
),
createPolicy(async (ctx) =>
ctx.subscription?.active && ctx.subscription.plan === 'pro'
? allow({ billing: 'pro' })
: deny('Pro subscription required'),
),
createPolicy(async (ctx) =>
ctx.trial?.active && ctx.trial.daysRemaining > 0
? allow({ billing: 'trial', daysLeft: ctx.trial.daysRemaining })
: deny('Active trial required'),
),
],
};
// Stage 3: Final Constraints (ALL must pass)
const constraintsGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.team.memberCount < ctx.subscription?.maxMembers
? allow({ constraint: 'member-limit' })
: deny('Member limit exceeded'),
),
createPolicy(async (ctx) =>
ctx.invitations.length <= 10
? allow({ constraint: 'batch-size' })
: deny('Cannot invite more than 10 members at once'),
),
],
};
// Execute all groups sequentially - ALL groups must pass
const result = await evaluator.evaluateGroups(
[authenticationGroup, subscriptionGroup, constraintsGroup],
context,
);
return {
allowed: result.allowed,
reasons: result.reasons,
metadata: {
stagesCompleted: result.results.length,
authenticationPassed: result.results.some(
(r) => r.metadata?.step === 'authenticated',
),
billingType: result.results.find((r) => r.metadata?.billing)?.metadata
?.billing,
constraintsChecked: result.results.some((r) => r.metadata?.constraint),
},
};
}
```
### Middleware-Style Policy Chain
```typescript
// Simulate middleware pattern: Auth → Rate Limiting → Business Logic
async function processApiRequest(context: ApiContext) {
const evaluator = createPoliciesEvaluator();
// Layer 1: Security (ALL required)
const securityLayer = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.apiKey && ctx.apiKey.length > 0
? allow({ security: 'api-key-valid' })
: deny('API key required'),
),
createPolicy(async (ctx) =>
ctx.rateLimitRemaining > 0
? allow({ security: 'rate-limit-ok' })
: deny('Rate limit exceeded'),
),
createPolicy(async (ctx) =>
!ctx.blacklisted
? allow({ security: 'not-blacklisted' })
: deny('Client is blacklisted'),
),
],
};
// Layer 2: Authorization (ANY sufficient - flexible access levels)
const authorizationLayer = {
operator: 'ANY' as const,
policies: [
createPolicy(async (ctx) =>
ctx.user.role === 'admin'
? allow({ access: 'admin' })
: deny('Admin access denied'),
),
createPolicy(async (ctx) =>
ctx.user.permissions.includes(ctx.requestedResource)
? allow({ access: 'resource-specific' })
: deny('Resource access denied'),
),
createPolicy(async (ctx) =>
ctx.user.subscription?.includes('api-access')
? allow({ access: 'subscription-based' })
: deny('Subscription access denied'),
),
],
};
// Layer 3: Business Rules (ALL required)
const businessLayer = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.request.size <= ctx.maxRequestSize
? allow({ business: 'size-valid' })
: deny('Request too large'),
),
createPolicy(async (ctx) =>
ctx.user.dailyQuota > ctx.user.dailyUsage
? allow({ business: 'quota-available' })
: deny('Daily quota exceeded'),
),
],
};
return evaluator.evaluateGroups(
[securityLayer, authorizationLayer, businessLayer],
context,
);
}
```
### Complex Nested Logic with Short-Circuiting
```typescript
// Complex scenario: (Premium User OR (Basic User AND Low Usage)) AND Security Checks
async function validateFeatureAccess(context: FeatureContext) {
const evaluator = createPoliciesEvaluator();
// Group 1: User Tier Logic - demonstrates complex OR conditions
const userTierGroup = {
operator: 'ANY' as const,
policies: [
// Premium users get immediate access
createPolicy(async (ctx) =>
ctx.user.plan === 'premium'
? allow({ tier: 'premium', reason: 'premium-user' })
: deny('Not premium user'),
),
// Enterprise users get immediate access
createPolicy(async (ctx) =>
ctx.user.plan === 'enterprise'
? allow({ tier: 'enterprise', reason: 'enterprise-user' })
: deny('Not enterprise user'),
),
// Basic users need additional validation (sub-group logic)
createPolicy(async (ctx) => {
if (ctx.user.plan !== 'basic') {
return deny('Not basic user');
}
// Simulate nested AND logic for basic users
const basicUserRequirements = [
ctx.user.monthlyUsage < 1000,
ctx.user.accountAge > 30, // days
!ctx.user.hasViolations,
];
const allBasicRequirementsMet = basicUserRequirements.every(
(req) => req,
);
return allBasicRequirementsMet
? allow({ tier: 'basic', reason: 'low-usage-basic-user' })
: deny('Basic user requirements not met');
}),
],
};
// Group 2: Security Requirements (ALL must pass)
const securityGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.user.emailVerified
? allow({ security: 'email-verified' })
: deny('Email verification required'),
),
createPolicy(async (ctx) =>
ctx.user.twoFactorEnabled || ctx.user.plan === 'basic'
? allow({ security: '2fa-compliant' })
: deny('Two-factor authentication required for premium plans'),
),
createPolicy(async (ctx) =>
!ctx.user.suspiciousActivity
? allow({ security: 'activity-clean' })
: deny('Suspicious activity detected'),
),
],
};
return evaluator.evaluateGroups([userTierGroup, securityGroup], context);
}
```
### Dynamic Policy Composition
```typescript
// Dynamically compose policies based on context
async function createContextAwarePolicyFlow(context: DynamicContext) {
const evaluator = createPoliciesEvaluator();
const groups = [];
// Always include base security
const baseSecurityGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.isAuthenticated ? allow() : deny('Authentication required'),
),
],
};
groups.push(baseSecurityGroup);
// Add user-type specific policies
if (context.user.type === 'admin') {
const adminGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.user.adminLevel >= ctx.requiredAdminLevel
? allow({ admin: 'level-sufficient' })
: deny('Insufficient admin level'),
),
createPolicy(async (ctx) =>
ctx.user.lastLogin > Date.now() - 24 * 60 * 60 * 1000 // 24 hours
? allow({ admin: 'recent-login' })
: deny('Admin must have logged in within 24 hours'),
),
],
};
groups.push(adminGroup);
}
// Add feature-specific policies based on requested feature
if (context.feature.requiresBilling) {
const billingGroup = {
operator: 'ANY' as const,
policies: [
createPolicy(async (ctx) =>
ctx.subscription?.active
? allow({ billing: 'subscription' })
: deny('Active subscription required'),
),
createPolicy(async (ctx) =>
ctx.credits && ctx.credits > ctx.feature.creditCost
? allow({ billing: 'credits' })
: deny('Insufficient credits'),
),
],
};
groups.push(billingGroup);
}
// Add rate limiting for high-impact features
if (context.feature.highImpact) {
const rateLimitGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.rateLimit.remaining > 0
? allow({ rateLimit: 'within-limits' })
: deny('Rate limit exceeded for high-impact features'),
),
],
};
groups.push(rateLimitGroup);
}
return evaluator.evaluateGroups(groups, context);
}
```
### Performance-Optimized Large Group Evaluation
```typescript
// Handle large numbers of policies efficiently
async function validateComplexBusinessRules(context: BusinessContext) {
const evaluator = createPoliciesEvaluator({ maxCacheSize: 200 });
// Group policies by evaluation cost and criticality
const criticalFastGroup = {
operator: 'ALL' as const,
policies: [
// Fast critical checks first
createPolicy(async (ctx) =>
ctx.isActive ? allow() : deny('Account inactive'),
),
createPolicy(async (ctx) =>
ctx.hasPermission ? allow() : deny('No permission'),
),
createPolicy(async (ctx) =>
!ctx.isBlocked ? allow() : deny('Account blocked'),
),
],
};
const businessLogicGroup = {
operator: 'ANY' as const,
policies: [
// Complex business rules
createPolicy(async (ctx) => {
// Simulate complex calculation
const score = await calculateRiskScore(ctx);
return score < 0.8
? allow({ risk: 'low' })
: deny('High risk detected');
}),
createPolicy(async (ctx) => {
// Simulate external API call
const verification = await verifyWithThirdParty(ctx);
return verification.success
? allow({ external: 'verified' })
: deny('External verification failed');
}),
],
};
const finalValidationGroup = {
operator: 'ALL' as const,
policies: [
// Final checks after complex logic
createPolicy(async (ctx) =>
ctx.complianceCheck ? allow() : deny('Compliance check failed'),
),
],
};
// Use staged evaluation for better performance
const startTime = Date.now();
const result = await evaluator.evaluateGroups(
[
criticalFastGroup, // Fast critical checks first
businessLogicGroup, // Complex logic only if critical checks pass
finalValidationGroup, // Final validation
],
context,
);
const evaluationTime = Date.now() - startTime;
return {
...result,
performance: {
evaluationTimeMs: evaluationTime,
groupsEvaluated: result.results.length > 0 ? 3 : 1,
},
};
}
// Helper functions for complex examples
async function calculateRiskScore(context: any): Promise<number> {
// Simulate complex risk calculation
await new Promise((resolve) => setTimeout(resolve, 10));
return Math.random();
}
async function verifyWithThirdParty(
context: any,
): Promise<{ success: boolean }> {
// Simulate external API call
await new Promise((resolve) => setTimeout(resolve, 5));
return { success: Math.random() > 0.2 };
}
```
## Advanced Usage
### Configurable Policies
```typescript
// Create policy factories for configuration
const createMaxInvitationsPolicy = (maxInvitations: number) =>
createPolicy(async (context) => {
if (context.invitations.length > maxInvitations) {
return deny({
code: 'MAX_INVITATIONS_EXCEEDED',
message: `Cannot invite more than ${maxInvitations} members`,
remediation: `Reduce invitations to ${maxInvitations} or fewer`,
});
}
return allow();
});
// Use with different configurations
const strictPolicy = createMaxInvitationsPolicy(1);
const standardPolicy = createMaxInvitationsPolicy(5);
const permissivePolicy = createMaxInvitationsPolicy(25);
```
### Feature-Specific evaluators
```typescript
// Create feature-specific evaluator with preset configurations
export function createInvitationevaluator(
preset: 'strict' | 'standard' | 'permissive',
) {
const configs = {
strict: { maxInvitationsPerBatch: 1 },
standard: { maxInvitationsPerBatch: 5 },
permissive: { maxInvitationsPerBatch: 25 },
};
const config = configs[preset];
return {
async validateInvitations(context: InvitationContext) {
const policies = [
emailValidationPolicy,
createMaxInvitationsPolicy(config.maxInvitationsPerBatch),
subscriptionRequiredPolicy,
paddleBillingPolicy,
];
const evaluator = createPoliciesEvaluator();
return evaluator.evaluatePolicies(policies, context, 'ALL');
},
};
}
// Usage
const evaluator = createInvitationevaluator('standard');
const result = await evaluator.validateInvitations(context);
```
### Error Handling
```typescript
const result = await evaluator.evaluate();
if (!result.allowed) {
result.reasons.forEach((reason) => {
console.log(`Policy ${reason.policyId} failed:`);
console.log(` Code: ${reason.code}`);
console.log(` Message: ${reason.message}`);
if (reason.remediation) {
console.log(` Fix: ${reason.remediation}`);
}
});
}
```
### 1. Register Complex Policy with Configuration
```typescript
import { createPolicyRegistry, definePolicy } from '@kit/policies';
const registry = createPolicyRegistry();
const customConfigurablePolicy = definePolicy({
id: 'custom-domain-check',
configSchema: z.object({
allowedDomains: z.array(z.string()),
strictMode: z.boolean(),
}),
evaluate: async (context, config) => {
const emailDomain = context.userEmail?.split('@')[1];
if (config?.strictMode && !config.allowedDomains.includes(emailDomain)) {
return deny({
code: 'DOMAIN_NOT_ALLOWED',
message: `Email domain ${emailDomain} is not in the allowed list`,
remediation: 'Use an email from an approved domain',
});
}
return allow();
},
});
registry.registerPolicy(customConfigurablePolicy);
```
## Key Concepts
### 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
- Example: Authentication AND permissions AND rate limiting
- **`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
- Example: Premium subscription OR trial access OR admin override
### Group Evaluation Flow
1. **Sequential Group Processing**: Groups are evaluated in order
2. **All Groups Must Pass**: If any group fails, entire evaluation fails
3. **Short-Circuiting**: Stops on first group failure for performance
4. **Metadata Preservation**: All policy results and metadata are collected
### Performance Considerations
- **Order groups by criticality**: Put fast, critical checks first
- **Use caching**: Configure `maxCacheSize` for frequently used policies
- **Group by evaluation cost**: Separate expensive operations
- **Monitor evaluation time**: Track performance for optimization
## Stage-Aware Evaluation
Policies can be filtered by execution stage. This is useful for running a subset of policies depending on the situation:
```typescript
// Only run preliminary checks
const prelimResult = await evaluator.evaluate(
registry,
context,
'ALL',
'preliminary',
);
// Run submission validation
const submitResult = await evaluator.evaluate(
registry,
context,
'ALL',
'submission',
);
// Run all applicable policies
const fullResult = await evaluator.evaluate(registry, context, 'ALL');
```

View File

@@ -0,0 +1,3 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -0,0 +1 @@
export * from './src';

View File

@@ -0,0 +1,29 @@
{
"name": "@kit/policies",
"private": true,
"version": "0.1.0",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*",
"zod": "^3.25.74"
},
"prettier": "@kit/prettier-config"
}

View File

@@ -0,0 +1,247 @@
import type { z } from 'zod';
import type { PolicyContext, PolicyResult, PolicyStage } from './types';
/**
* Error code for structured policy failures
*/
export interface PolicyErrorCode {
/** Machine-readable error code */
code: string;
/** Human-readable error message */
message: string;
/** Optional remediation instructions */
remediation?: string;
/** Additional metadata */
metadata?: Record<string, unknown>;
}
/**
* Enhanced policy result with structured error information
*/
export interface PolicyReason extends PolicyErrorCode {
/** Policy ID that generated this reason */
policyId: string;
/** Stage at which this reason was generated */
stage?: PolicyStage;
}
/**
* Policy evaluator function with immutable context
*/
export interface PolicyEvaluator<TContext extends PolicyContext> {
/** Evaluate the policy for a specific stage */
evaluate(stage?: PolicyStage): Promise<PolicyResult>;
/** Get the immutable context */
getContext(): Readonly<TContext>;
}
/**
* Policy definition factory configuration
*/
export interface FeaturePolicyDefinition<
TContext extends PolicyContext = PolicyContext,
TConfig = unknown,
> {
/** Unique policy identifier */
id: string;
/** Optional stages this policy applies to */
stages?: PolicyStage[];
/** Optional configuration schema for validation */
configSchema?: z.ZodType<TConfig>;
/** Factory function to create evaluator instances */
create(context: TContext, config?: TConfig): PolicyEvaluator<TContext>;
}
/**
* Helper function to create a successful policy result
*/
export function allow(metadata?: Record<string, unknown>): PolicyResult {
return {
allowed: true,
metadata,
};
}
/**
* Helper function to create a failed policy result with structured error
*/
export function deny(error: PolicyErrorCode): PolicyResult {
return {
allowed: false,
reason: error.message,
metadata: {
code: error.code,
remediation: error.remediation,
...error.metadata,
},
};
}
/**
* Deep freeze an object and all its nested properties
*/
function deepFreeze<T>(obj: T, visited = new WeakSet()): Readonly<T> {
// Prevent infinite recursion with circular references
if (visited.has(obj as object)) {
return obj;
}
visited.add(obj as object);
// Get all property names
const propNames = Reflect.ownKeys(obj as object);
// Freeze properties before freezing self
for (const name of propNames) {
const value = (obj as Record<string, unknown>)[name as string];
if ((value && typeof value === 'object') || typeof value === 'function') {
deepFreeze(value, visited);
}
}
return Object.freeze(obj);
}
/**
* Safe cloning that handles functions and other non-cloneable objects
*/
function safeClone<T>(obj: T): T {
try {
return structuredClone(obj);
} catch {
// If structuredClone fails (e.g., due to functions), create a shallow clone
// and recursively clone cloneable properties
if (obj && typeof obj === 'object') {
const cloned = Array.isArray(obj) ? ([] as unknown as T) : ({} as T);
for (const [key, value] of Object.entries(obj)) {
try {
// Try to clone individual properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(cloned as any)[key] = structuredClone(value);
} catch {
// If individual property can't be cloned (like functions), keep as-is
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(cloned as any)[key] = value;
}
}
return cloned;
}
// For primitives or non-cloneable objects, return as-is
return obj;
}
}
/**
* Creates an immutable context wrapper
*/
function createImmutableContext<T extends PolicyContext>(
context: T,
): Readonly<T> {
// Safely clone the context, handling functions and other edge cases
const cloned = safeClone(context);
// Deep freeze the object to make it immutable
return deepFreeze(cloned);
}
/**
* Factory function to define a policy with metadata and configuration
*/
export function definePolicy<
TContext extends PolicyContext = PolicyContext,
TConfig = unknown,
>(config: {
/** Unique policy identifier */
id: string;
/** Optional stages this policy applies to */
stages?: PolicyStage[];
/** Optional configuration schema for validation */
configSchema?: z.ZodType<TConfig>;
/** Policy implementation function */
evaluate: (
context: Readonly<TContext>,
config?: TConfig,
stage?: PolicyStage,
) => Promise<PolicyResult>;
}) {
return {
id: config.id,
stages: config.stages,
configSchema: config.configSchema,
create(context: TContext, policyConfig?: TConfig) {
// Validate configuration if schema is provided
if (config.configSchema && policyConfig !== undefined) {
const validation = config.configSchema.safeParse(policyConfig);
if (!validation.success) {
throw new Error(
`Invalid configuration for policy "${config.id}": ${validation.error.message}`,
);
}
}
// Create immutable context
const immutableContext = createImmutableContext(context);
return {
async evaluate(stage?: PolicyStage) {
// Check if this policy should run at this stage
if (stage && config.stages && !config.stages.includes(stage)) {
return allow({
skipped: true,
reason: `Policy not applicable for stage: ${stage}`,
});
}
try {
const result = await config.evaluate(
immutableContext,
policyConfig,
stage,
);
// Ensure metadata includes policy ID and stage
return {
...result,
metadata: {
policyId: config.id,
stage,
...result.metadata,
},
};
} catch (error) {
return deny({
code: 'POLICY_EVALUATION_ERROR',
message:
error instanceof Error
? error.message
: 'Policy evaluation failed',
metadata: {
policyId: config.id,
stage,
error: error instanceof Error ? error.message : 'Unknown error',
},
});
}
},
getContext() {
return immutableContext;
},
};
},
};
}

View File

@@ -0,0 +1,406 @@
import type { FeaturePolicyDefinition, PolicyErrorCode } from './declarative';
import type { PolicyRegistry } from './registry';
import type { PolicyContext, PolicyResult, PolicyStage } from './types';
const OPERATORS = {
ALL: 'ALL' as const,
ANY: 'ANY' as const,
};
type Operator = (typeof OPERATORS)[keyof typeof OPERATORS];
/**
* Simple policy function type
*/
export type PolicyFunction<TContext extends PolicyContext = PolicyContext> = (
context: Readonly<TContext>,
stage?: PolicyStage,
) => Promise<PolicyResult>;
/**
* Policy group - just an array of policies with an operator
*/
export interface PolicyGroup<TContext extends PolicyContext = PolicyContext> {
operator: Operator;
policies: PolicyFunction<TContext>[];
}
/**
* Evaluation result
*/
export interface EvaluationResult {
allowed: boolean;
reasons: string[];
results: PolicyResult[];
}
/**
* LRU Cache for policy definitions with size limit
*/
class LRUCache<K, V> {
private cache = new Map<K, V>();
private maxSize: number;
constructor(maxSize: number = 100) {
this.maxSize = maxSize;
}
get(key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// Remove least recently used (first entry)
const firstKey = this.cache.keys().next().value;
if (firstKey) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, value);
}
clear(): void {
this.cache.clear();
}
size(): number {
return this.cache.size;
}
}
export class PoliciesEvaluator<TContext extends PolicyContext = PolicyContext> {
// Use WeakMap for registry references to allow garbage collection
private registryPolicyCache = new WeakMap<
PolicyRegistry,
LRUCache<string, FeaturePolicyDefinition<TContext>>
>();
private readonly maxCacheSize: number;
constructor(options?: { maxCacheSize?: number }) {
this.maxCacheSize = options?.maxCacheSize ?? 100;
}
private async getCachedPolicy(
registry: PolicyRegistry,
policyId: string,
): Promise<FeaturePolicyDefinition<TContext> | undefined> {
if (!this.registryPolicyCache.has(registry)) {
this.registryPolicyCache.set(registry, new LRUCache(this.maxCacheSize));
}
const cache = this.registryPolicyCache.get(registry)!;
let definition = cache.get(policyId);
if (!definition) {
definition = await registry.getPolicy<TContext>(policyId);
if (definition) {
cache.set(policyId, definition);
}
}
return definition;
}
/**
* Clear all cached policies (useful for testing or memory management)
*/
clearCache(): void {
// Create new WeakMap to clear all references
this.registryPolicyCache = new WeakMap();
}
async hasPoliciesForStage(
registry: PolicyRegistry,
stage?: PolicyStage,
): Promise<boolean> {
const policyIds = registry.listPolicies();
for (const policyId of policyIds) {
const definition = await this.getCachedPolicy(registry, policyId);
if (!definition) {
continue;
}
if (!stage) {
return true;
}
if (!definition.stages) {
return true;
}
if (definition.stages.includes(stage)) {
return true;
}
}
return false;
}
/**
* Evaluate a registry with support for stages and AND/OR logic
*/
async evaluate(
registry: PolicyRegistry,
context: TContext,
operator: Operator = OPERATORS.ALL,
stage?: PolicyStage,
): Promise<EvaluationResult> {
const results: PolicyResult[] = [];
const reasons: string[] = [];
const policyIds = registry.listPolicies();
for (const policyId of policyIds) {
const definition = await this.getCachedPolicy(registry, policyId);
if (!definition) {
continue;
}
if (stage && definition.stages && !definition.stages.includes(stage)) {
continue;
}
const evaluator = definition.create(context);
const result = await evaluator.evaluate(stage);
results.push(result);
if (!result.allowed && result.reason) {
reasons.push(result.reason);
}
if (operator === OPERATORS.ALL && !result.allowed) {
return { allowed: false, reasons, results };
}
if (operator === OPERATORS.ANY && result.allowed) {
return { allowed: true, reasons: [], results };
}
}
// Handle edge case: empty policy list with ANY operator
if (results.length === 0 && operator === OPERATORS.ANY) {
return {
allowed: false,
reasons: ['No policies matched the criteria'],
results: [],
};
}
const allowed =
operator === OPERATORS.ALL
? results.every((r) => r.allowed)
: results.some((r) => r.allowed);
return { allowed, reasons: allowed ? [] : reasons, results };
}
/**
* Evaluate a single group of policies
*/
async evaluateGroup(
group: PolicyGroup<TContext>,
context: TContext,
stage?: PolicyStage,
): Promise<EvaluationResult> {
const results: PolicyResult[] = [];
const reasons: string[] = [];
for (const policy of group.policies) {
const result = await policy(Object.freeze({ ...context }), stage);
results.push(result);
if (!result.allowed && result.reason) {
reasons.push(result.reason);
}
// Short-circuit logic
if (group.operator === OPERATORS.ALL && !result.allowed) {
return {
allowed: false,
reasons,
results,
};
}
if (group.operator === OPERATORS.ANY && result.allowed) {
return {
allowed: true,
reasons: [],
results,
};
}
}
// Final evaluation
const allowed =
group.operator === OPERATORS.ALL
? results.every((r) => r.allowed)
: results.some((r) => r.allowed);
return {
allowed,
reasons: allowed ? [] : reasons,
results,
};
}
/**
* Evaluate multiple groups in sequence
*/
async evaluateGroups(
groups: PolicyGroup<TContext>[],
context: TContext,
stage?: PolicyStage,
): Promise<EvaluationResult> {
const allResults: PolicyResult[] = [];
const allReasons: string[] = [];
for (const group of groups) {
const groupResult = await this.evaluateGroup(group, context, stage);
allResults.push(...groupResult.results);
allReasons.push(...groupResult.reasons);
// Stop on first failure
if (!groupResult.allowed) {
return {
allowed: false,
reasons: allReasons,
results: allResults,
};
}
}
return {
allowed: true,
reasons: [],
results: allResults,
};
}
/**
* Evaluate a simple array of policies with ALL/ANY logic
*/
async evaluatePolicies(
policies: PolicyFunction<TContext>[],
context: TContext,
operator: Operator = OPERATORS.ALL,
stage?: PolicyStage,
) {
return this.evaluateGroup({ operator, policies }, context, stage);
}
}
/**
* Helper to create a policy function
*/
export function createPolicy<TContext extends PolicyContext = PolicyContext>(
evaluate: (
context: Readonly<TContext>,
stage?: PolicyStage,
) => Promise<PolicyResult>,
): PolicyFunction<TContext> {
return evaluate;
}
/**
* Helper policy results
*/
export const allow = (metadata?: Record<string, unknown>): PolicyResult => ({
allowed: true,
metadata,
});
// Function overloads for deny() to support both string and structured errors
export function deny(
reason: string,
metadata?: Record<string, unknown>,
): PolicyResult;
export function deny(error: PolicyErrorCode): PolicyResult;
export function deny(
reasonOrError: string | PolicyErrorCode,
metadata?: Record<string, unknown>,
): PolicyResult {
if (typeof reasonOrError === 'string') {
return {
allowed: false,
reason: reasonOrError,
metadata,
};
} else {
return {
allowed: false,
reason: reasonOrError.message,
metadata: {
code: reasonOrError.code,
remediation: reasonOrError.remediation,
...reasonOrError.metadata,
},
};
}
}
/**
* Create a policies evaluator with optional configuration
*/
export function createPoliciesEvaluator<
TContext extends PolicyContext = PolicyContext,
>(options?: { maxCacheSize?: number }) {
return new PoliciesEvaluator<TContext>(options);
}
/**
* Convert a registry-based policy to a simple policy function
*/
export async function createPolicyFromRegistry<
TContext extends PolicyContext = PolicyContext,
>(registry: PolicyRegistry, policyId: string, config?: unknown) {
const definition = await registry.getPolicy<TContext>(policyId);
return async (context: Readonly<TContext>, stage?: PolicyStage) => {
const evaluator = definition.create(context as TContext, config);
return evaluator.evaluate(stage);
};
}
/**
* Create multiple policy functions from registry policy IDs
*/
export async function createPoliciesFromRegistry<
TContext extends PolicyContext = PolicyContext,
>(registry: PolicyRegistry, policySpecs: Array<string | [string, unknown]>) {
const policies: PolicyFunction<TContext>[] = [];
for (const spec of policySpecs) {
if (typeof spec === 'string') {
// Simple policy ID
policies.push(await createPolicyFromRegistry(registry, spec));
} else {
// Policy ID with config
const [policyId, config] = spec;
policies.push(await createPolicyFromRegistry(registry, policyId, config));
}
}
return policies;
}

View File

@@ -0,0 +1,32 @@
// Export core types and interfaces
export type { PolicyContext, PolicyResult, PolicyStage } from './types';
// Export primary registry-based API
export { definePolicy } from './declarative';
export type {
FeaturePolicyDefinition,
PolicyEvaluator,
PolicyErrorCode,
PolicyReason,
} from './declarative';
// Export policy registry (primary API)
export { createPolicyRegistry } from './registry';
export type { PolicyRegistry } from './registry';
// Export evaluator and bridge functions
export {
createPolicy,
createPoliciesEvaluator,
createPolicyFromRegistry,
createPoliciesFromRegistry,
} from './evaluator';
export type {
PolicyFunction,
PolicyGroup,
EvaluationResult,
} from './evaluator';
// Export helper functions (for policy implementations)
export { allow, deny } from './evaluator';

View File

@@ -0,0 +1,81 @@
import { createRegistry } from '@kit/shared/registry';
import type { FeaturePolicyDefinition } from './declarative';
import type { PolicyContext } from './types';
/**
* Simple policy registry interface
*/
export interface PolicyRegistry {
/** Register a single policy definition */
registerPolicy<
TContext extends PolicyContext = PolicyContext,
TConfig = unknown,
>(
definition: FeaturePolicyDefinition<TContext, TConfig>,
): PolicyRegistry;
/** Get a policy definition by ID */
getPolicy<TContext extends PolicyContext = PolicyContext, TConfig = unknown>(
id: string,
): Promise<FeaturePolicyDefinition<TContext, TConfig>>;
/** Check if a policy exists */
hasPolicy(id: string): boolean;
/** List all registered policy IDs */
listPolicies(): string[];
}
/**
* Creates a new policy registry instance
*/
export function createPolicyRegistry(): PolicyRegistry {
const baseRegistry = createRegistry<
FeaturePolicyDefinition<PolicyContext, unknown>,
string
>();
const policyIds = new Set<string>();
return {
registerPolicy<
TContext extends PolicyContext = PolicyContext,
TConfig = unknown,
>(definition: FeaturePolicyDefinition<TContext, TConfig>) {
// Check for duplicates
if (policyIds.has(definition.id)) {
throw new Error(
`Policy with ID "${definition.id}" is already registered`,
);
}
// Register the policy definition
baseRegistry.register(definition.id, () => definition);
policyIds.add(definition.id);
return this;
},
async getPolicy<
TContext extends PolicyContext = PolicyContext,
TConfig = unknown,
>(id: string) {
if (!policyIds.has(id)) {
throw new Error(`Policy with ID "${id}" is not registered`);
}
return baseRegistry.get(id) as Promise<
FeaturePolicyDefinition<TContext, TConfig>
>;
},
hasPolicy(id: string) {
return policyIds.has(id);
},
listPolicies() {
return Array.from(policyIds);
},
};
}

View File

@@ -0,0 +1,42 @@
/**
* Base context interface that all policy contexts must extend.
* Provides common metadata and identifiers used across all policy types.
*/
export interface PolicyContext {
/** Timestamp when the policy evaluation was initiated */
timestamp: string;
/** Additional metadata for debugging and logging */
metadata?: Record<string, unknown>;
}
/**
* Standard result interface returned by all policy evaluations.
* Provides consistent structure for policy decisions across all features.
*/
export interface PolicyResult {
/** Whether the action is allowed by this policy */
allowed: boolean;
/** Human-readable reason when action is not allowed */
reason?: string;
/** Whether this policy failure requires manual review */
requiresManualReview?: boolean;
/** Additional metadata for debugging, logging, and UI customization */
metadata?: Record<string, unknown>;
}
/**
* Policy evaluation stages are user-defined strings for multi-phase validation.
* Allows policies to run at different points in the user workflow.
*
* Common examples:
* - 'preliminary' - runs before user input/form submission
* - 'submission' - runs during form submission with actual user data
* - 'post_action' - runs after the action has been completed
*
* You can define your own stages like 'validation', 'authorization', 'audit', etc.
*/
export type PolicyStage = string;

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}