diff --git a/src/capabilities.ts b/src/capabilities.ts new file mode 100644 index 0000000..39c4ca4 --- /dev/null +++ b/src/capabilities.ts @@ -0,0 +1,142 @@ +import type { AnyCtx } from './types'; + +/** + * A capability definition describes a single feature-level permission + * with a label, category, and the roles that have it by default. + */ +export interface CapabilityDefinition { + label: string; + category: string; + defaultRoles: readonly string[]; +} + +/** + * A capability override stored in the database. + * Replaces the default roles for a specific capability key. + */ +export interface CapabilityOverride { + key: string; + roles: readonly string[]; +} + +/** + * Registry of all capability definitions, keyed by capability key. + */ +export type CapabilityRegistry = Record; + +/** + * Configuration for creating a capability checker. + */ +export interface CapabilityCheckerConfig< + TRegistry extends CapabilityRegistry = CapabilityRegistry, +> { + /** The full registry of capability definitions */ + registry: TRegistry; + /** How to look up a DB override for a capability key */ + getOverride: (ctx: AnyCtx, key: string) => Promise; + /** Roles that always have all capabilities (default: ['admin']) */ + adminRoles?: string[]; +} + +/** + * Creates a capability checker from a registry and a DB lookup function. + * + * Capabilities are feature-level permissions (e.g. 'invoice.manage', + * 'documentation.lock') that are more granular than CRUD permissions. + * + * Each capability has default roles defined in the registry. + * These can be overridden per-deployment via DB entries. + * + * @example + * ```ts + * const capabilities = createCapabilityChecker({ + * registry: { + * 'invoice.manage': { + * label: 'Manage invoices', + * category: 'invoices', + * defaultRoles: ['accountant'], + * }, + * }, + * getOverride: async (ctx, key) => { + * return await ctx.db.query('capabilityOverrides') + * .withIndex('by_key', q => q.eq('key', key)) + * .first(); + * }, + * }); + * + * // In a mutation handler: + * if (await capabilities.has(ctx, user.role, 'invoice.manage')) { ... } + * ``` + */ +export const createCapabilityChecker = < + TRegistry extends CapabilityRegistry = CapabilityRegistry, +>( + config: CapabilityCheckerConfig, +) => { + const adminRoles = config.adminRoles ?? ['admin']; + + type Key = string & keyof TRegistry; + + /** + * Check if a role has a specific capability. + * Admin roles always return true. + * DB overrides take precedence over registry defaults. + */ + const has = async ( + ctx: AnyCtx, + role: string | undefined, + key: Key, + ): Promise => { + if (role !== undefined && adminRoles.includes(role)) { + return true; + } + + const effectiveRole = role ?? 'default'; + + // Check for DB override first + const override = await config.getOverride(ctx, key); + if (override) { + return (override.roles as readonly string[]).includes(effectiveRole); + } + + // Fall back to registry defaults + const definition = config.registry[key]; + if (!definition) { + return false; + } + + return (definition.defaultRoles as readonly string[]).includes( + effectiveRole, + ); + }; + + /** + * Check all capabilities for a given role. + * Returns a record of capability key → boolean. + */ + const checkAll = async ( + ctx: AnyCtx, + role: string | undefined, + ): Promise> => { + const keys = Object.keys(config.registry) as Key[]; + const results = {} as Record; + + for (const key of keys) { + results[key] = await has(ctx, role, key); + } + + return results; + }; + + /** + * Get all capability keys from the registry. + */ + const keys = Object.keys(config.registry) as readonly Key[]; + + /** + * Get the registry (for UI rendering, admin panels, etc.) + */ + const registry = config.registry; + + return { has, checkAll, keys, registry }; +}; diff --git a/src/index.test.ts b/src/index.test.ts index 1ca6526..d7dfb96 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -5,9 +5,13 @@ import { ErrorCode, addSystemFields, createAuthorized, + createCapabilityChecker, createError, createPermissionChecker, createPrimitives, + publicQuery, + publicMutation, + publicAction, zid, } from './index'; @@ -83,6 +87,14 @@ describe('createPrimitives', () => { }); }); +describe('public builders', () => { + it('exports publicQuery, publicMutation, publicAction as functions', () => { + expect(typeof publicQuery).toBe('function'); + expect(typeof publicMutation).toBe('function'); + expect(typeof publicAction).toBe('function'); + }); +}); + describe('createPermissionChecker', () => { it('bypasses permission checks for admin roles', async () => { const checker = createPermissionChecker({ @@ -198,3 +210,105 @@ describe('createAuthorized', () => { }); }); }); + +describe('createCapabilityChecker', () => { + const registry = { + 'invoice.manage': { + label: 'Manage invoices', + category: 'invoices', + defaultRoles: ['accountant'] as const, + }, + 'client.create': { + label: 'Create client', + category: 'clients', + defaultRoles: ['cob'] as const, + }, + 'system.config': { + label: 'System config', + category: 'system', + defaultRoles: [] as const, + }, + }; + + it('grants all capabilities to admin roles', async () => { + const checker = createCapabilityChecker({ + registry, + getOverride: async () => null, + }); + + await expect(checker.has({}, 'admin', 'invoice.manage')).resolves.toBe( + true, + ); + await expect(checker.has({}, 'admin', 'system.config')).resolves.toBe(true); + }); + + it('checks default roles from registry', async () => { + const checker = createCapabilityChecker({ + registry, + getOverride: async () => null, + }); + + await expect(checker.has({}, 'accountant', 'invoice.manage')).resolves.toBe( + true, + ); + await expect(checker.has({}, 'cob', 'invoice.manage')).resolves.toBe(false); + await expect(checker.has({}, 'cob', 'client.create')).resolves.toBe(true); + await expect(checker.has({}, undefined, 'client.create')).resolves.toBe( + false, + ); + }); + + it('uses DB overrides over registry defaults', async () => { + const checker = createCapabilityChecker({ + registry, + getOverride: async (_ctx, key) => { + if (key === 'invoice.manage') { + return { key, roles: ['cob', 'accountant'] }; + } + return null; + }, + }); + + // cob now has invoice.manage via override + await expect(checker.has({}, 'cob', 'invoice.manage')).resolves.toBe(true); + }); + + it('supports custom admin roles', async () => { + const checker = createCapabilityChecker({ + registry, + getOverride: async () => null, + adminRoles: ['admin', 'superadmin'], + }); + + await expect(checker.has({}, 'superadmin', 'system.config')).resolves.toBe( + true, + ); + }); + + it('checkAll returns all capabilities for a role', async () => { + const checker = createCapabilityChecker({ + registry, + getOverride: async () => null, + }); + + const result = await checker.checkAll({}, 'accountant'); + expect(result).toEqual({ + 'invoice.manage': true, + 'client.create': false, + 'system.config': false, + }); + }); + + it('exposes registry keys', () => { + const checker = createCapabilityChecker({ + registry, + getOverride: async () => null, + }); + + expect(checker.keys).toEqual([ + 'invoice.manage', + 'client.create', + 'system.config', + ]); + }); +}); diff --git a/src/index.ts b/src/index.ts index f0341c9..ad29b71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './authorized'; +export * from './capabilities'; export * from './errors'; export * from './permissions'; export * from './primitives'; diff --git a/src/primitives.ts b/src/primitives.ts index b3672c9..c645a32 100644 --- a/src/primitives.ts +++ b/src/primitives.ts @@ -58,3 +58,31 @@ export const createPrimitives = ( adminAction: customAction(actionGeneric, adminCtx), }; }; + +/** + * Public (unauthenticated) query/mutation/action builders. + * + * Use these for intentionally public endpoints (e.g. tenant config, health checks). + * They are thin wrappers around the generic Convex builders — their purpose is to + * make the intent explicit and detectable by CI auth-enforcement checks. + * + * Any file importing from `_generated/server` directly should be flagged by CI. + * Using `publicQuery` instead signals "this is intentionally unauthenticated". + * + * @example + * ```ts + * import { publicQuery } from '@amadeni/convex-lib'; + * + * export const getTenantConfig = publicQuery({ + * args: { subdomain: v.string() }, + * handler: async (ctx, args) => { + * return await ctx.db.query('tenantConfig') + * .filter(q => q.eq(q.field('subdomain'), args.subdomain)) + * .first(); + * }, + * }); + * ``` + */ +export const publicQuery = queryGeneric; +export const publicMutation = mutationGeneric; +export const publicAction = actionGeneric;