From 3f9a21c64df4413955585ec919c977cf4f7f5ea9 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 2 Sep 2026 21:08:49 +0200 Subject: [PATCH] refactor(connect): validate the Peppol connector with the shared contract schemas (#2193) The hosted Peppol route now parses requests with the schemas published in @accounted/connect-contract instead of its own copies, and the instance transport validates every hosted answer against the contract's response schemas (a shape mismatch is a non-retryable protocol error) and reads the error envelope through connectorErrorSchema. Paths come from the operation table. No behaviour change for a conforming peer; the two sides can no longer drift apart silently. Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- app/api/connect/peppol/[...path]/route.ts | 59 ++++--------------- .../transports/__tests__/connector.test.ts | 27 +++++++-- lib/invoices/transports/connector.ts | 58 ++++++++++-------- 3 files changed, 69 insertions(+), 75 deletions(-) diff --git a/app/api/connect/peppol/[...path]/route.ts b/app/api/connect/peppol/[...path]/route.ts index 5f39ae9b..51811558 100644 --- a/app/api/connect/peppol/[...path]/route.ts +++ b/app/api/connect/peppol/[...path]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' -import { z } from 'zod' +import type { z } from 'zod' +import { CONNECTOR_HEADERS, PEPPOL_OPERATIONS, peppolParticipantSchema } from '@accounted/connect-contract' import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth' import { reserveUpstream } from '@/lib/connect/hosted/upstream-budget' import { @@ -63,53 +64,19 @@ import { QVALIA_PROVIDER, createQvaliaTransport, readQvaliaConfigFromEnv } from * operation answers 403. */ -const MAX_DOCUMENT_CHARS = 5_000_000 -const COMPANY_HEADER = 'x-connector-company' +const COMPANY_HEADER = CONNECTOR_HEADERS.company.toLowerCase() const PENDING_STATE_PREFIX = 'peppol:' -const participantSchema = z.object({ - // Peppol participant scheme (ISO 6523 ICD), four digits; not a BAS account. - scheme: z.string().length(4).regex(/^\d+$/), - identifier: z.string().trim().min(1).max(64), -}) -const documentTypeSchema = z.enum(['Invoice', 'CreditNote']) - -const lookupSchema = z.object({ participant: participantSchema }) -const submissionSchema = z.object({ - idempotencyKey: z.string().trim().min(1).max(128), - tenantReference: z.string().trim().min(1).max(128), - sender: participantSchema, - recipient: participantSchema, - documentTypeId: z.string().trim().min(1).max(512), - processId: z.string().trim().min(1).max(512), - filename: z.string().trim().min(1).max(255), - contentType: z.literal('application/xml'), - document: z.string().min(1).max(MAX_DOCUMENT_CHARS), - documentSha256: z.string().regex(/^[0-9a-f]{64}$/), -}) -const submissionRefSchema = z.object({ providerSubmissionId: z.string().trim().min(1).max(128) }) -const registrationSchema = z.object({ - participant: participantSchema, - businessCard: z.object({ - companyName: z.string().trim().min(1).max(200), - countryCode: z.string().trim().length(2), - geographicalInformation: z.string().max(500).nullish(), - vatNumber: z.string().max(64).nullish(), - orgNumber: z.string().max(64).nullish(), - }), - documentTypes: z.array(z.object({ processId: z.string().min(1).max(512), documentTypeId: z.string().min(1).max(512) })).min(1).max(20), - description: z.string().max(200).nullish(), - tenantReference: z.string().max(128).nullish(), -}) -const inboundListSchema = z.object({ - documentType: documentTypeSchema, - limit: z.number().int().min(1).max(100).optional(), - includeRead: z.boolean().optional(), -}) -const inboundXmlSchema = z.object({ - providerDocumentId: z.string().trim().min(1).max(128), - documentType: documentTypeSchema, -}) +// Request shapes come from the published contract so this route and the +// instance transport (and any third-party implementation of either side) +// validate with the same schemas. +const participantSchema = peppolParticipantSchema +const lookupSchema = PEPPOL_OPERATIONS.lookup.request +const submissionSchema = PEPPOL_OPERATIONS.submit.request +const submissionRefSchema = PEPPOL_OPERATIONS.status.request +const registrationSchema = PEPPOL_OPERATIONS.register.request +const inboundListSchema = PEPPOL_OPERATIONS.inboundList.request +const inboundXmlSchema = PEPPOL_OPERATIONS.inboundXml.request function hostedTransport(): PeppolTransport | null { const config = readQvaliaConfigFromEnv() diff --git a/lib/invoices/transports/__tests__/connector.test.ts b/lib/invoices/transports/__tests__/connector.test.ts index 55962d09..2d557540 100644 --- a/lib/invoices/transports/__tests__/connector.test.ts +++ b/lib/invoices/transports/__tests__/connector.test.ts @@ -62,15 +62,24 @@ describe('connector Peppol transport', () => { }) it('polls status and evidence with the connector provider stamped on and the owning company resolved', async () => { + const event = { + provider: 'qvalia', providerTenantId: '5560000000', providerSubmissionId: 'int-1', providerEventId: 'e1', idempotencyKey: null, + eventCode: 'status_poll', normalizedStatus: 'transport_succeeded', isTerminal: false, detail: null, occurredAt: 't', + rawPayload: {}, eventSha256: 'a'.repeat(64), verificationMethod: 'provider_poll', + } + const evidence = { + provider: 'qvalia', evidenceType: 'qvalia_message_record', payload: {}, exactDocument: null, exactDocumentSha256: null, + evidenceSha256: 'b'.repeat(64), retrievedAt: 't', + } const fetchMock = vi.fn() - .mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', eventCode: 'status_poll' }])) - .mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', evidenceType: 'qvalia_message_record' }])) + .mockResolvedValueOnce(jsonResponse([event])) + .mockResolvedValueOnce(jsonResponse([evidence])) const transport = createConnectorPeppolTransport(upstream, { fetch: fetchMock as unknown as typeof fetch, companyFor: async (id) => (id === 'int-1' ? 'company-7' : null), }) - expect(await transport.pollDeliveryStatus!('int-1')).toEqual([{ provider: 'connector', eventCode: 'status_poll' }]) - expect(await transport.retrieveEvidence('int-1')).toEqual([{ provider: 'connector', evidenceType: 'qvalia_message_record' }]) + expect(await transport.pollDeliveryStatus!('int-1')).toEqual([{ ...event, provider: 'connector' }]) + expect(await transport.retrieveEvidence('int-1')).toEqual([{ ...evidence, provider: 'connector' }]) for (const call of fetchMock.mock.calls as Array<[string, RequestInit]>) { expect((call[1].headers as Record)['X-Connector-Company']).toBe('company-7') } @@ -109,3 +118,13 @@ describe('transport security', () => { await expect(transport.lookupRecipient(participant)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && e.retryable === true) }) }) + +describe('contract validation', () => { + it('rejects a hosted answer that does not match the contract as a non-retryable protocol error', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ reachable: 'maybe' })) + const transport = build(fetchMock as unknown as typeof fetch) + await expect(transport.lookupRecipient(participant)).rejects.toSatisfy( + (e: unknown) => isPeppolTransportError(e) && e.retryable === false && /unexpected response shape/.test(e.message), + ) + }) +}) diff --git a/lib/invoices/transports/connector.ts b/lib/invoices/transports/connector.ts index ee56b084..0417b901 100644 --- a/lib/invoices/transports/connector.ts +++ b/lib/invoices/transports/connector.ts @@ -1,18 +1,20 @@ +import { PEPPOL_OPERATIONS, connectorErrorSchema } from '@accounted/connect-contract' +import type { z } from 'zod' import { CONNECTOR_COMPANY_HEADER, type ConnectorUpstream } from '@/lib/connect/instance/upstreams' import { CONNECTOR_PEPPOL_PROVIDER, PeppolTransportError, type PeppolDeliveryEvidence, - type PeppolInboundListOptions, type PeppolInboundMessage, - type PeppolParticipant, type PeppolRecipientLookup, type PeppolRecipientRegistration, + type PeppolSubmissionReceipt, + type PeppolVerifiedEvent, + type PeppolInboundListOptions, + type PeppolParticipant, type PeppolRecipientRegistrationInput, type PeppolSubmission, - type PeppolSubmissionReceipt, type PeppolTransport, - type PeppolVerifiedEvent, type PeppolWebhookRequest, } from '@/lib/invoices/peppol-transport' @@ -49,13 +51,6 @@ export interface ConnectorTransportDeps { companyForParticipant?: (participant: PeppolParticipant) => Promise } -interface ConnectorErrorBody { - error?: string - code?: string - retryable?: boolean - detail?: string | null -} - async function readJson(response: Response): Promise { const text = await response.text() if (!text) return null @@ -67,14 +62,25 @@ async function readJson(response: Response): Promise { } function failureFromResponse(status: number, body: unknown): PeppolTransportError { - const parsed = (body && typeof body === 'object' ? body : {}) as ConnectorErrorBody - const code = typeof parsed.code === 'string' ? parsed.code : `HTTP_${status}` - const message = typeof parsed.error === 'string' && parsed.error ? parsed.error : `Connector answered ${status}` - const retryable = typeof parsed.retryable === 'boolean' ? parsed.retryable : status === 429 || status >= 500 - const detail = [code, typeof parsed.detail === 'string' ? parsed.detail : null].filter(Boolean).join(': ') + const parsed = connectorErrorSchema.safeParse(body) + const envelope = parsed.success ? parsed.data : null + const code = envelope?.code ?? `HTTP_${status}` + const message = envelope?.error || `Connector answered ${status}` + const retryable = typeof envelope?.retryable === 'boolean' ? envelope.retryable : status === 429 || status >= 500 + const detail = [code, envelope?.detail ?? null].filter(Boolean).join(': ') return new PeppolTransportError(`Connector: ${message}`, { retryable, detail: detail || null }) } +/** Validate a hosted answer against the contract; a shape mismatch is a protocol error, never retried. */ +function parseResponse(schema: z.ZodType, json: unknown, operation: string): T { + const parsed = schema.safeParse(json) + if (parsed.success) return parsed.data + throw new PeppolTransportError(`Connector: unexpected response shape from ${operation}`, { + retryable: false, + detail: parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')}: ${i.message}`).join('; '), + }) +} + /** * The connector key travels as a bearer token, so the hosted origin must be * https. Plain http is tolerated for loopback only (local development against @@ -104,6 +110,8 @@ export function createConnectorPeppolTransport( assertTransportSecurity(baseUrl) async function call( + operation: string, + schema: z.ZodType, method: 'POST' | 'PUT' | 'DELETE', path: string, body: unknown, @@ -130,7 +138,7 @@ export function createConnectorPeppolTransport( }) const json = await readJson(response) if (!response.ok) throw failureFromResponse(response.status, json) - return json as T + return parseResponse(schema, json, operation) } catch (error) { if (error instanceof PeppolTransportError) throw error throw new PeppolTransportError('Connector: could not reach the hosted service', { retryable: true, cause: error }) @@ -144,11 +152,11 @@ export function createConnectorPeppolTransport( } async function lookupRecipient(participant: PeppolParticipant): Promise { - return call('POST', '/lookup', { participant }) + return call('lookup', PEPPOL_OPERATIONS.lookup.response, 'POST', PEPPOL_OPERATIONS.lookup.path, { participant }) } async function submit(submission: PeppolSubmission): Promise { - const receipt = await call('POST', '/submit', submission, { + const receipt = await call('submit', PEPPOL_OPERATIONS.submit.response, 'POST', PEPPOL_OPERATIONS.submit.path, submission, { companyRef: submission.tenantReference, }) return withProvider(receipt) @@ -168,14 +176,14 @@ export function createConnectorPeppolTransport( } async function retrieveEvidence(providerSubmissionId: string): Promise { - const items = await call('POST', '/evidence', { providerSubmissionId }, { + const items = await call('evidence', PEPPOL_OPERATIONS.evidence.response, 'POST', PEPPOL_OPERATIONS.evidence.path, { providerSubmissionId }, { companyRef: await companyFor(providerSubmissionId), }) return (items ?? []).map(withProvider) } async function pollDeliveryStatus(providerSubmissionId: string): Promise { - const events = await call('POST', '/status', { providerSubmissionId }, { + const events = await call('status', PEPPOL_OPERATIONS.status.response, 'POST', PEPPOL_OPERATIONS.status.path, { providerSubmissionId }, { companyRef: await companyFor(providerSubmissionId), }) return (events ?? []).map(withProvider) @@ -187,7 +195,7 @@ export function createConnectorPeppolTransport( retryable: false, }) } - const result = await call('PUT', '/recipient', input, { + const result = await call('register', PEPPOL_OPERATIONS.register.response, 'PUT', PEPPOL_OPERATIONS.register.path, input, { companyRef: input.tenantReference, }) return { ...result, participant: input.participant } @@ -195,13 +203,13 @@ export function createConnectorPeppolTransport( async function unregisterRecipient(participant: PeppolParticipant): Promise { const query = new URLSearchParams({ scheme: participant.scheme, identifier: participant.identifier }) - await call('DELETE', `/recipient?${query.toString()}`, undefined, { + await call('unregister', PEPPOL_OPERATIONS.unregister.response, 'DELETE', `${PEPPOL_OPERATIONS.unregister.path}?${query.toString()}`, undefined, { companyRef: deps.companyForParticipant ? await deps.companyForParticipant(participant) : null, }) } async function listInboundDocuments(options: PeppolInboundListOptions): Promise { - const items = await call('POST', '/inbound/list', options) + const items = await call('inboundList', PEPPOL_OPERATIONS.inboundList.response, 'POST', PEPPOL_OPERATIONS.inboundList.path, options) return (items ?? []).map(withProvider) } @@ -209,7 +217,7 @@ export function createConnectorPeppolTransport( providerDocumentId: string, documentType: PeppolInboundListOptions['documentType'], ): Promise { - const result = await call<{ xml: string | null }>('POST', '/inbound/xml', { providerDocumentId, documentType }) + const result = await call('inboundXml', PEPPOL_OPERATIONS.inboundXml.response, 'POST', PEPPOL_OPERATIONS.inboundXml.path, { providerDocumentId, documentType }) return typeof result?.xml === 'string' && result.xml.trim().startsWith('<') ? result.xml : null }