Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions src/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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<string, CapabilityDefinition>;

/**
* 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<CapabilityOverride | null>;
/** 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<TRegistry>,
) => {
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<boolean> => {
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<Record<Key, boolean>> => {
const keys = Object.keys(config.registry) as Key[];
const results = {} as Record<Key, boolean>;

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 };
};
114 changes: 114 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import {
ErrorCode,
addSystemFields,
createAuthorized,
createCapabilityChecker,
createError,
createPermissionChecker,
createPrimitives,
publicQuery,
publicMutation,
publicAction,
zid,
} from './index';

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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',
]);
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './authorized';
export * from './capabilities';
export * from './errors';
export * from './permissions';
export * from './primitives';
Expand Down
28 changes: 28 additions & 0 deletions src/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,31 @@ export const createPrimitives = <User extends ConvexLibUser>(
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;
Loading