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
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;
|
||||
Reference in New Issue
Block a user