feat: enforce CLI and MCP token permissions

## Summary

- adds a shared client-side API permission mapper and guarded API client for CLI and MCP calls
- exposes a non-secret /api/auth/context endpoint for scoped token preflight
- routes CLI and MCP task lookup helpers through the guarded client
- preflights direct summary text fetches that bypass the JSON API helper
- adds focused CLI and MCP token authorization coverage and documents the behavior

Refs #336.

## Verification

- pnpm --filter @veritas-kanban/shared build
- pnpm --filter @veritas-kanban/cli typecheck
- pnpm --filter @veritas-kanban/mcp build
- pnpm --filter @veritas-kanban/server typecheck
- focused CLI and MCP api-permissions tests
- pnpm lint:budget
- pnpm audit --prod --audit-level=high
- pnpm build
- GitHub Actions: Build, Lint & Type Check, Security Audit, Workspace Unit Tests
This commit is contained in:
Brad Groux 2026-05-31 04:35:10 -05:00 committed by GitHub
parent b615052d4a
commit 3f5c9a03af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 635 additions and 12 deletions

View file

@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ClientPermissionError, createGuardedApiClient } from '../utils/api.js';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
describe('CLI API permission preflight', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('blocks mutating commands before calling the target endpoint when the token is read-only', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
role: 'read-only',
isLocalhost: false,
permissions: ['task:read'],
})
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const api = createGuardedApiClient('http://vk.test', 'reader-key');
await expect(
api('/api/tasks', {
method: 'POST',
body: JSON.stringify({ title: 'blocked' }),
})
).rejects.toBeInstanceOf(ClientPermissionError);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
});
it('allows read commands when the token has the mapped read permission', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
role: 'read-only',
isLocalhost: false,
permissions: ['task:read'],
})
)
.mockResolvedValueOnce(jsonResponse([{ id: 'task_1', title: 'allowed' }]));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const api = createGuardedApiClient('http://vk.test', 'reader-key');
const tasks = await api<{ id: string; title: string }[]>('/api/tasks');
expect(tasks).toEqual([{ id: 'task_1', title: 'allowed' }]);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toBe('http://vk.test/api/tasks');
});
});

View file

@ -1,6 +1,6 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { api, API_BASE, buildApiHeaders } from '../utils/api.js';
import { api, API_BASE, assertApiPermissionForRequest, buildApiHeaders } from '../utils/api.js';
export function registerSummaryCommands(program: Command): void {
// Create summary parent command with subcommands
@ -88,6 +88,9 @@ export function registerSummaryCommands(program: Command): void {
const standup = await api<unknown>(`/api/summary/standup?date=${dateParam}&format=json`);
console.log(JSON.stringify(standup, null, 2));
} else {
await assertApiPermissionForRequest(
`/api/summary/standup?date=${dateParam}&format=${format}`
);
// Fetch markdown or text directly
const res = await fetch(
`${API_BASE}/api/summary/standup?date=${dateParam}&format=${format}`,
@ -121,6 +124,7 @@ export function registerSummaryCommands(program: Command): void {
const recent = await api<unknown>(`/api/summary/recent?hours=${options.hours}`);
console.log(JSON.stringify(recent, null, 2));
} else {
await assertApiPermissionForRequest(`/api/summary/memory?hours=${options.hours}`);
const res = await fetch(`${API_BASE}/api/summary/memory?hours=${options.hours}`, {
headers: buildApiHeaders({ accept: 'text/markdown, text/plain, application/json' }),
});

View file

@ -1,2 +1,26 @@
// Re-export shared API client
export { api, createApiClient, API_BASE, buildApiHeaders } from '@veritas-kanban/shared';
// Re-export shared API helpers with CLI permission preflight enabled.
import {
API_BASE,
createApiClient,
createApiPermissionGuard,
createGuardedApiClient,
type ClientAuthContext,
} from '@veritas-kanban/shared';
export {
API_BASE,
ClientPermissionError,
buildApiHeaders,
createApiClient,
createGuardedApiClient,
getApiPermissionRequirement,
type ClientAuthContext,
type ClientAuthPermission,
} from '@veritas-kanban/shared';
export const api = createGuardedApiClient(API_BASE);
const contextApi = createApiClient(API_BASE);
export const assertApiPermissionForRequest = createApiPermissionGuard(() =>
contextApi<ClientAuthContext>('/api/auth/context')
);

View file

@ -1,2 +1,7 @@
// Re-export shared findTask
export { findTask } from '@veritas-kanban/shared';
import { findTask as findTaskWithClient } from '@veritas-kanban/shared';
import type { Task } from './types.js';
import { api } from './api.js';
export function findTask(id: string): Promise<Task | null> {
return findTaskWithClient(id, api);
}

9
cli/vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
exclude: ['**/node_modules/**', '**/dist/**'],
globals: true,
},
});

View file

@ -645,6 +645,10 @@ automation should use an `agent` role key; read-only dashboards and reporting
scripts should use `read-only`. Reserve the admin key for setup, migration,
backup/import, and policy operations.
The CLI preflights protected commands against `/api/auth/context` before it
calls the target endpoint. If `VK_API_KEY` lacks the mapped permission, the
command fails locally without sending the mutating request.
### Read/Write Smoke Check
Use this check after linking `vk` and exporting `VK_API_URL`/`VK_API_KEY`. It proves the CLI can both read from and write to the configured VK server.

View file

@ -711,7 +711,9 @@ Resources are useful for MCP clients that support resource browsing (e.g., Claud
- API keys are passed via the `X-API-Key` header by the shared VK API client. The server also accepts `Authorization: Bearer <key>` for direct HTTP callers.
- The MCP server reads `VK_API_KEY` from its environment and includes it in every HTTP request to VK.
- Keys never appear in MCP tool inputs/outputs — they stay in the transport layer.
- v5 auth context classifies MCP calls with `actorType=agent`, `authMethod=api-key`, the configured token name, and role-derived permissions. Future MCP tools should request the narrowest server permission that matches the operation.
- v5 auth context classifies MCP calls with `actorType=agent`, `authMethod=api-key`, the configured token name, and role-derived permissions.
- MCP tool calls preflight protected HTTP requests against `/api/auth/context`. If the configured key lacks the mapped permission, the tool returns a permission error before sending the target write request.
- Future MCP tools should request the narrowest server permission that matches the operation.
### What the MCP Server Cannot Do

View file

@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ClientPermissionError, createGuardedApiClient } from '../utils/api.js';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
describe('MCP API permission preflight', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('blocks write tools before calling the target endpoint when the token lacks write scope', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
role: 'agent',
isLocalhost: false,
permissions: ['task:read', 'agent:read'],
})
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const api = createGuardedApiClient('http://vk.test', 'agent-key');
await expect(
api('/api/projects', {
method: 'POST',
body: JSON.stringify({ label: 'blocked' }),
})
).rejects.toBeInstanceOf(ClientPermissionError);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
});
it('allows read tools when the token has the mapped read permission', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
role: 'agent',
isLocalhost: false,
permissions: ['report:read'],
})
)
.mockResolvedValueOnce(jsonResponse({ total: 0 }));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const api = createGuardedApiClient('http://vk.test', 'agent-key');
const summary = await api<{ total: number }>('/api/summary');
expect(summary).toEqual({ total: 0 });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toBe('http://vk.test/api/summary');
});
});

View file

@ -1,4 +1,4 @@
import { api, API_BASE, buildApiHeaders } from '../utils/api.js';
import { api, API_BASE, assertApiPermissionForRequest, buildApiHeaders } from '../utils/api.js';
export const summaryTools = [
{
@ -36,6 +36,7 @@ export async function handleSummaryTool(name: string, args: any): Promise<any> {
case 'get_memory_summary': {
const hours = (args as { hours?: number })?.hours || 24;
await assertApiPermissionForRequest(`/api/summary/memory?hours=${hours}`);
const res = await fetch(`${API_BASE}/api/summary/memory?hours=${hours}`, {
headers: buildApiHeaders(),
});

View file

@ -1,2 +1,26 @@
// Re-export shared API client
export { api, createApiClient, API_BASE, buildApiHeaders } from '@veritas-kanban/shared';
// Re-export shared API helpers with MCP permission preflight enabled.
import {
API_BASE,
createApiClient,
createApiPermissionGuard,
createGuardedApiClient,
type ClientAuthContext,
} from '@veritas-kanban/shared';
export {
API_BASE,
ClientPermissionError,
buildApiHeaders,
createApiClient,
createGuardedApiClient,
getApiPermissionRequirement,
type ClientAuthContext,
type ClientAuthPermission,
} from '@veritas-kanban/shared';
export const api = createGuardedApiClient(API_BASE);
const contextApi = createApiClient(API_BASE);
export const assertApiPermissionForRequest = createApiPermissionGuard(() =>
contextApi<ClientAuthContext>('/api/auth/context')
);

View file

@ -1,2 +1,7 @@
// Re-export shared findTask
export { findTask } from '@veritas-kanban/shared';
import { findTask as findTaskWithClient } from '@veritas-kanban/shared';
import type { Task } from './types.js';
import { api } from './api.js';
export function findTask(id: string): Promise<Task | null> {
return findTaskWithClient(id, api);
}

View file

@ -154,6 +154,37 @@ router.get(
})
);
/**
* @openapi
* /api/auth/context:
* get:
* summary: Return current authenticated API context
* description: Returns non-secret auth metadata for CLI/MCP scoped token preflight.
* tags: [Auth]
* responses:
* 200:
* description: Current auth context
* 401:
* description: Authentication required
*/
router.get(
'/context',
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
res.json({
role: req.auth?.role,
keyName: req.auth?.keyName,
isLocalhost: req.auth?.isLocalhost,
userId: req.auth?.userId,
workspaceId: req.auth?.workspaceId,
actorType: req.auth?.actorType,
authMethod: req.auth?.authMethod,
tokenName: req.auth?.tokenName,
permissions: req.auth?.permissions ?? [],
});
})
);
/**
* @openapi
* /api/auth/setup:

View file

@ -3,6 +3,19 @@
*/
import type { Task } from '../types/task.types.js';
import { createApiPermissionGuard, type ClientAuthContext } from './api-permissions.js';
export {
ClientPermissionError,
createApiPermissionGuard,
getApiPermissionRequirement,
hasClientPermission,
type ApiPermissionRequirement,
type ClientAuthActorType,
type ClientAuthContext,
type ClientAuthMethod,
type ClientAuthPermission,
type ClientAuthRole,
} from './api-permissions.js';
const DEFAULT_BASE = 'http://localhost:3001';
@ -119,6 +132,18 @@ export function createApiClient(baseUrl = DEFAULT_BASE, apiKey = getEnv('VK_API_
};
}
export function createGuardedApiClient(baseUrl = DEFAULT_BASE, apiKey = getEnv('VK_API_KEY')) {
const rawApi = createApiClient(baseUrl, apiKey);
const assertPermission = createApiPermissionGuard(() =>
rawApi<ClientAuthContext>('/api/auth/context')
);
return async function api<T>(path: string, options?: RequestInit): Promise<T> {
await assertPermission(path, options);
return rawApi<T>(path, options);
};
}
/**
* Default API client using environment variable or localhost
* Uses typeof check to avoid ReferenceError in browser environments

View file

@ -0,0 +1,358 @@
export type ClientAuthRole = 'admin' | 'read-only' | 'agent';
export type ClientAuthMethod = 'disabled' | 'session' | 'api-key' | 'localhost-bypass';
export type ClientAuthActorType = 'user' | 'agent' | 'service' | 'localhost-bypass';
export type ClientAuthPermission =
| '*'
| 'workspace:read'
| 'task:read'
| 'task:write'
| 'comment:write'
| 'workflow:read'
| 'workflow:write'
| 'workflow:execute'
| 'work_product:read'
| 'work_product:write'
| 'report:read'
| 'telemetry:read'
| 'telemetry:write'
| 'agent:read'
| 'agent:write'
| 'settings:read'
| 'settings:write'
| 'policy:read'
| 'policy:write'
| 'backup:read'
| 'backup:write'
| 'admin:manage';
export interface ClientAuthContext {
role: ClientAuthRole;
keyName?: string;
isLocalhost: boolean;
userId?: string;
workspaceId?: string;
actorType?: ClientAuthActorType;
authMethod?: ClientAuthMethod;
tokenName?: string;
permissions?: ClientAuthPermission[];
}
export interface ApiPermissionRequirement {
permissions: ClientAuthPermission[];
path: string;
method: string;
public: boolean;
}
export type ApiContextClient = <T>(path: string, options?: RequestInit) => Promise<T>;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
interface RoutePermissionConfig {
prefix: string;
read: ClientAuthPermission | ClientAuthPermission[];
write?: ClientAuthPermission | ClientAuthPermission[];
overrides?: {
methods?: string[];
path: RegExp;
permissions: ClientAuthPermission | ClientAuthPermission[];
}[];
}
function asPermissions(
permissions: ClientAuthPermission | ClientAuthPermission[]
): ClientAuthPermission[] {
return Array.isArray(permissions) ? permissions : [permissions];
}
function isSafeMethod(method: string): boolean {
return SAFE_METHODS.has(method.toUpperCase());
}
function normalizeApiPath(path: string): string {
const url = new URL(path, 'http://veritas.local');
let normalized = url.pathname.replace(/\/+$/, '') || '/';
if (normalized === '/api/v1') {
normalized = '/api';
} else if (normalized.startsWith('/api/v1/')) {
normalized = `/api${normalized.slice('/api/v1'.length)}`;
}
return normalized;
}
function routeRequirement(
config: RoutePermissionConfig,
path: string,
method: string
): ApiPermissionRequirement | null {
if (path !== config.prefix && !path.startsWith(`${config.prefix}/`)) {
return null;
}
const relativePath = path.slice(config.prefix.length) || '/';
const override = config.overrides?.find((candidate) => {
const methodMatches =
!candidate.methods ||
candidate.methods.some((candidateMethod) => candidateMethod.toUpperCase() === method);
return methodMatches && candidate.path.test(relativePath);
});
const permissions = override
? override.permissions
: isSafeMethod(method)
? config.read
: (config.write ?? config.read);
return {
permissions: asPermissions(permissions),
path,
method,
public: false,
};
}
const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
{
prefix: '/api/tasks',
read: 'task:read',
write: 'task:write',
overrides: [
{
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
path: /^\/[^/]+\/comments(?:\/.*)?$/,
permissions: 'comment:write',
},
{
methods: ['GET', 'HEAD', 'OPTIONS'],
path: /^\/[^/]+\/work-products(?:\/.*)?$/,
permissions: 'work_product:read',
},
{
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
path: /^\/[^/]+\/work-products(?:\/.*)?$/,
permissions: 'work_product:write',
},
],
},
{ prefix: '/api/backlog', read: 'task:read', write: 'task:write' },
{ prefix: '/api/observations', read: 'task:read' },
{
prefix: '/api/config',
read: 'settings:read',
write: 'settings:write',
overrides: [
{ methods: ['POST'], path: /^\/repos\/validate\/?$/, permissions: 'settings:read' },
],
},
{ prefix: '/api/changes', read: 'task:read' },
{ prefix: '/api/chat', read: 'task:read', write: 'comment:write' },
{ prefix: '/api/agents/register', read: 'agent:read' },
{ prefix: '/api/agents/permissions', read: 'agent:read' },
{ prefix: '/api/agents', read: 'agent:read', write: 'task:write' },
{ prefix: '/api/diff', read: 'task:read' },
{ prefix: '/api/automation', read: 'task:read', write: 'task:write' },
{ prefix: '/api/summary', read: 'report:read' },
{ prefix: '/api/notifications', read: 'agent:read', write: 'comment:write' },
{ prefix: '/api/broadcasts', read: 'task:read', write: 'comment:write' },
{ prefix: '/api/templates', read: 'settings:read', write: 'settings:write' },
{ prefix: '/api/task-types', read: 'settings:read', write: 'settings:write' },
{ prefix: '/api/projects', read: 'settings:read', write: 'settings:write' },
{ prefix: '/api/sprints', read: 'settings:read', write: 'settings:write' },
{ prefix: '/api/activity', read: 'telemetry:read', write: 'admin:manage' },
{ prefix: '/api/github', read: 'task:read', write: 'task:write' },
{ prefix: '/api/preview', read: 'task:read' },
{ prefix: '/api/conflicts', read: 'task:read', write: 'task:write' },
{ prefix: '/api/telemetry', read: 'telemetry:read', write: 'telemetry:write' },
{ prefix: '/api/metrics', read: 'report:read' },
{ prefix: '/api/analytics', read: 'report:read' },
{ prefix: '/api/traces', read: 'telemetry:read', write: 'telemetry:write' },
{ prefix: '/api/drift', read: 'telemetry:read', write: 'telemetry:write' },
{ prefix: '/api/settings/transition-hooks', read: 'admin:manage', write: 'admin:manage' },
{ prefix: '/api/settings', read: 'settings:read', write: 'settings:write' },
{ prefix: '/api/agent/status', read: 'agent:read', write: 'telemetry:write' },
{ prefix: '/api/cost-prediction', read: 'report:read', write: 'task:write' },
{ prefix: '/api/deliverables', read: 'task:read', write: 'task:write' },
{
prefix: '/api/reports',
read: 'report:read',
write: 'settings:write',
overrides: [{ methods: ['POST'], path: /^\/generate\/?$/, permissions: 'report:read' }],
},
{ prefix: '/api/doc-freshness', read: 'settings:read', write: 'settings:write' },
{ prefix: '/api/docs', read: 'settings:read', write: 'settings:write' },
{ prefix: '/api/errors', read: 'telemetry:read', write: 'telemetry:write' },
{
prefix: '/api/search',
read: 'task:read',
write: 'settings:write',
overrides: [
{ methods: ['POST'], path: /^\/?$/, permissions: ['task:read', 'work_product:read'] },
],
},
{ prefix: '/api/work-products', read: 'work_product:read', write: 'work_product:write' },
{ prefix: '/api/hooks', read: 'settings:read', write: 'settings:write' },
{ prefix: '/api/shared-resources', read: 'settings:read', write: 'settings:write' },
{ prefix: '/api/status-history', read: 'telemetry:read', write: 'admin:manage' },
{ prefix: '/api/digest', read: 'report:read' },
{ prefix: '/api/audit', read: 'admin:manage', write: 'admin:manage' },
{ prefix: '/api/lessons', read: 'task:read' },
{ prefix: '/api/delegation', read: 'agent:read', write: 'admin:manage' },
{
prefix: '/api/workflows',
read: 'workflow:read',
write: 'workflow:write',
overrides: [
{ methods: ['POST'], path: /^\/[^/]+\/runs\/?$/, permissions: 'workflow:execute' },
{ methods: ['POST'], path: /^\/runs\/[^/]+\/resume\/?$/, permissions: 'workflow:execute' },
{
methods: ['POST'],
path: /^\/runs\/[^/]+\/steps\/[^/]+\/(approve|reject)\/?$/,
permissions: 'workflow:execute',
},
],
},
{
prefix: '/api/tool-policies',
read: 'policy:read',
overrides: [
{ methods: ['POST'], path: /^\/evaluate\/?$/, permissions: ['policy:read', 'agent:read'] },
],
},
{
prefix: '/api/policies',
read: 'policy:read',
overrides: [
{ methods: ['POST'], path: /^\/evaluate\/?$/, permissions: ['policy:read', 'agent:read'] },
],
},
{ prefix: '/api/integrations', read: 'settings:read', write: 'settings:write' },
{ prefix: '/api/transcripts', read: 'workspace:read', write: 'workflow:execute' },
{
prefix: '/api/scoring',
read: 'report:read',
write: 'settings:write',
overrides: [{ methods: ['POST'], path: /^\/evaluate\/?$/, permissions: 'report:read' }],
},
{ prefix: '/api/system/health', read: 'workspace:read', write: 'admin:manage' },
{ prefix: '/api/decisions', read: 'task:read', write: 'task:write' },
{ prefix: '/api/feedback', read: 'report:read', write: 'comment:write' },
{
prefix: '/api/prompt-registry',
read: 'settings:read',
write: 'settings:write',
overrides: [
{
methods: ['POST'],
path: /^\/[^/]+\/render-preview\/?$/,
permissions: 'settings:read',
},
{
methods: ['POST'],
path: /^\/[^/]+\/record-usage\/?$/,
permissions: 'telemetry:write',
},
],
},
{ prefix: '/api/sqlite', read: 'backup:read', write: 'backup:write' },
{ prefix: '/api/identity', read: 'workspace:read', write: 'admin:manage' },
];
function isPublicApiPath(path: string): boolean {
return (
path === '/health' ||
path.startsWith('/health/') ||
path === '/api/health' ||
path === '/api/health/live' ||
path === '/api/health/ready' ||
path === '/api/auth/status' ||
path === '/api/auth/setup' ||
path === '/api/auth/login' ||
path === '/api/auth/logout' ||
path === '/api/auth/recover' ||
path === '/api/auth/context' ||
path.startsWith('/api/webhook/')
);
}
export function getApiPermissionRequirement(
path: string,
options: Pick<RequestInit, 'method'> = {}
): ApiPermissionRequirement {
const method = (options.method || 'GET').toUpperCase();
const normalizedPath = normalizeApiPath(path);
if (isPublicApiPath(normalizedPath)) {
return { permissions: [], path: normalizedPath, method, public: true };
}
for (const config of ROUTE_PERMISSIONS) {
const requirement = routeRequirement(config, normalizedPath, method);
if (requirement) return requirement;
}
return {
permissions: ['admin:manage'],
path: normalizedPath,
method,
public: false,
};
}
export function hasClientPermission(
auth: Pick<ClientAuthContext, 'role' | 'permissions'> | undefined,
permission: ClientAuthPermission
): boolean {
if (!auth) return false;
if (auth.role === 'admin') return true;
const permissions = auth.permissions ?? [];
return permissions.includes('*') || permissions.includes(permission);
}
export class ClientPermissionError extends Error {
readonly code = 'CLIENT_PERMISSION_DENIED';
readonly required: ClientAuthPermission[];
readonly currentRole?: ClientAuthRole;
readonly currentPermissions: ClientAuthPermission[];
readonly path: string;
readonly method: string;
constructor(requirement: ApiPermissionRequirement, context: ClientAuthContext) {
const required = requirement.permissions.join(', ');
super(
`Token is not allowed to call ${requirement.method} ${requirement.path}. Required permission: ${required}. Current role: ${context.role}.`
);
this.name = 'ClientPermissionError';
this.required = requirement.permissions;
this.currentRole = context.role;
this.currentPermissions = context.permissions ?? [];
this.path = requirement.path;
this.method = requirement.method;
}
}
export function createApiPermissionGuard(loadContext: () => Promise<ClientAuthContext>) {
let cachedContext: Promise<ClientAuthContext> | null = null;
return async function assertApiPermissionForRequest(
path: string,
options: Pick<RequestInit, 'method'> = {}
): Promise<ClientAuthContext | null> {
const requirement = getApiPermissionRequirement(path, options);
if (requirement.public) return null;
cachedContext ??= loadContext();
const context = await cachedContext;
const allowed = requirement.permissions.some((permission) =>
hasClientPermission(context, permission)
);
if (!allowed) {
throw new ClientPermissionError(requirement, context);
}
return context;
};
}

View file

@ -5,5 +5,6 @@
export * from './path.js';
export * from './format.js';
export * from './constants.js';
export * from './api-permissions.js';
export * from './api-client.js';
export * from './agent-helpers.js';

View file

@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: ['server', 'web', 'mcp'],
projects: ['server', 'web', 'mcp', 'cli'],
},
});