feat: enforce MCP capabilities and no-delete policy
This commit is contained in:
parent
96da8ac4e8
commit
f9d3427767
4 changed files with 136 additions and 0 deletions
39
src/lib/purchasing/client.test.ts
Normal file
39
src/lib/purchasing/client.test.ts
Normal 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');
|
||||
});
|
||||
73
src/lib/purchasing/client.ts
Normal file
73
src/lib/purchasing/client.ts
Normal 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};
|
||||
}
|
||||
6
src/lib/purchasing/errors.ts
Normal file
6
src/lib/purchasing/errors.ts
Normal 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
18
src/mcp/tool-contract.ts
Normal 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)}]};}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue