From f9d342776794778743020cfacafdea3a135732ba Mon Sep 17 00:00:00 2001 From: DEls Date: Mon, 5 Oct 2026 01:33:37 +0200 Subject: [PATCH] feat: enforce MCP capabilities and no-delete policy --- src/lib/purchasing/client.test.ts | 39 +++++++++++++++++ src/lib/purchasing/client.ts | 73 +++++++++++++++++++++++++++++++ src/lib/purchasing/errors.ts | 6 +++ src/mcp/tool-contract.ts | 18 ++++++++ 4 files changed, 136 insertions(+) create mode 100644 src/lib/purchasing/client.test.ts create mode 100644 src/lib/purchasing/client.ts create mode 100644 src/lib/purchasing/errors.ts create mode 100644 src/mcp/tool-contract.ts diff --git a/src/lib/purchasing/client.test.ts b/src/lib/purchasing/client.test.ts new file mode 100644 index 0000000..93de67e --- /dev/null +++ b/src/lib/purchasing/client.test.ts @@ -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'); +}); diff --git a/src/lib/purchasing/client.ts b/src/lib/purchasing/client.ts new file mode 100644 index 0000000..bc3afdc --- /dev/null +++ b/src/lib/purchasing/client.ts @@ -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{ + if(value instanceof FormData){ + const result:Record={}; + 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:{}; +} +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(); + 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{ + 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}; +} diff --git a/src/lib/purchasing/errors.ts b/src/lib/purchasing/errors.ts new file mode 100644 index 0000000..a0dbfa3 --- /dev/null +++ b/src/lib/purchasing/errors.ts @@ -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'; +} diff --git a/src/mcp/tool-contract.ts b/src/mcp/tool-contract.ts new file mode 100644 index 0000000..c4de9aa --- /dev/null +++ b/src/mcp/tool-contract.ts @@ -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)=>Promise}):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)}]};} + }); +}