feat: expose purchasing details and protected source documents

This commit is contained in:
DEls 2026-10-05 01:42:27 +02:00
parent 08becc6270
commit cc8e2c8f01
9 changed files with 215 additions and 1 deletions

View file

@ -0,0 +1,23 @@
import {afterEach,beforeEach,expect,it,vi} from 'vitest';
import {hash} from 'node:crypto';
const mocks=vi.hoisted(()=>({context:vi.fn(),document:vi.fn()}));
vi.mock('@/lib/purchasing/client',()=>({createPurchasingContext:mocks.context}));
vi.mock('@/lib/purchasing/documents',async importOriginal=>({...await importOriginal<typeof import('@/lib/purchasing/documents')>(),loadDocument:mocks.document}));
import {GET} from './route';
const token='document-test-token-of-sufficient-length';
beforeEach(()=>{
vi.stubEnv('MCP_PUBLIC_URL','https://mega.test/api/mcp');
vi.stubEnv('MCP_CLIENTS_JSON',JSON.stringify([{id:'read',tokenHash:hash('sha256',token),permissions:['read']}]));
mocks.context.mockReset();mocks.document.mockReset();
mocks.context.mockResolvedValue({});mocks.document.mockResolvedValue({bytes:new TextEncoder().encode('%PDF-fixture'),mime:'application/pdf',filename:'étude.pdf'});
});
afterEach(()=>vi.unstubAllEnvs());
it('rejects missing auth before PB or file access',async()=>{
const res=await GET(new Request('https://mega.test/api/mcp/documents/po/abcdefghijklmn1'),{params:Promise.resolve({kind:'po',id:'abcdefghijklmn1'})});
expect(res.status).toBe(401);expect(mocks.context).not.toHaveBeenCalled();expect(mocks.document).not.toHaveBeenCalled();
});
it('returns protected file bytes and Unicode attachment metadata',async()=>{
const res=await GET(new Request('https://mega.test/api/mcp/documents/po/abcdefghijklmn1',{headers:{Authorization:`Bearer ${token}`}}),{params:Promise.resolve({kind:'po',id:'abcdefghijklmn1'})});
expect(res.status).toBe(200);expect(await res.text()).toBe('%PDF-fixture');
expect(res.headers.get('Content-Disposition')).toContain('%C3%A9tude.pdf');expect(res.headers.get('Cache-Control')).toContain('no-store');
});

View file

@ -0,0 +1,13 @@
import {authenticateMcp,requirePermission,McpHttpError} from '@/mcp/auth';
import {createPurchasingContext} from '@/lib/purchasing/client';
import {loadDocument,parseDocumentReference} from '@/lib/purchasing/documents';
import {PurchasingError} from '@/lib/purchasing/errors';
export const runtime='nodejs';export const dynamic='force-dynamic';
export async function GET(request:Request,{params}:{params:Promise<{kind:string;id:string}>}):Promise<Response>{
try{
const principal=authenticateMcp(request);requirePermission(principal,'read');
const reference=parseDocumentReference(await params);
const document=await loadDocument(await createPurchasingContext(principal),reference);
return new Response(new Uint8Array(document.bytes),{headers:{'Content-Type':document.mime,'Content-Disposition':`attachment; filename="document"; filename*=UTF-8''${encodeURIComponent(document.filename).replace(/'/g,'%27')}`,'Content-Length':String(document.bytes.length),'Cache-Control':'private, no-store','X-Content-Type-Options':'nosniff'}});
}catch(error){return Response.json({error:error instanceof PurchasingError||error instanceof McpHttpError?error.message:'document_unavailable'},{status:error instanceof PurchasingError||error instanceof McpHttpError?error.status:502,headers:{'Cache-Control':'no-store'}});}
}

View file

@ -0,0 +1,26 @@
import {afterEach,expect,it,vi} from 'vitest';
import type PocketBase from 'pocketbase';
import {parseDocumentReference,safeAttachmentName,documentDownloadUrl,loadDocument} from './documents';
afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();});
it('rejects paths, URLs and unknown reference kinds',()=>{
expect(()=>parseDocumentReference({url:'https://evil.test'})).toThrow();
expect(()=>parseDocumentReference({kind:'po',id:'../secret'})).toThrow();
expect(()=>parseDocumentReference({kind:'users',id:'abcdefghijklmn1'})).toThrow();
});
it('preserves Unicode and strips unsafe attachment characters',()=>{
expect(safeAttachmentName('../étude"\r\n.pdf')).toBe('étude.pdf');
vi.stubEnv('MCP_PUBLIC_URL','https://mega.test/api/mcp');
expect(documentDownloadUrl({kind:'po',id:'abcdefghijklmn1'})).toBe('https://mega.test/api/mcp/documents/po/abcdefghijklmn1');
});
it('reads a protected original and returns file bytes without upstream credentials',async()=>{
const pb={collection:()=>({getOne:async()=>({id:'abcdefghijklmn1',collectionName:'purchase_orders',purchase_order_file:'invoice.pdf'})}),files:{getToken:async()=>'private-file-token',getURL:()=> 'https://pb.test/api/files/purchase_orders/abcdefghijklmn1/invoice.pdf?token=private-file-token'},authStore:{token:'private-admin'}} as unknown as PocketBase;
vi.spyOn(globalThis,'fetch').mockResolvedValue(new Response('%PDF-fixture',{headers:{'Content-Type':'application/pdf'}}));
const doc=await loadDocument({pb,principal:{id:'read',permissions:new Set(['read'])}},{kind:'po',id:'abcdefghijklmn1'});
expect(Buffer.from(doc.bytes).toString()).toBe('%PDF-fixture');expect(doc.filename).toBe('invoice.pdf');
expect(JSON.stringify(doc)).not.toContain('private');
});
it('bounds document reads even if the server omits Content-Length',async()=>{
const pb={collection:()=>({getOne:async()=>({id:'abcdefghijklmn1',file:'source.pdf'})}),files:{getToken:async()=>'token',getURL:()=> 'https://pb.test/source'},authStore:{token:'admin'}} as unknown as PocketBase;
vi.spyOn(globalThis,'fetch').mockResolvedValue(new Response(new Uint8Array(20*1024*1024+1)));
await expect(loadDocument({pb,principal:{id:'read',permissions:new Set(['read'])}},{kind:'source',id:'abcdefghijklmn1'})).rejects.toThrow('document_too_large');
});

View file

@ -0,0 +1,54 @@
import {z} from 'zod';
import {PDFDocument} from 'pdf-lib';
import type {RecordModel} from 'pocketbase';
import type {DocumentReference,PurchasingContext} from './types';
import {PurchasingError} from './errors';
export const RecordId=z.string().regex(/^[a-z0-9]{15}$/);
export const DocumentReferenceSchema=z.object({kind:z.enum(['po','receipt','source','page','candidate']),id:RecordId}).strict();
export function parseDocumentReference(input:unknown):DocumentReference{return DocumentReferenceSchema.parse(input);}
export function safeAttachmentName(filename:string):string{return filename.split(/[\\/]/).pop()!.replace(/[\x00-\x1f\x7f"<>]/g,'').slice(0,200)||'document';}
export function documentDownloadUrl(reference:DocumentReference):string {
parseDocumentReference(reference);
const base=new URL(process.env.MCP_PUBLIC_URL||'');
return new URL(`/api/mcp/documents/${reference.kind}/${reference.id}`,base).toString();
}
const MAX_BYTES=20*1024*1024;
async function boundedBytes(response:Response):Promise<Uint8Array>{
if(!response.ok)throw new PurchasingError('document_unavailable',502);
if(Number(response.headers.get('content-length'))>MAX_BYTES)throw new PurchasingError('document_too_large',413);
if(!response.body)throw new PurchasingError('document_empty',502);
const reader=response.body.getReader(),parts:Uint8Array[]=[];let size=0;
try{for(;;){const next=await reader.read();if(next.done)break;size+=next.value.byteLength;if(size>MAX_BYTES){await reader.cancel();throw new PurchasingError('document_too_large',413);}parts.push(next.value);}}finally{reader.releaseLock();}
return new Uint8Array(Buffer.concat(parts));
}
export async function loadDocument(ctx:PurchasingContext,reference:DocumentReference):Promise<{bytes:Uint8Array;mime:string;filename:string}>{
const ref=parseDocumentReference(reference);
if(ref.kind==='candidate'){
const note=await ctx.pb.collection('dn_batch_notes').getOne(ref.id,{fields:'id,batch,pages'});
const saved:Array<number>=Array.isArray(note.pages)?note.pages:[];
if(!saved.length||saved.length>100||new Set(saved).size!==saved.length)throw new PurchasingError('invalid_candidate_pages');
const rows=await ctx.pb.collection('dn_batch_pages').getFullList({filter:ctx.pb.filter('batch={:batch}',{batch:note.batch}),fields:'id,batch,global_page,image'});
const pdf=await PDFDocument.create();let total=0;
for(const page of saved){
const matches=rows.filter(row=>row.batch===note.batch&&row.global_page===page);
if(matches.length!==1)throw new PurchasingError('candidate_source_page_missing',404);
const source=await loadDocument(ctx,{kind:'page',id:matches[0].id});total+=source.bytes.length;
if(total>MAX_BYTES)throw new PurchasingError('document_too_large',413);
const image=source.mime==='image/jpeg'?await pdf.embedJpg(source.bytes):source.mime==='image/png'?await pdf.embedPng(source.bytes):null;
if(!image)throw new PurchasingError('source_page_type_unsupported');
const sheet=pdf.addPage([image.width,image.height]);sheet.drawImage(image,{x:0,y:0,width:image.width,height:image.height});
}
const bytes=await pdf.save();if(bytes.length>MAX_BYTES)throw new PurchasingError('document_too_large',413);
return {bytes,mime:'application/pdf',filename:`delivery-${ref.id}.pdf`};
}
const mapping={po:['purchase_orders','purchase_order_file'],receipt:['delivery_notes','slip_image'],source:['dn_batch_sources','file'],page:['dn_batch_pages','image']} as const;
const [collection,field]=mapping[ref.kind];
const record=await ctx.pb.collection(collection).getOne(ref.id,{fields:`id,collectionId,${field}`});
const filename=record[field];if(typeof filename!=='string'||!filename)throw new PurchasingError('document_not_found',404);
const url=ctx.pb.files.getURL({...record,collectionName:collection} as RecordModel,filename,{token:await ctx.pb.files.getToken()});
const upstream=await fetch(url,{headers:{Authorization:ctx.pb.authStore.token},signal:AbortSignal.timeout(60000)});
const mime=(upstream.headers.get('content-type')||'application/octet-stream').split(';')[0].toLowerCase();
const allowed=new Set(['application/pdf','image/png','image/jpeg','image/webp']);
const bytes=await boundedBytes(upstream);
return {bytes,mime:allowed.has(mime)?mime:'application/octet-stream',filename:safeAttachmentName(filename)};
}

View file

@ -0,0 +1,8 @@
import {expect,it} from 'vitest';
import {ListPurchasingSchema} from './reads';
it('bounds pagination and prevents arbitrary collection access',()=>{
expect(()=>ListPurchasingSchema.parse({entity:'users'})).toThrow();
expect(()=>ListPurchasingSchema.parse({entity:'purchase_orders',pageSize:1001})).toThrow();
expect(()=>ListPurchasingSchema.parse({entity:'purchase_orders',filter:'true'})).toThrow();
expect(ListPurchasingSchema.parse({entity:'purchase_orders'}).pageSize).toBe(50);
});

View file

@ -0,0 +1,61 @@
import {z} from 'zod';
import type {PurchasingContext} from './types';
import {RecordId} from './documents';
import {PurchasingError} from './errors';
import {computeOutstanding,type DeliveryLineLike} from '@/lib/po-fulfilment';
import {reconcilePoDelivery,type ReconciliationReceipt,type ReconciliationCandidate} from '@/lib/po-delivery-reconciliation';
import type {DocumentKind} from '@/lib/document-kind';
const entities={
purchase_orders:'id,site,purchase_order_number,supplier,supplier_ref,order_date,required_by_date,ordered_by,subtotal,vat_amount,total_amount,is_verified,processing_status,purchase_order_file,created,updated',
purchase_order_items:'id,purchase_order,description,material_code,cost_code,qty,unit,rate,disc_percent,vat_percent,amount,created,updated',
delivery_notes:'id,site,purchase_order,delivery_note_number,date_received,received_by_name,document_type,is_verified,slip_image,processing_status,created,updated',
delivery_note_items:'id,delivery_note,purchase_order_item,qty_received,qty_raw,unit_raw,condition_notes,booking,allocation_key,source_line_keys',
dn_batches:'id,site,status,stage,page_count,source_count,file_hash,assembly_revision,revision,created,updated',
dn_batch_sources:'id,batch,ordinal,mime,original_name,file_hash,file,created',
dn_batch_pages:'id,batch,source,source_page,global_page,region,image,mime,image_hash',
dn_batch_notes:'id,batch,pages,doc_number,doc_number_alts,delivery_note_number,supplier,supplier_name_raw,purchase_order,items,checks,status,stage,reconciliation_preview,manual_overrides,line_posting,delivery_note,posted_at,assembly_revision,candidate_revision',
suppliers:'id,name,created,updated',
supplier_reconciliation_rules:'id,supplier,scope,status,title,instruction,examples,created,updated',
supplier_document_aliases:'id,supplier,raw_value,normalised_key,source,approved_by',
dn_item_aliases:'id,supplier_key,slip_description_key,purchase_order_item,po_description,times_used',
} as const;
export const EntitySchema=z.enum(Object.keys(entities) as [keyof typeof entities,...(keyof typeof entities)[]]);
export const ListPurchasingSchema=z.object({entity:EntitySchema,site:RecordId.optional(),status:z.string().max(80).optional(),page:z.number().int().min(1).max(10000).default(1),pageSize:z.number().int().min(1).max(100).default(50)}).strict();
export const GetPurchasingSchema=z.object({entity:EntitySchema,id:RecordId}).strict();
export const GetPoSchema=z.object({id:RecordId}).strict();
export const GetCandidateSchema=z.object({batchId:RecordId,id:RecordId}).strict();
export async function readPurchasing(ctx:PurchasingContext,action:'list_purchasing_records'|'get_purchasing_record'|'get_purchase_order_detail'|'get_delivery_candidate'|'get_delivery_reconciliation',input:Record<string,unknown>):Promise<Record<string,unknown>>{
const pb=ctx.pb;
if(action==='list_purchasing_records'){
const args=ListPurchasingSchema.parse(input);const filters:string[]=[];
if(args.site){if(!['purchase_orders','delivery_notes','dn_batches'].includes(args.entity))throw new PurchasingError('entity_has_no_site_filter');filters.push(pb.filter('site={:site}',{site:args.site}));}
if(args.status){const field=args.entity==='purchase_orders'||args.entity==='delivery_notes'?'processing_status':'status';if(!['purchase_orders','delivery_notes','dn_batches','dn_batch_notes','supplier_reconciliation_rules'].includes(args.entity))throw new PurchasingError('entity_has_no_status_filter');filters.push(pb.filter(`${field}={:status}`,{status:args.status}));}
const result=await pb.collection(args.entity).getList(args.page,args.pageSize,{filter:filters.join(' && '),sort:'id',fields:entities[args.entity]});return {...result};
}
if(action==='get_purchasing_record'){const args=GetPurchasingSchema.parse(input);return {...await pb.collection(args.entity).getOne(args.id,{fields:entities[args.entity]})};}
if(action==='get_delivery_candidate'){
const args=GetCandidateSchema.parse(input);const note=await pb.collection('dn_batch_notes').getOne(args.id,{fields:entities.dn_batch_notes});
if(note.batch!==args.batchId)throw new PurchasingError('candidate_batch_mismatch');
const pages=await pb.collection('dn_batch_pages').getFullList({filter:pb.filter('batch={:batch}',{batch:args.batchId}),fields:entities.dn_batch_pages});
return {note,pages:pages.filter(page=>Array.isArray(note.pages)&&note.pages.includes(page.global_page)).sort((a,b)=>note.pages.indexOf(a.global_page)-note.pages.indexOf(b.global_page))};
}
const {id}=GetPoSchema.parse(input);
const po=await pb.collection('purchase_orders').getOne(id,{fields:entities.purchase_orders});
const [items,deliveries,candidates]=await Promise.all([
pb.collection('purchase_order_items').getFullList({filter:pb.filter('purchase_order={:id}',{id}),fields:entities.purchase_order_items}),
pb.collection('delivery_notes').getFullList({filter:pb.filter('purchase_order={:id}',{id}),fields:entities.delivery_notes}),
pb.collection('dn_batch_notes').getFullList({filter:pb.filter("purchase_order={:id} && status!='promoted' && status!='dismissed'",{id}),fields:entities.dn_batch_notes}),
]);
const receipts:ReconciliationReceipt[]=[];const fulfilmentLines:DeliveryLineLike[]=[];
for(const delivery of deliveries){
const lines=await pb.collection('delivery_note_items').getFullList({filter:pb.filter('delivery_note={:id}',{id:delivery.id}),fields:entities.delivery_note_items});
receipts.push({id:delivery.id,number:String(delivery.delivery_note_number??''),documentType:String(delivery.document_type??'') as DocumentKind,lines:lines.map(line=>({purchaseOrderItem:String(line.purchase_order_item??''),quantity:Number(line.qty_received??0),booking:line.booking}))});
fulfilmentLines.push(...lines.map(line=>({purchase_order_item:String(line.purchase_order_item??''),qty_received:Number(line.qty_received??0),document_type:String(delivery.document_type??'') as DocumentKind})));
}
if(action==='get_purchase_order_detail'){
const fulfilment=computeOutstanding(items.map(item=>({id:item.id,qty:Number(item.qty??0)})),fulfilmentLines);
return {po,items:items.map(item=>({...item,fulfilment:fulfilment.get(item.id)})),deliveries,document:po.purchase_order_file?{kind:'po',id}:null};
}
const snapshot=reconcilePoDelivery(items.map(item=>({id:item.id,description:String(item.description??''),unit:String(item.unit??''),rate:item.rate==null?NaN:Number(item.rate),orderedQuantity:item.qty==null?null:Number(item.qty)})),receipts,candidates.map(note=>({id:note.id,batchId:String(note.batch??''),number:String(note.doc_number||note.delivery_note_number||''),pages:Array.isArray(note.pages)?note.pages:[],status:String(note.status??''),checks:Array.isArray(note.checks)?note.checks:[],items:Array.isArray(note.items)?note.items:[]} as ReconciliationCandidate)));
return {po,generatedAt:new Date().toISOString(),...snapshot};
}

View file

@ -2,6 +2,7 @@ import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import {tools} from '@/lib/agent/tools';
import type {Principal} from '@/lib/purchasing/types';
import {requirePermission} from './auth';
import {registerPurchasingReads} from './purchasing-read-tools';
export function createMcpServer(principal:Principal):McpServer {
const server=new McpServer({name:'mega-infrastructure',version:'1.1.0'});
if(principal.permissions.has('read'))for(const tool of tools){
@ -18,5 +19,6 @@ export function createMcpServer(principal:Principal):McpServer {
}
});
}
registerPurchasingReads(server,principal);
return server;
}

View file

@ -15,7 +15,7 @@ it('initializes using the actual SDK without a cookie or session',async()=>{
});
it('discovers and calls read tools through fresh requests',async()=>{
const listed=await handleMcp(req({jsonrpc:'2.0',id:2,method:'tools/list',params:{}}));
expect((await listed.json()).result.tools.map((t:{name:string})=>t.name)).toEqual(['fixture_read']);
expect((await listed.json()).result.tools.map((t:{name:string})=>t.name)).toContain('fixture_read');
const called=await handleMcp(req({jsonrpc:'2.0',id:3,method:'tools/call',params:{name:'fixture_read',arguments:{}}}));
expect(JSON.parse((await called.json()).result.content[0].text)).toEqual({ok:true});
});

View file

@ -0,0 +1,27 @@
import {z} from 'zod';
import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import type {Principal} from '@/lib/purchasing/types';
import {registerPurchasingTool} from './tool-contract';
import {ListPurchasingSchema,GetPurchasingSchema,GetPoSchema,GetCandidateSchema,readPurchasing} from '@/lib/purchasing/reads';
import {DocumentReferenceSchema,loadDocument,documentDownloadUrl} from '@/lib/purchasing/documents';
import {PurchasingError} from '@/lib/purchasing/errors';
import {getOperation} from '@/lib/purchasing/operations';
export function registerPurchasingReads(server:McpServer,principal:Principal):void{
const detail=[
['list_purchasing_records','List purchasing records with bounded pagination.',ListPurchasingSchema],
['get_purchasing_record','Get an allowed purchasing record and related operational fields.',GetPurchasingSchema],
['get_purchase_order_detail','Get PO lines, receipt quantities and outstanding quantities.',GetPoSchema],
['get_delivery_candidate','Get a delivery candidate, matching decisions and source page references.',GetCandidateSchema],
['get_delivery_reconciliation','Compare an order, posted receipts and unposted candidates.',GetPoSchema],
] as const;
for(const [name,description,schema] of detail)registerPurchasingTool(server,principal,{name,description,schema,permission:'read',handler:(ctx,input)=>readPurchasing(ctx,name,input)});
registerPurchasingTool(server,principal,{name:'get_document_download',description:'Get document metadata and a same-app download URL requiring the MCP bearer header.',permission:'read',schema:DocumentReferenceSchema,handler:async(ctx,input)=>{
const ref=DocumentReferenceSchema.parse(input),doc=await loadDocument(ctx,ref);return {filename:doc.filename,mime:doc.mime,size:doc.bytes.length,url:documentDownloadUrl(ref),authentication:'Authorization: Bearer <your MCP key>'};
}});
registerPurchasingTool(server,principal,{name:'read_document',description:'Read a bounded binary document segment as base64. Iterate offset for larger files.',permission:'read',schema:DocumentReferenceSchema.extend({offset:z.number().int().min(0).default(0),length:z.number().int().min(1).max(2*1024*1024).default(2*1024*1024)}).strict(),handler:async(ctx,input)=>{
const {kind,id,offset,length}=DocumentReferenceSchema.extend({offset:z.number(),length:z.number()}).parse(input);
const doc=await loadDocument(ctx,{kind,id});if(offset>=doc.bytes.length)throw new PurchasingError('document_range_invalid',416);
const slice=doc.bytes.slice(offset,offset+length);return {filename:doc.filename,mime:doc.mime,size:doc.bytes.length,offset,length:slice.length,nextOffset:offset+slice.length<doc.bytes.length?offset+slice.length:null,base64:Buffer.from(slice).toString('base64')};
}});
registerPurchasingTool(server,principal,{name:'get_mcp_operation',description:'Get the status of an operation belonging to this credential.',permission:'read',schema:z.object({operationId:z.string().uuid()}).strict(),handler:(ctx,input)=>getOperation(ctx,String(input.operationId))});
}