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:
committed by
GitHub
parent
3c13b5ec1e
commit
1dd6fdad22
@@ -8,11 +8,11 @@
|
|||||||
"format": "prettier --check --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\""
|
"format": "prettier --check --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^2.0.34",
|
"@ai-sdk/openai": "^2.0.38",
|
||||||
"@faker-js/faker": "^10.0.0",
|
"@faker-js/faker": "^10.0.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "5.90.2",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"ai": "5.0.51",
|
"ai": "5.0.56",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"@types/nodemailer": "7.0.1",
|
"@types/nodemailer": "7.0.1",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||||
"pino-pretty": "13.0.0",
|
"pino-pretty": "13.0.0",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"author": "Makerkit",
|
"author": "Makerkit",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.55.1",
|
"@playwright/test": "^1.55.1",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"dotenv": "17.2.2",
|
"dotenv": "17.2.2",
|
||||||
"node-html-parser": "^7.0.1",
|
"node-html-parser": "^7.0.1",
|
||||||
|
|||||||
@@ -115,18 +115,17 @@ export class InvitationsPageObject {
|
|||||||
async acceptInvitation() {
|
async acceptInvitation() {
|
||||||
console.log('Accepting invitation...');
|
console.log('Accepting invitation...');
|
||||||
|
|
||||||
const click = this.page
|
await Promise.all([
|
||||||
.locator('[data-test="join-team-form"] button[type="submit"]')
|
this.page
|
||||||
.click();
|
.locator('[data-test="join-team-form"] button[type="submit"]')
|
||||||
|
.click(),
|
||||||
const response = this.page.waitForResponse((response) => {
|
this.page.waitForResponse((response) => {
|
||||||
return (
|
return (
|
||||||
response.url().includes('/join') &&
|
response.url().includes('/join') &&
|
||||||
response.request().method() === 'POST'
|
response.request().method() === 'POST'
|
||||||
);
|
);
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
await Promise.all([click, response]);
|
|
||||||
|
|
||||||
console.log('Invitation accepted');
|
console.log('Invitation accepted');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ test.describe('Team Invitation with MFA Flow', () => {
|
|||||||
const invitations = new InvitationsPageObject(page);
|
const invitations = new InvitationsPageObject(page);
|
||||||
|
|
||||||
await auth.loginAsUser({
|
await auth.loginAsUser({
|
||||||
email: 'test@makerkit.dev',
|
email: 'owner@makerkit.dev',
|
||||||
});
|
});
|
||||||
|
|
||||||
const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`;
|
const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`;
|
||||||
|
|||||||
69
apps/web/app/home/[account]/members/policies/route.ts
Normal file
69
apps/web/app/home/[account]/members/policies/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import {
|
||||||
|
createInvitationContextBuilder,
|
||||||
|
createInvitationsPolicyEvaluator,
|
||||||
|
} from '@kit/team-accounts/policies';
|
||||||
|
|
||||||
|
export const GET = enhanceRouteHandler(
|
||||||
|
async function ({ params, user }) {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { account } = z.object({ account: z.string() }).parse(params);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Evaluate with standard evaluator
|
||||||
|
const evaluator = createInvitationsPolicyEvaluator();
|
||||||
|
const hasPolicies = await evaluator.hasPoliciesForStage('preliminary');
|
||||||
|
|
||||||
|
if (!hasPolicies) {
|
||||||
|
return NextResponse.json({
|
||||||
|
allowed: true,
|
||||||
|
reasons: [],
|
||||||
|
metadata: {
|
||||||
|
policiesEvaluated: 0,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
noPoliciesConfigured: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build context for policy evaluation (empty invitations for testing)
|
||||||
|
const contextBuilder = createInvitationContextBuilder(client);
|
||||||
|
|
||||||
|
const context = await contextBuilder.buildContext(
|
||||||
|
{
|
||||||
|
invitations: [],
|
||||||
|
accountSlug: account,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
|
||||||
|
// validate against policies
|
||||||
|
const result = await evaluator.canInvite(context, 'preliminary');
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
allowed: false,
|
||||||
|
reasons: [
|
||||||
|
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
error: true,
|
||||||
|
originalError:
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"@marsidev/react-turnstile": "^1.3.1",
|
"@marsidev/react-turnstile": "^1.3.1",
|
||||||
"@nosecone/next": "1.0.0-beta.12",
|
"@nosecone/next": "1.0.0-beta.12",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@tanstack/react-query": "5.90.2",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^16.0.0",
|
||||||
"recharts": "2.15.3",
|
"recharts": "2.15.3",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
@@ -79,13 +79,13 @@
|
|||||||
"@next/bundle-analyzer": "15.5.4",
|
"@next/bundle-analyzer": "15.5.4",
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||||
"cssnano": "^7.1.1",
|
"cssnano": "^7.1.1",
|
||||||
"pino-pretty": "13.0.0",
|
"pino-pretty": "13.0.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"supabase": "2.45.5",
|
"supabase": "2.47.2",
|
||||||
"tailwindcss": "4.1.13",
|
"tailwindcss": "4.1.13",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
|
|||||||
@@ -159,5 +159,17 @@
|
|||||||
"leaveTeamInputLabel": "Please type LEAVE to confirm leaving the team.",
|
"leaveTeamInputLabel": "Please type LEAVE to confirm leaving the team.",
|
||||||
"leaveTeamInputDescription": "By leaving the team, you will no longer have access to it.",
|
"leaveTeamInputDescription": "By leaving the team, you will no longer have access to it.",
|
||||||
"reservedNameError": "This name is reserved. Please choose a different one.",
|
"reservedNameError": "This name is reserved. Please choose a different one.",
|
||||||
"specialCharactersError": "This name cannot contain special characters. Please choose a different one."
|
"specialCharactersError": "This name cannot contain special characters. Please choose a different one.",
|
||||||
|
"checkingPolicies": "Loading. Please wait...",
|
||||||
|
"policyCheckError": "We are unable to verify invitations restrictions. Please try again.",
|
||||||
|
"invitationsBlockedMultiple": "Invitations are currently not allowed for the following reasons:",
|
||||||
|
"invitationsBlockedDefault": "Invitations are currently not allowed due to policy restrictions.",
|
||||||
|
"policyErrors": {
|
||||||
|
"subscriptionRequired": "An active subscription is required to invite team members.",
|
||||||
|
"paddleTrialRestriction": "Cannot invite members during trial period with per-seat billing on Paddle."
|
||||||
|
},
|
||||||
|
"policyRemediation": {
|
||||||
|
"subscriptionRequired": "Please upgrade your plan or activate your subscription",
|
||||||
|
"paddleTrialRestriction": "Wait until trial period ends or upgrade to full plan"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-supabase-saas-kit-turbo",
|
"name": "next-supabase-saas-kit-turbo",
|
||||||
"version": "2.15.1",
|
"version": "2.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -26,14 +26,14 @@
|
|||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^16.0.0",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class BillingWebhooksService {
|
|||||||
|
|
||||||
return gateway.cancelSubscription({
|
return gateway.cancelSubscription({
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
|
invoiceNow: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"wp-types": "^4.68.1"
|
"wp-types": "^4.68.1"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/team-accounts": "workspace:*",
|
"@kit/team-accounts": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "0.5.4"
|
"@react-email/components": "0.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kit/eslint-config": "workspace:*",
|
"@kit/eslint-config": "workspace:*",
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@tanstack/react-query": "5.90.2",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^16.0.0",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
"prettier": "@kit/prettier-config",
|
"prettier": "@kit/prettier-config",
|
||||||
|
|||||||
@@ -20,10 +20,10 @@
|
|||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
||||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@tanstack/react-query": "5.90.2",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
|
|||||||
@@ -29,13 +29,13 @@
|
|||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@marsidev/react-turnstile": "^1.3.1",
|
"@marsidev/react-turnstile": "^1.3.1",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@tanstack/react-query": "5.90.2",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^16.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,13 +19,13 @@
|
|||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@tanstack/react-query": "5.90.2",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
"react-i18next": "^15.7.3"
|
"react-i18next": "^16.0.0"
|
||||||
},
|
},
|
||||||
"prettier": "@kit/prettier-config",
|
"prettier": "@kit/prettier-config",
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
"./api": "./src/server/api.ts",
|
"./api": "./src/server/api.ts",
|
||||||
"./components": "./src/components/index.ts",
|
"./components": "./src/components/index.ts",
|
||||||
"./hooks/*": "./src/hooks/*.ts",
|
"./hooks/*": "./src/hooks/*.ts",
|
||||||
"./webhooks": "./src/server/services/webhooks/index.ts"
|
"./webhooks": "./src/server/services/webhooks/index.ts",
|
||||||
|
"./policies": "./src/server/policies/index.ts",
|
||||||
|
"./policies/orchestrator": "./src/server/policies/orchestrator.ts",
|
||||||
|
"./services/account-invitations.service": "./src/server/services/account-invitations.service.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^5.1.6"
|
"nanoid": "^5.1.6"
|
||||||
@@ -27,15 +30,16 @@
|
|||||||
"@kit/monitoring": "workspace:*",
|
"@kit/monitoring": "workspace:*",
|
||||||
"@kit/next": "workspace:*",
|
"@kit/next": "workspace:*",
|
||||||
"@kit/otp": "workspace:*",
|
"@kit/otp": "workspace:*",
|
||||||
|
"@kit/policies": "workspace:*",
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@tanstack/react-query": "5.90.2",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -44,7 +48,7 @@
|
|||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^16.0.0",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
"prettier": "@kit/prettier-config",
|
"prettier": "@kit/prettier-config",
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Plus, X } from 'lucide-react';
|
import { Plus, X } from 'lucide-react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
import { Input } from '@kit/ui/input';
|
import { Input } from '@kit/ui/input';
|
||||||
import { toast } from '@kit/ui/sonner';
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { Spinner } from '@kit/ui/spinner';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -62,6 +65,13 @@ export function InviteMembersDialogContainer({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { t } = useTranslation('teams');
|
const { t } = useTranslation('teams');
|
||||||
|
|
||||||
|
// Evaluate policies when dialog is open
|
||||||
|
const {
|
||||||
|
data: policiesResult,
|
||||||
|
isLoading: isLoadingPolicies,
|
||||||
|
error: policiesError,
|
||||||
|
} = useFetchInvitationsPolicies({ accountSlug, isOpen });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
|
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
@@ -77,30 +87,70 @@ export function InviteMembersDialogContainer({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
<If condition={isLoadingPolicies}>
|
||||||
{(roles) => (
|
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||||
<InviteMembersForm
|
<Spinner className="h-6 w-6" />
|
||||||
pending={pending}
|
|
||||||
roles={roles}
|
|
||||||
onSubmit={(data) => {
|
|
||||||
startTransition(() => {
|
|
||||||
const promise = createInvitationsAction({
|
|
||||||
accountSlug,
|
|
||||||
invitations: data.invitations,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.promise(() => promise, {
|
<span className="text-muted-foreground text-sm">
|
||||||
loading: t('invitingMembers'),
|
<Trans i18nKey="teams:checkingPolicies" />
|
||||||
success: t('inviteMembersSuccessMessage'),
|
</span>
|
||||||
error: t('inviteMembersErrorMessage'),
|
</div>
|
||||||
});
|
</If>
|
||||||
|
|
||||||
setIsOpen(false);
|
<If condition={policiesError}>
|
||||||
});
|
<Alert variant="destructive">
|
||||||
}}
|
<AlertDescription>
|
||||||
/>
|
<Trans
|
||||||
)}
|
i18nKey="teams:policyCheckError"
|
||||||
</RolesDataProvider>
|
values={{ error: policiesError?.message }}
|
||||||
|
/>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<If condition={policiesResult && !policiesResult.allowed}>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey={policiesResult?.reasons[0]}
|
||||||
|
defaults={policiesResult?.reasons[0]}
|
||||||
|
/>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<If condition={policiesResult?.allowed}>
|
||||||
|
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
||||||
|
{(roles) => (
|
||||||
|
<InviteMembersForm
|
||||||
|
pending={pending}
|
||||||
|
roles={roles}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const toastId = toast.loading(t('invitingMembers'));
|
||||||
|
|
||||||
|
const result = await createInvitationsAction({
|
||||||
|
accountSlug,
|
||||||
|
invitations: data.invitations,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(t('inviteMembersSuccessMessage'), {
|
||||||
|
id: toastId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(t('inviteMembersErrorMessage'), {
|
||||||
|
id: toastId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RolesDataProvider>
|
||||||
|
</If>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -275,3 +325,27 @@ function InviteMembersForm({
|
|||||||
function createEmptyInviteModel() {
|
function createEmptyInviteModel() {
|
||||||
return { email: '', role: 'member' as Role };
|
return { email: '', role: 'member' as Role };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useFetchInvitationsPolicies({
|
||||||
|
accountSlug,
|
||||||
|
isOpen,
|
||||||
|
}: {
|
||||||
|
accountSlug: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['invitation-policies', accountSlug],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`./members/policies`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: isOpen,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,5 +38,6 @@ function useFetchRoles(props: { maxRoleHierarchy: number }) {
|
|||||||
|
|
||||||
return data.map((item) => item.name);
|
return data.map((item) => item.name);
|
||||||
},
|
},
|
||||||
|
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import { redirect } from 'next/navigation';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { enhanceAction } from '@kit/next/actions';
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { JWTUserData } from '@kit/supabase/types';
|
||||||
|
|
||||||
import { AcceptInvitationSchema } from '../../schema/accept-invitation.schema';
|
import { AcceptInvitationSchema } from '../../schema/accept-invitation.schema';
|
||||||
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
|
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
|
||||||
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||||
import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
|
import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
|
||||||
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||||
|
import { createInvitationContextBuilder } from '../policies/invitation-context-builder';
|
||||||
|
import { createInvitationsPolicyEvaluator } from '../policies/invitation-policies';
|
||||||
import { createAccountInvitationsService } from '../services/account-invitations.service';
|
import { createAccountInvitationsService } from '../services/account-invitations.service';
|
||||||
import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
|
import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
|
||||||
|
|
||||||
@@ -22,20 +26,47 @@ import { createAccountPerSeatBillingService } from '../services/account-per-seat
|
|||||||
* @description Creates invitations for inviting members.
|
* @description Creates invitations for inviting members.
|
||||||
*/
|
*/
|
||||||
export const createInvitationsAction = enhanceAction(
|
export const createInvitationsAction = enhanceAction(
|
||||||
async (params) => {
|
async (params, user) => {
|
||||||
const client = getSupabaseServerClient();
|
const logger = await getLogger();
|
||||||
|
|
||||||
// Create the service
|
logger.info(
|
||||||
|
{ params, userId: user.id },
|
||||||
|
'User requested to send invitations',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Evaluate invitation policies
|
||||||
|
const policiesResult = await evaluateInvitationsPolicies(params, user);
|
||||||
|
|
||||||
|
// If the invitations are not allowed, throw an error
|
||||||
|
if (!policiesResult.allowed) {
|
||||||
|
logger.info(
|
||||||
|
{ reasons: policiesResult?.reasons, userId: user.id },
|
||||||
|
'Invitations blocked by policies',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reasons: policiesResult?.reasons,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// invitations are allowed, so continue with the action
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
const service = createAccountInvitationsService(client);
|
const service = createAccountInvitationsService(client);
|
||||||
|
|
||||||
// send invitations
|
try {
|
||||||
await service.sendInvitations(params);
|
await service.sendInvitations(params);
|
||||||
|
|
||||||
revalidateMemberPage();
|
revalidateMemberPage();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
schema: InviteMembersSchema.and(
|
schema: InviteMembersSchema.and(
|
||||||
@@ -157,3 +188,30 @@ export const renewInvitationAction = enhanceAction(
|
|||||||
function revalidateMemberPage() {
|
function revalidateMemberPage() {
|
||||||
revalidatePath('/home/[account]/members', 'page');
|
revalidatePath('/home/[account]/members', 'page');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name evaluateInvitationsPolicies
|
||||||
|
* @description Evaluates invitation policies with performance optimization.
|
||||||
|
* @param params - The invitations to evaluate (emails and roles).
|
||||||
|
*/
|
||||||
|
async function evaluateInvitationsPolicies(
|
||||||
|
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
|
||||||
|
user: JWTUserData,
|
||||||
|
) {
|
||||||
|
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.buildContext(params, user);
|
||||||
|
|
||||||
|
return evaluator.canInvite(context, 'submission');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { PolicyContext, PolicyResult } from '@kit/policies';
|
||||||
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invitation policy context that extends the base PolicyContext
|
||||||
|
* from @kit/policies for invitation-specific data.
|
||||||
|
*/
|
||||||
|
export interface FeaturePolicyInvitationContext extends PolicyContext {
|
||||||
|
/** The account slug being invited to */
|
||||||
|
accountSlug: string;
|
||||||
|
|
||||||
|
/** The account ID being invited to (same as accountId from base) */
|
||||||
|
accountId: string;
|
||||||
|
|
||||||
|
/** Current subscription data for the account */
|
||||||
|
subscription?: {
|
||||||
|
id: string;
|
||||||
|
status: Database['public']['Enums']['subscription_status'];
|
||||||
|
provider: Database['public']['Enums']['billing_provider'];
|
||||||
|
active: boolean;
|
||||||
|
trial_starts_at?: string;
|
||||||
|
trial_ends_at?: string;
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
type: Database['public']['Enums']['subscription_item_type'];
|
||||||
|
quantity: number;
|
||||||
|
product_id: string;
|
||||||
|
variant_id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Current number of members in the account */
|
||||||
|
currentMemberCount: number;
|
||||||
|
|
||||||
|
/** The invitations being attempted */
|
||||||
|
invitations: Array<{
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** The user performing the invitation */
|
||||||
|
invitingUser: {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invitation policy result that extends the base PolicyResult
|
||||||
|
* from @kit/policies while maintaining backward compatibility.
|
||||||
|
*/
|
||||||
|
export interface FeaturePolicyInvitationResult extends PolicyResult {
|
||||||
|
/** Whether the invitations are allowed */
|
||||||
|
allowed: boolean;
|
||||||
|
|
||||||
|
/** Human-readable reason if not allowed */
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
/** Additional metadata for logging/debugging */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { createInvitationsPolicyEvaluator } from './invitation-policies';
|
||||||
|
|
||||||
|
// Context building
|
||||||
|
export { createInvitationContextBuilder } from './invitation-context-builder';
|
||||||
|
|
||||||
|
// Type exports
|
||||||
|
export type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
import { JWTUserData } from '@kit/supabase/types';
|
||||||
|
|
||||||
|
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||||
|
import type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an invitation context builder
|
||||||
|
* @param client - The Supabase client
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function createInvitationContextBuilder(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
) {
|
||||||
|
return new InvitationContextBuilder(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invitation context builder
|
||||||
|
*/
|
||||||
|
class InvitationContextBuilder {
|
||||||
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build policy context for invitation evaluation with optimized parallel loading
|
||||||
|
*/
|
||||||
|
async buildContext(
|
||||||
|
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
|
||||||
|
user: JWTUserData,
|
||||||
|
): Promise<FeaturePolicyInvitationContext> {
|
||||||
|
// Fetch all data in parallel for optimal performance
|
||||||
|
const account = await this.getAccount(params.accountSlug);
|
||||||
|
|
||||||
|
// Fetch subscription and member count in parallel using account ID
|
||||||
|
const [subscription, memberCount] = await Promise.all([
|
||||||
|
this.getSubscription(account.id),
|
||||||
|
this.getMemberCount(account.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Base PolicyContext fields
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
metadata: {
|
||||||
|
accountSlug: params.accountSlug,
|
||||||
|
invitationCount: params.invitations.length,
|
||||||
|
invitingUserEmail: user.email as string,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Invitation-specific fields
|
||||||
|
accountSlug: params.accountSlug,
|
||||||
|
accountId: account.id,
|
||||||
|
subscription,
|
||||||
|
currentMemberCount: memberCount,
|
||||||
|
invitations: params.invitations,
|
||||||
|
invitingUser: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the account from the database
|
||||||
|
* @param accountSlug - The slug of the account to get
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private async getAccount(accountSlug: string) {
|
||||||
|
const { data: account } = await this.client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', accountSlug)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the subscription from the database
|
||||||
|
* @param accountId - The ID of the account to get the subscription for
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private async getSubscription(accountId: string) {
|
||||||
|
const { data: subscription } = await this.client
|
||||||
|
.from('subscriptions')
|
||||||
|
.select(
|
||||||
|
`
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
active,
|
||||||
|
trial_starts_at,
|
||||||
|
trial_ends_at,
|
||||||
|
billing_provider,
|
||||||
|
subscription_items(
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
quantity,
|
||||||
|
product_id,
|
||||||
|
variant_id
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.eq('active', true)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
return subscription
|
||||||
|
? {
|
||||||
|
id: subscription.id,
|
||||||
|
status: subscription.status,
|
||||||
|
provider: subscription.billing_provider,
|
||||||
|
active: subscription.active,
|
||||||
|
trial_starts_at: subscription.trial_starts_at || undefined,
|
||||||
|
trial_ends_at: subscription.trial_ends_at || undefined,
|
||||||
|
items:
|
||||||
|
subscription.subscription_items?.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
quantity: item.quantity,
|
||||||
|
product_id: item.product_id,
|
||||||
|
variant_id: item.variant_id,
|
||||||
|
})) || [],
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the member count from the database
|
||||||
|
* @param accountId - The ID of the account to get the member count for
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private async getMemberCount(accountId: string) {
|
||||||
|
const { count } = await this.client
|
||||||
|
.from('accounts_memberships')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('account_id', accountId);
|
||||||
|
|
||||||
|
return count || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createPoliciesEvaluator } from '@kit/policies';
|
||||||
|
|
||||||
|
import type { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
|
||||||
|
import { invitationPolicyRegistry } from './policies';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an invitation evaluator
|
||||||
|
*/
|
||||||
|
export function createInvitationsPolicyEvaluator() {
|
||||||
|
const evaluator = createPoliciesEvaluator<FeaturePolicyInvitationContext>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Checks if there are any invitation policies for the given stage
|
||||||
|
* @param stage - The stage to check if there are any invitation policies for
|
||||||
|
*/
|
||||||
|
async hasPoliciesForStage(stage: 'preliminary' | 'submission') {
|
||||||
|
return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates the invitation policies for the given context and stage
|
||||||
|
* @param context - The context for the invitation policy
|
||||||
|
* @param stage - The stage to evaluate the invitation policies for
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async canInvite(
|
||||||
|
context: FeaturePolicyInvitationContext,
|
||||||
|
stage: 'preliminary' | 'submission',
|
||||||
|
) {
|
||||||
|
return evaluator.evaluate(
|
||||||
|
invitationPolicyRegistry,
|
||||||
|
context,
|
||||||
|
'ALL',
|
||||||
|
stage,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { allow, definePolicy, deny } from '@kit/policies';
|
||||||
|
import { createPolicyRegistry } from '@kit/policies';
|
||||||
|
|
||||||
|
import { 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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paddle billing policy
|
||||||
|
* Checks if the account has a paddle subscription and is in a trial period
|
||||||
|
*/
|
||||||
|
export const paddleBillingInvitationsPolicy =
|
||||||
|
definePolicy<FeaturePolicyInvitationContext>({
|
||||||
|
id: 'paddle-billing',
|
||||||
|
stages: ['preliminary', 'submission'],
|
||||||
|
evaluate: async ({ subscription }) => {
|
||||||
|
// combine with subscriptionRequiredPolicy if subscription must be required
|
||||||
|
if (!subscription) {
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paddle specific constraint: cannot update subscription items during trial
|
||||||
|
if (
|
||||||
|
subscription.provider === 'paddle' &&
|
||||||
|
subscription.status === 'trialing'
|
||||||
|
) {
|
||||||
|
const hasPerSeatItems = subscription.items.some(
|
||||||
|
(item) => item.type === 'per_seat',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasPerSeatItems) {
|
||||||
|
return deny({
|
||||||
|
code: 'PADDLE_TRIAL_RESTRICTION',
|
||||||
|
message: 'teams:policyErrors.paddleTrialRestriction',
|
||||||
|
remediation: 'teams:policyRemediation.paddleTrialRestriction',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allow();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// register policies below to apply them
|
||||||
|
//
|
||||||
|
//
|
||||||
@@ -12,6 +12,10 @@ import type { DeleteInvitationSchema } from '../../schema/delete-invitation.sche
|
|||||||
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
|
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||||
import type { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
import type { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Create an account invitations service.
|
||||||
|
*/
|
||||||
export function createAccountInvitationsService(
|
export function createAccountInvitationsService(
|
||||||
client: SupabaseClient<Database>,
|
client: SupabaseClient<Database>,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ class AccountPerSeatBillingService {
|
|||||||
subscription_items !inner (
|
subscription_items !inner (
|
||||||
quantity,
|
quantity,
|
||||||
id,
|
id,
|
||||||
type
|
type,
|
||||||
|
variant_id
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
"react-i18next": "^15.7.3"
|
"react-i18next": "^16.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "25.5.2",
|
"i18next": "25.5.2",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@modelcontextprotocol/sdk": "1.18.1",
|
"@modelcontextprotocol/sdk": "1.18.2",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"postgres": "3.4.7",
|
"postgres": "3.4.7",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"@kit/sentry": "workspace:*",
|
"@kit/sentry": "workspace:*",
|
||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"@kit/eslint-config": "workspace:*",
|
"@kit/eslint-config": "workspace:*",
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"react": "19.1.1"
|
"react": "19.1.1"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
|
|||||||
@@ -16,15 +16,15 @@
|
|||||||
"./config/server": "./src/sentry.client.server.ts"
|
"./config/server": "./src/sentry.client.server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/nextjs": "^10.14.0",
|
"@sentry/nextjs": "^10.15.0",
|
||||||
"import-in-the-middle": "1.14.2"
|
"import-in-the-middle": "1.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kit/eslint-config": "workspace:*",
|
"@kit/eslint-config": "workspace:*",
|
||||||
"@kit/monitoring-core": "workspace:*",
|
"@kit/monitoring-core": "workspace:*",
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"react": "19.1.1"
|
"react": "19.1.1"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
|
|||||||
684
packages/policies/AGENTS.md
Normal file
684
packages/policies/AGENTS.md
Normal 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
684
packages/policies/CLAUDE.md
Normal 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');
|
||||||
|
```
|
||||||
3
packages/policies/eslint.config.mjs
Normal file
3
packages/policies/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||||
|
|
||||||
|
export default eslintConfigBase;
|
||||||
1
packages/policies/index.ts
Normal file
1
packages/policies/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './src';
|
||||||
29
packages/policies/package.json
Normal file
29
packages/policies/package.json
Normal 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"
|
||||||
|
}
|
||||||
247
packages/policies/src/declarative.ts
Normal file
247
packages/policies/src/declarative.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
406
packages/policies/src/evaluator.ts
Normal file
406
packages/policies/src/evaluator.ts
Normal 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;
|
||||||
|
}
|
||||||
32
packages/policies/src/index.ts
Normal file
32
packages/policies/src/index.ts
Normal 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';
|
||||||
81
packages/policies/src/registry.ts
Normal file
81
packages/policies/src/registry.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
42
packages/policies/src/types.ts
Normal file
42
packages/policies/src/types.ts
Normal 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;
|
||||||
8
packages/policies/tsconfig.json
Normal file
8
packages/policies/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@kit/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||||
|
},
|
||||||
|
"include": ["*.ts", "src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -20,10 +20,10 @@
|
|||||||
"@kit/eslint-config": "workspace:*",
|
"@kit/eslint-config": "workspace:*",
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@types/react": "19.1.13"
|
"@types/react": "19.1.15"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pino": "^9.11.0"
|
"pino": "^9.12.0"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -26,12 +26,11 @@
|
|||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@tanstack/react-query": "5.90.2",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"server-only": "^0.0.1",
|
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
"@kit/eslint-config": "workspace:*",
|
"@kit/eslint-config": "workspace:*",
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@supabase/supabase-js": "2.57.4",
|
"@supabase/supabase-js": "2.58.0",
|
||||||
"@tanstack/react-query": "5.90.2",
|
"@tanstack/react-query": "5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/react": "19.1.13",
|
"@types/react": "19.1.15",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"react-day-picker": "^9.11.0",
|
"react-day-picker": "^9.11.0",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^16.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwindcss": "4.1.13",
|
"tailwindcss": "4.1.13",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
2079
pnpm-lock.yaml
generated
2079
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user