feat: enforce MCP capabilities and no-delete policy

This commit is contained in:
DEls 2026-10-05 01:33:37 +02:00
parent 96da8ac4e8
commit f9d3427767
4 changed files with 136 additions and 0 deletions

View file

@ -0,0 +1,39 @@
import {afterEach,expect,it,vi} from 'vitest';
import PocketBase from 'pocketbase';
import {assertAllowedMutation,guardPurchasingClient,createPurchasingContext} from './client';
const principal={id:'agent',permissions:new Set<'read'|'write'>(['read','write'])};
afterEach(()=>{vi.unstubAllEnvs();vi.restoreAllMocks();});
it.each(['purchase_orders','delivery_notes','delivery_note_items','dn_batch_sources'])('denies deleting %s',collection=>{
expect(()=>assertAllowedMutation(collection,'DELETE')).toThrow('business_delete_forbidden');
});
it('denies file replacement, removal and unknown collection writes',()=>{
expect(()=>assertAllowedMutation('purchase_orders','PATCH',{purchase_order_file:'replacement.pdf'})).toThrow('source_file_immutable');
expect(()=>assertAllowedMutation('delivery_notes','PATCH',{'slip_image-':['a.pdf']})).toThrow('source_file_immutable');
expect(()=>assertAllowedMutation('users','PATCH',{active:false})).toThrow('collection_write_forbidden');
});
it('allows business create and ordinary header updates',()=>{
expect(()=>assertAllowedMutation('purchase_orders','POST',{site:'site'})).not.toThrow();
expect(()=>assertAllowedMutation('purchase_orders','PATCH',{po_number:'changed'})).not.toThrow();
});
it('allows only explicitly owned transient lock deletions',()=>{
expect(()=>assertAllowedMutation('dn_pipeline_locks','DELETE')).toThrow();
expect(()=>assertAllowedMutation('dn_pipeline_locks','DELETE',undefined,true)).not.toThrow();
});
it('blocks encoded deletes and batch deletes before fetch',async()=>{
const pb=new PocketBase('https://pb.test');guardPurchasingClient(pb,principal);
const fetcher=vi.spyOn(globalThis,'fetch');
await expect(pb.send('/api/collections/purchase_orders/records/abcdefghijklmn1',{method:'DELETE'})).rejects.toThrow();
await expect(pb.send('/api/batch',{method:'POST',body:{requests:[{method:'DELETE',url:'/api/collections/delivery_notes/records/abcdefghijklmn1'}]}})).rejects.toThrow();
expect(fetcher).not.toHaveBeenCalled();
});
it('denies hidden mutations for a read principal',async()=>{
const pb=new PocketBase('https://pb.test');guardPurchasingClient(pb,{id:'read',permissions:new Set(['read'])});
await expect(pb.collection('purchase_orders').create({site:'site'})).rejects.toThrow();
});
it('refreshes an expired preset token using server credentials',async()=>{
vi.stubEnv('NEXT_PUBLIC_POCKETBASE_URL','https://pb.test');
vi.stubEnv('POCKETBASE_ADMIN_TOKEN','expired');vi.stubEnv('POCKETBASE_ADMIN_EMAIL','test@example.com');vi.stubEnv('POCKETBASE_ADMIN_PASSWORD','server-secret');
const response=vi.spyOn(globalThis,'fetch').mockResolvedValue(new Response(JSON.stringify({token:'refreshed',record:{id:'admin'}}),{status:200,headers:{'Content-Type':'application/json'}}));
const ctx=await createPurchasingContext(principal);
expect(response).toHaveBeenCalledTimes(1);expect(ctx.pb.authStore.token).toBe('refreshed');
});

View file

@ -0,0 +1,73 @@
import PocketBase from 'pocketbase';
import type {Principal,PurchasingContext} from './types';
import {PurchasingError} from './errors';
const BUSINESS=new Set(['purchase_orders','purchase_order_items','delivery_notes','delivery_note_items','dn_batches','dn_batch_sources','dn_batch_pages','dn_batch_notes','dn_review_events','suppliers','supplier_reconciliation_rules','supplier_document_aliases','supplier_item_aliases','dn_item_aliases','mcp_operations','mcp_jobs']);
const LOCKS=new Set(['dn_pipeline_locks','mcp_resource_locks']);
const FILES=new Set(['purchase_order_file','slip_image','image','file','source_file','document']);
function objectBody(value:unknown):Record<string,unknown>{
if(value instanceof FormData){
const result:Record<string,unknown>={};
value.forEach((v,k)=>{if(k==='@jsonPayload'&&typeof v==='string')Object.assign(result,JSON.parse(v));else result[k]=v;});return result;
}
if(typeof value==='string')return JSON.parse(value);
return value&&typeof value==='object'?value as Record<string,unknown>:{};
}
export function assertAllowedMutation(collection:string,method:string,data?:unknown,ownedLock=false):void {
const verb=method.toUpperCase();if(verb==='GET')return;
if(verb==='DELETE'){
if(LOCKS.has(collection)&&ownedLock)return;
throw new PurchasingError('business_delete_forbidden',403);
}
if(!['POST','PATCH'].includes(verb)||(!BUSINESS.has(collection)&&!LOCKS.has(collection)))throw new PurchasingError('collection_write_forbidden',403);
if(verb==='PATCH')for(const name of Object.keys(objectBody(data))){
if(FILES.has(name.replace(/^[+-]|[+-]$/g,'')))throw new PurchasingError('source_file_immutable',403);
}
}
export function guardPurchasingClient(pb:PocketBase,principal:Principal):void {
const cleanup=new Set<string>();
const recordPath=(url:string)=>/^\/api\/collections\/([^/]+)\/records(?:\/([^/]+))?$/.exec(decodeURIComponent(new URL(url,pb.baseURL).pathname));
pb.beforeSend=(url,options)=>{
const method=(options.method||'GET').toUpperCase();
const path=new URL(url).pathname;
if(method==='GET')return {url,options};
if(path==='/api/files/token'&&method==='POST')return {url,options};
if(!principal.permissions.has('write')&&!principal.permissions.has('process'))throw new PurchasingError('permission_denied',403);
const validate=(suburl:string,verb:string,body?:unknown)=>{
const record=recordPath(suburl);if(!record)throw new PurchasingError('collection_write_forbidden',403);
assertAllowedMutation(record[1],verb,body,cleanup.has(`${record[1]}:${record[2]}`));
};
if(path==='/api/batch'){
const body=objectBody(options.body);const requests=body.requests;
if(!Array.isArray(requests)||requests.length>50)throw new PurchasingError('invalid_batch');
for(const raw of requests){
const item=raw as {url?:unknown;method?:unknown;body?:unknown;json?:unknown};
if(typeof item.url!=='string'||typeof item.method!=='string')throw new PurchasingError('invalid_batch');
validate(item.url,item.method,item.json??item.body);
}
}else validate(url,method,options.body);
return {url,options};
};
pb.afterSend=(response,data)=>{
const record=recordPath(response.url||pb.baseURL);
if(record&&LOCKS.has(record[1])){
const rows=Array.isArray(data?.items)?data.items:[data];
for(const row of rows){
if(!row||typeof row.id!=='string')continue;
if(row.worker===principal.id||String(row.worker??'').startsWith(principal.id+':')||row.owner===principal.id||String(row.owner??'').startsWith(principal.id+':')||Date.parse(row.expires_at)<=Date.now())cleanup.add(`${record[1]}:${row.id}`);
}
}
return data;
};
}
export async function createPurchasingContext(principal:Principal):Promise<PurchasingContext>{
const url=process.env.NEXT_PUBLIC_POCKETBASE_URL;
if(!url)throw new PurchasingError('pocketbase_setup_required',503);
const pb=new PocketBase(url);pb.autoCancellation(false);
if(process.env.POCKETBASE_ADMIN_TOKEN)pb.authStore.save(process.env.POCKETBASE_ADMIN_TOKEN);
if(!pb.authStore.isValid){
const email=process.env.POCKETBASE_ADMIN_EMAIL,password=process.env.POCKETBASE_ADMIN_PASSWORD;
if(!email||!password)throw new PurchasingError('pocketbase_setup_required',503);
try{await pb.collection('_superusers').authWithPassword(email,password);}catch{throw new PurchasingError('pocketbase_auth_failed',503);}
}
guardPurchasingClient(pb,principal);return {pb,principal};
}

View file

@ -0,0 +1,6 @@
export class PurchasingError extends Error {
constructor(public code:string,public status=400){super(code);}
}
export function safePurchasingError(error:unknown):string {
return error instanceof PurchasingError?error.code:'purchasing_operation_failed';
}

18
src/mcp/tool-contract.ts Normal file
View file

@ -0,0 +1,18 @@
import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import {z} from 'zod';
import type {Permission,Principal,PurchasingContext} from '@/lib/purchasing/types';
import {createPurchasingContext} from '@/lib/purchasing/client';
import {safePurchasingError} from '@/lib/purchasing/errors';
import {requirePermission} from './auth';
export function registerPurchasingTool(server:McpServer,principal:Principal,definition:{name:string;description:string;permission:Permission;schema:z.AnyZodObject;handler:(context:PurchasingContext,input:Record<string,unknown>)=>Promise<unknown>}):void {
if(!principal.permissions.has(definition.permission))return;
server.registerTool(definition.name,{description:definition.description,inputSchema:definition.schema.shape,annotations:{readOnlyHint:definition.permission==='read',destructiveHint:false}},async args=>{
try {
requirePermission(principal,definition.permission);
const input=definition.schema.parse(args);
const context=await createPurchasingContext(principal);
const result=await definition.handler(context,input);
return {content:[{type:'text' as const,text:JSON.stringify(result)}]};
}catch(error){return {isError:true,content:[{type:'text' as const,text:safePurchasingError(error)}]};}
});
}