From f31eeaa603925c0cbd37dd471b9784ecc1256dd7 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 2 Sep 2026 20:57:57 +0200 Subject: [PATCH] feat(connect): Peppol through the connector (hosted proxy, instance transport, ownership ledger) (#2177) * feat(connect): peppol connector foundation: capability, ledger/budget service, quota Adds the storage + package shape for brokering Peppol through the connector with the same one-address + rate-budget model as bank/skatteverket: a peppol capability (connector-gated, free on hosted), peppol as a ledger + upstream service, a conservative rate budget, and a migration extending the ledger service CHECK and the per-key limits (peppol_connections_per_company). Proxy route + instance-side Qvalia reroute follow. Switch-on gated on the Qvalia brokering-terms check. (cherry picked from commit 3cc0da6a3, migration renumbered 20260902190000) Signed-off-by: Jakob Wennberg Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat * feat(connect): Peppol through the connector: hosted proxy, instance transport, ownership ledger Completes the Peppol upstream for self-hosted instances on the connector (WS3): an instance with a connector key carrying the peppol scope and no Qvalia keys of its own sends and receives e-invoices through Arcim's contracted access point, the same way bank and Skatteverket already route. Hosted: app/api/connect/peppol/[...path] speaks the PeppolTransport operations (lookup, submit, status, evidence, recipient PUT/DELETE, inbound list/xml) rather than proxying Qvalia paths, because the Qvalia account is shared by every hosted company and every instance: reads must be scoped to what the caller owns, and the inbound read endpoint is destructive for the whole account. Ownership: a receiving registration is a ledger row (service peppol, participant id in account_uids, sha256 in handle_hash so one key holds a participant at a time); outbound submissions land in the new connector_peppol_submissions table and gate status/evidence; inbound documents are served from the hosted archive filtered by the participants the key holds. Per-company quota (peppol_connections_per_company), the shared PEPPOL_RECEIVING_MAX_REGISTRATIONS cap, and the global peppol rate budget apply. Provider failures cross as CONNECTOR_UPSTREAM_ERROR with the adapter's retryable flag (422 or 502). Instance: lib/invoices/transports/connector.ts implements PeppolTransport over that API and registers itself in connector mode (key present, no QVALIA_* keys); getPeppolTransportAvailability() defaults to it when no provider is selected, so an instance needs no PEPPOL_TRANSPORT_PROVIDER. Webhooks are not brokered; the existing outbound status poll covers it. Hosted is byte-identical: it has its own keys, so connector mode is never on. Docs: SELF-HOSTING.md, SOVEREIGN.md, .env.example. Switch-on for third-party instances stays gated on the Qvalia brokering-terms check; without the scope every operation answers 403. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * fix(connect): authorize Peppol participants per key, harden the proxy after review Review follow-ups on #2177. Authorization: a key may only register (and send as) participant identifiers Arcim recorded on the key at issuance (connector_keys.peppol_participants, migration 20260902191000) or the licensee's own org number, and a document may only be submitted as a sender the key has registered; X-Connector-Company stays an opaque per-company ref. Cap: the shared access-point cap now counts fresh pending reservations and is re-checked after this request's own reservation, so concurrent registrations cannot both pass. Inbound: both halves of the participant id are filtered in the archive query (over-fetched, then exact-pair checked), so foreign rows sharing an identifier cannot consume the limit. Delete: deregistration is a required transport capability, checked before the ledger row is revoked, and registration refuses an access point that cannot deregister. Instance transport: the hosted URL must be https (loopback http only, same rule as getConnectorConfig), and the response body is read inside the timeout window with body-read failures mapped to retryable transport errors. issue-connector-key.ts gains --peppol-participants and --peppol-connections-per-company. Declined: NOT VALID on the ledger CHECK (the table is empty until keys are issued; the validated scan is instant). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * fix(connect): bind Peppol ownership to the instance company, query exact participant pairs Second review round on #2177. Ownership is now (key, company_ref), not key alone: a sender must be registered under the same company header, status and evidence reads look the submission up under the header company, DELETE and re-registration refuse a participant the key holds for another company, so one company on a multi-company instance cannot act on another company's registration through the shared key. The instance transport resolves the owning company from its own peppol_deliveries / peppol_registrations rows before status, evidence and deregistration calls (deps.companyFor, deps.companyForParticipant, wired in transports/index.ts). Inbound listing stays key-wide (the instance routes documents to its own companies by its own registrations). The archive query now runs one exact-pair query per scheme (scheme fixed, that scheme's identifiers), so neither foreign nor cross-pair rows can consume the limit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> --------- Signed-off-by: Jakob Wennberg 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 --- .env.example | 5 + .../peppol/[...path]/__tests__/route.test.ts | 429 +++++++++++++ app/api/connect/peppol/[...path]/route.ts | 562 ++++++++++++++++++ docs/SELF-HOSTING.md | 4 +- docs/SOVEREIGN.md | 3 +- lib/connect/hosted/__tests__/keys.test.ts | 6 +- lib/connect/hosted/keys.ts | 3 + lib/connect/hosted/ledger.ts | 2 +- lib/connect/hosted/peppol-ledger.ts | 204 +++++++ lib/connect/hosted/upstream-budget.ts | 8 + .../instance/__tests__/upstreams.test.ts | 16 +- lib/connect/instance/upstreams.ts | 15 +- .../__tests__/capability-maps.test.ts | 2 +- lib/entitlements/keys.ts | 3 + lib/entitlements/own-credentials.ts | 6 + .../__tests__/peppol-registration.test.ts | 1 + .../__tests__/peppol-transport.test.ts | 12 + lib/invoices/peppol-registration.ts | 1 + lib/invoices/peppol-transport.ts | 19 + .../transports/__tests__/connector.test.ts | 111 ++++ .../transports/__tests__/index.test.ts | 51 ++ lib/invoices/transports/connector.ts | 228 +++++++ lib/invoices/transports/index.ts | 49 ++ scripts/issue-connector-key.ts | 26 +- .../20260902190000_connector_peppol.sql | 56 ++ ...000_connector_keys_peppol_participants.sql | 17 + tests/pg/connector-proxy-ledger.pg.test.ts | 49 +- 27 files changed, 1875 insertions(+), 13 deletions(-) create mode 100644 app/api/connect/peppol/[...path]/__tests__/route.test.ts create mode 100644 app/api/connect/peppol/[...path]/route.ts create mode 100644 lib/connect/hosted/peppol-ledger.ts create mode 100644 lib/invoices/transports/__tests__/connector.test.ts create mode 100644 lib/invoices/transports/__tests__/index.test.ts create mode 100644 lib/invoices/transports/connector.ts create mode 100644 supabase/migrations/20260902190000_connector_peppol.sql create mode 100644 supabase/migrations/20260902191000_connector_keys_peppol_participants.sql diff --git a/.env.example b/.env.example index 001af8e1..42b32f00 100644 --- a/.env.example +++ b/.env.example @@ -164,6 +164,11 @@ RECEIPT_HUNT_COMPANY_IDS= # the Authorization header (what the sandbox accepts); QVALIA_AUTH_SCHEME=apikey # switches to the "ApiKey " form from the newest docs. # PEPPOL_TRANSPORT_PROVIDER=qvalia +# Self-hosted alternative: leave every QVALIA_* variable unset and set +# GNUBOK_CONNECTOR_KEY (with the peppol scope). The instance then reaches +# Arcim's access point through the hosted connector and needs no +# PEPPOL_TRANSPORT_PROVIDER. Setting QVALIA_API_KEY or QVALIA_PARTNER_REG_NO +# switches Peppol out of connector mode. See docs/SELF-HOSTING.md. # Cap on companies that may publish a receiving identifier through the # provider account (the Qvalia partner contract is priced per tenant, 10 to # start). Unset = no cap. diff --git a/app/api/connect/peppol/[...path]/__tests__/route.test.ts b/app/api/connect/peppol/[...path]/__tests__/route.test.ts new file mode 100644 index 00000000..b19247ac --- /dev/null +++ b/app/api/connect/peppol/[...path]/__tests__/route.test.ts @@ -0,0 +1,429 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { PeppolTransportError } from '@/lib/invoices/peppol-transport' + +let currentKey = { + id: 'key-1', + orgNumber: '5561234567', + instanceUrl: 'https://bokforing.example.se', + scopes: ['peppol'], + status: 'active' as const, + currentPeriodEnd: null as string | null, + limits: { bank_connections_per_company: 1, skv_connections_per_company: 1, peppol_connections_per_company: 1, sync_min_interval_s: 0 }, +} + +const h = vi.hoisted(() => ({ + budget: vi.fn(), + ledger: { + countHeldConnections: vi.fn(), + deletePendingConnectionById: vi.fn(), + createPendingConnection: vi.fn(), + activateByPendingState: vi.fn(), + findByAccountUid: vi.fn(), + revokeByHandle: vi.fn(), + touchConnection: vi.fn(), + }, + peppolLedger: { + countConnectorPeppolRegistrations: vi.fn(), + findOwnedPeppolSubmission: vi.fn(), + getPeppolAllowedIdentifiers: vi.fn(), + isHostedPeppolParticipantLive: vi.fn(), + isPeppolParticipantHeld: vi.fn(), + listActivePeppolParticipants: vi.fn(), + recordPeppolSubmission: vi.fn(), + }, + registration: { countLivePeppolRegistrations: vi.fn(), getPeppolReceivingCap: vi.fn() }, + transport: { + provider: 'qvalia', + lookupRecipient: vi.fn(), + submit: vi.fn(), + verifyWebhook: vi.fn(), + retrieveEvidence: vi.fn(), + pollDeliveryStatus: vi.fn(), + registerRecipient: vi.fn(), + unregisterRecipient: vi.fn(), + listInboundDocuments: vi.fn(), + fetchInboundDocumentXml: vi.fn(), + }, + qvaliaConfigured: { value: true }, + archive: { rows: [] as unknown[], single: null as unknown, ins: [] as Array<[string, unknown]> }, +})) + +vi.mock('@/lib/connect/hosted/with-connector-auth', () => ({ + withConnectorAuth: (_op: string, handler: (req: Request, ctx: unknown) => Promise) => (req: Request) => + handler(req, { + requestId: 'conn_test', + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + supabase: { + from: (table: string) => { + if (table !== 'peppol_inbound_documents') throw new Error(`unexpected table ${table}`) + const chain: Record = {} + const self = () => chain + for (const m of ['select', 'eq', 'order']) chain[m] = self + chain.in = (col: string, vals: unknown) => { h.archive.ins.push([col, vals]); return chain } + chain.limit = () => Promise.resolve({ data: h.archive.rows, error: null }) + chain.maybeSingle = () => Promise.resolve({ data: h.archive.single, error: null }) + return chain + }, + }, + key: currentKey, + }), +})) +vi.mock('@/lib/connect/hosted/upstream-budget', () => ({ reserveUpstream: (...a: unknown[]) => h.budget(...a) })) +vi.mock('@/lib/connect/hosted/ledger', () => ({ ...h.ledger, hashHandle: (s: string) => `h:${s}` })) +vi.mock('@/lib/connect/hosted/peppol-ledger', async () => { + const actual = await vi.importActual('@/lib/connect/hosted/peppol-ledger') + return { + ...h.peppolLedger, + peppolHandle: actual.peppolHandle, + parsePeppolHandle: actual.parsePeppolHandle, + describePeppolUpstreamFailure: actual.describePeppolUpstreamFailure, + } +}) +vi.mock('@/lib/invoices/peppol-registration', () => h.registration) +vi.mock('@/lib/invoices/transports/qvalia', () => ({ + QVALIA_PROVIDER: 'qvalia', + readQvaliaConfigFromEnv: () => (h.qvaliaConfigured.value ? { apiKey: 'k' } : null), + createQvaliaTransport: () => h.transport, +})) + +import { POST, PUT, DELETE } from '../route' + +const participant = { scheme: '0007', identifier: '5561234567' } +const businessCard = { companyName: 'Testbolaget AB', countryCode: 'SE', orgNumber: '5561234567' } +const documentTypes = [{ processId: 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0', documentTypeId: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice' }] + +function req(method: string, path: string, body?: unknown, headers: Record = {}): Request { + return new Request(`https://app.gnubok.se/api/connect/peppol${path}`, { + method, + headers: { 'x-connector-company': 'company-1', 'content-type': 'application/json', ...headers }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + currentKey = { ...currentKey, scopes: ['peppol'], limits: { ...currentKey.limits, peppol_connections_per_company: 1 } } + h.budget.mockResolvedValue({ ok: true }) + h.qvaliaConfigured.value = true + h.archive.rows = [] + h.archive.single = null + h.archive.ins = [] + h.ledger.findByAccountUid.mockResolvedValue(null) + h.ledger.countHeldConnections.mockResolvedValue(0) + h.ledger.createPendingConnection.mockResolvedValue('pending-1') + h.ledger.activateByPendingState.mockResolvedValue({ id: 'row-1' }) + h.peppolLedger.isPeppolParticipantHeld.mockResolvedValue(false) + h.peppolLedger.isHostedPeppolParticipantLive.mockResolvedValue(false) + h.peppolLedger.countConnectorPeppolRegistrations.mockResolvedValue(0) + h.peppolLedger.getPeppolAllowedIdentifiers.mockResolvedValue(new Set(['5561234567'])) + h.peppolLedger.listActivePeppolParticipants.mockResolvedValue([participant]) + h.registration.getPeppolReceivingCap.mockReturnValue(null) + h.registration.countLivePeppolRegistrations.mockResolvedValue(0) +}) + +describe('scope, configuration and path allowlist', () => { + it('403s when the key lacks the peppol scope', async () => { + currentKey = { ...currentKey, scopes: ['bank_sync'] } + const res = await POST(req('POST', '/lookup', { participant })) + expect(res.status).toBe(403) + expect((await res.json()).code).toBe('CONNECTOR_SCOPE_MISSING') + }) + + it('503s when the hosted access point is not configured', async () => { + h.qvaliaConfigured.value = false + const res = await POST(req('POST', '/lookup', { participant })) + expect(res.status).toBe(503) + expect((await res.json()).code).toBe('CONNECTOR_UPSTREAM_UNCONFIGURED') + }) + + it('refuses unknown operations', async () => { + const res = await POST(req('POST', '/partner/123/anything', {})) + expect(res.status).toBe(403) + expect((await res.json()).code).toBe('CONNECTOR_PATH_NOT_ALLOWED') + }) + + it('400s on a malformed body', async () => { + const res = await POST(req('POST', '/lookup', { participant: { scheme: 'abc', identifier: '' } })) + expect(res.status).toBe(400) + expect(h.transport.lookupRecipient).not.toHaveBeenCalled() + }) +}) + +describe('lookup and submit', () => { + it('forwards a lookup after reserving budget', async () => { + h.transport.lookupRecipient.mockResolvedValue({ reachable: true, participant, capabilities: [], checkedAt: 'now' }) + const res = await POST(req('POST', '/lookup', { participant })) + expect(res.status).toBe(200) + expect((await res.json()).reachable).toBe(true) + expect(h.budget).toHaveBeenCalledWith(expect.anything(), 'peppol') + }) + + it('429s with Retry-After when the global budget is exhausted', async () => { + h.budget.mockResolvedValue({ ok: false, scope: 'minute', retryAfterSec: 17 }) + const res = await POST(req('POST', '/lookup', { participant })) + expect(res.status).toBe(429) + expect(res.headers.get('Retry-After')).toBe('17') + expect(h.transport.lookupRecipient).not.toHaveBeenCalled() + }) + + const submissionBody = () => ({ + idempotencyKey: 'idem-1', + tenantReference: 'company-OTHER', + sender: participant, + recipient: { scheme: '0007', identifier: '5569876543' }, + documentTypeId: documentTypes[0].documentTypeId, + processId: documentTypes[0].processId, + filename: 'inv.xml', + contentType: 'application/xml', + document: '', + documentSha256: 'a'.repeat(64), + }) + + it('refuses to send as a participant this key has not registered, or registered for another company', async () => { + h.ledger.findByAccountUid.mockResolvedValue(null) + let res = await POST(req('POST', '/submit', submissionBody())) + expect(res.status).toBe(403) + expect((await res.json()).code).toBe('CONNECTOR_PEPPOL_SENDER_NOT_REGISTERED') + h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-other', company_ref: 'company-2' }) + res = await POST(req('POST', '/submit', submissionBody())) + expect(res.status).toBe(403) + expect(h.transport.submit).not.toHaveBeenCalled() + }) + + it('submits under the header company and records ownership of the provider submission id', async () => { + h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-sender', company_ref: 'company-1' }) + h.transport.submit.mockResolvedValue({ provider: 'qvalia', providerSubmissionId: 'int-9', idempotencyKey: 'idem-1', tenantReference: 'company-1', acceptedAt: 'now' }) + const submission = { + idempotencyKey: 'idem-1', + tenantReference: 'company-OTHER', + sender: participant, + recipient: { scheme: '0007', identifier: '5569876543' }, + documentTypeId: documentTypes[0].documentTypeId, + processId: documentTypes[0].processId, + filename: 'inv.xml', + contentType: 'application/xml', + document: '', + documentSha256: 'a'.repeat(64), + } + const res = await POST(req('POST', '/submit', submission)) + expect(res.status).toBe(200) + expect(h.transport.submit).toHaveBeenCalledWith(expect.objectContaining({ tenantReference: 'company-1' })) + expect(h.peppolLedger.recordPeppolSubmission).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + keyId: 'key-1', companyRef: 'company-1', providerSubmissionId: 'int-9', idempotencyKey: 'idem-1', + })) + }) + + it('requires the company header for submit', async () => { + const res = await POST(new Request('https://app.gnubok.se/api/connect/peppol/submit', { method: 'POST', body: '{}' })) + expect(res.status).toBe(400) + expect((await res.json()).code).toBe('CONNECTOR_COMPANY_MISSING') + }) + + it('maps a non-retryable provider rejection to 422 and a retryable one to 502', async () => { + h.transport.lookupRecipient.mockRejectedValueOnce(new PeppolTransportError('rejected', { retryable: false, detail: 'bad id' })) + let res = await POST(req('POST', '/lookup', { participant })) + expect(res.status).toBe(422) + expect(await res.json()).toMatchObject({ code: 'CONNECTOR_UPSTREAM_ERROR', retryable: false, detail: 'bad id' }) + h.transport.lookupRecipient.mockRejectedValueOnce(new PeppolTransportError('down', { retryable: true })) + res = await POST(req('POST', '/lookup', { participant })) + expect(res.status).toBe(502) + expect((await res.json()).retryable).toBe(true) + }) +}) + +describe('status and evidence are ownership-gated', () => { + it('404s for a submission this key did not make, and looks it up under the header company', async () => { + h.peppolLedger.findOwnedPeppolSubmission.mockResolvedValue(null) + const res = await POST(req('POST', '/status', { providerSubmissionId: 'int-foreign' })) + expect(res.status).toBe(404) + expect((await res.json()).code).toBe('CONNECTOR_NOT_OWNED') + expect(h.peppolLedger.findOwnedPeppolSubmission).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ keyId: 'key-1', companyRef: 'company-1', providerSubmissionId: 'int-foreign' })) + expect(h.transport.pollDeliveryStatus).not.toHaveBeenCalled() + const noCompany = await POST(new Request('https://app.gnubok.se/api/connect/peppol/status', { method: 'POST', body: JSON.stringify({ providerSubmissionId: 'int-9' }) })) + expect(noCompany.status).toBe(400) + }) + + it('polls and retrieves evidence for an owned submission', async () => { + h.peppolLedger.findOwnedPeppolSubmission.mockResolvedValue({ id: 's1', company_ref: 'company-1' }) + h.transport.pollDeliveryStatus.mockResolvedValue([{ eventCode: 'status_poll' }]) + h.transport.retrieveEvidence.mockResolvedValue([{ evidenceType: 'qvalia_message_record' }]) + const status = await POST(req('POST', '/status', { providerSubmissionId: 'int-9' })) + expect(await status.json()).toEqual([{ eventCode: 'status_poll' }]) + const evidence = await POST(req('POST', '/evidence', { providerSubmissionId: 'int-9' })) + expect(await evidence.json()).toEqual([{ evidenceType: 'qvalia_message_record' }]) + }) +}) + +describe('receiving registration', () => { + const body = { participant, businessCard, documentTypes } + + it('registers a new participant: reserves quota, calls the access point, activates the ledger row', async () => { + h.transport.registerRecipient.mockResolvedValue({ status: 'registered', participant, providerAccountReference: '5560000000', raw: { secret: 'x' } }) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(200) + const json = await res.json() + expect(json).toEqual({ status: 'registered', participant, providerAccountReference: 'accounted-connector', raw: {} }) + expect(h.ledger.createPendingConnection).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ service: 'peppol', companyRef: 'company-1', provider: 'qvalia' })) + expect(h.ledger.activateByPendingState).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ handle: '0007:5561234567', accountUids: ['0007:5561234567'] })) + expect(h.transport.registerRecipient).toHaveBeenCalledWith(expect.objectContaining({ participant })) + }) + + it('refuses a participant another key already holds, before touching the access point', async () => { + h.peppolLedger.isPeppolParticipantHeld.mockResolvedValue(true) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(409) + expect((await res.json()).code).toBe('CONNECTOR_PEPPOL_PARTICIPANT_TAKEN') + expect(h.transport.registerRecipient).not.toHaveBeenCalled() + }) + + it('refuses a participant a hosted company holds', async () => { + h.peppolLedger.isHostedPeppolParticipantLive.mockResolvedValue(true) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(409) + expect(h.transport.registerRecipient).not.toHaveBeenCalled() + }) + + it('enforces the per-company quota with a reservation re-count', async () => { + h.ledger.countHeldConnections.mockResolvedValueOnce(0).mockResolvedValueOnce(2) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(403) + expect((await res.json()).code).toBe('CONNECTOR_QUOTA_EXCEEDED') + expect(h.ledger.deletePendingConnectionById).toHaveBeenCalledWith(expect.anything(), 'pending-1') + expect(h.transport.registerRecipient).not.toHaveBeenCalled() + }) + + it('shares the provider-account cap between hosted companies and connector instances', async () => { + h.registration.getPeppolReceivingCap.mockReturnValue(10) + h.registration.countLivePeppolRegistrations.mockResolvedValue(7) + h.peppolLedger.countConnectorPeppolRegistrations.mockResolvedValue(3) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(403) + expect((await res.json()).code).toBe('PEPPOL_REGISTRATION_CAP_REACHED') + expect(h.ledger.createPendingConnection).not.toHaveBeenCalled() + }) + + it('re-checks the cap after its own reservation and rolls back when a concurrent registration won', async () => { + h.registration.getPeppolReceivingCap.mockReturnValue(10) + h.registration.countLivePeppolRegistrations.mockResolvedValue(7) + h.peppolLedger.countConnectorPeppolRegistrations.mockResolvedValueOnce(2).mockResolvedValueOnce(4) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(403) + expect((await res.json()).code).toBe('PEPPOL_REGISTRATION_CAP_REACHED') + expect(h.ledger.deletePendingConnectionById).toHaveBeenCalledWith(expect.anything(), 'pending-1') + expect(h.transport.registerRecipient).not.toHaveBeenCalled() + }) + + it('refuses a participant identifier the key is not authorized to publish', async () => { + h.peppolLedger.getPeppolAllowedIdentifiers.mockResolvedValue(new Set(['5560000001'])) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(403) + expect((await res.json()).code).toBe('CONNECTOR_PEPPOL_PARTICIPANT_NOT_ALLOWED') + expect(h.peppolLedger.isPeppolParticipantHeld).not.toHaveBeenCalled() + expect(h.transport.registerRecipient).not.toHaveBeenCalled() + }) + + it('refuses to register through an access point that cannot deregister', async () => { + const original = h.transport.unregisterRecipient + ;(h.transport as { unregisterRecipient?: unknown }).unregisterRecipient = undefined + try { + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(422) + expect((await res.json()).code).toBe('PEPPOL_RECEIVING_UNSUPPORTED') + const del = await DELETE(req('DELETE', '/recipient?scheme=0007&identifier=5561234567')) + expect(del.status).toBe(404) + h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-1', company_ref: 'company-1' }) + const del2 = await DELETE(req('DELETE', '/recipient?scheme=0007&identifier=5561234567')) + expect(del2.status).toBe(422) + expect(h.ledger.revokeByHandle).not.toHaveBeenCalled() + } finally { + h.transport.unregisterRecipient = original + } + }) + + it('rolls the reservation back when the access point rejects', async () => { + h.transport.registerRecipient.mockRejectedValue(new PeppolTransportError('nope', { retryable: false })) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(422) + expect(h.ledger.deletePendingConnectionById).toHaveBeenCalledWith(expect.anything(), 'pending-1') + }) + + it('unregisters upstream and answers 409 when activation loses the participant race', async () => { + h.transport.registerRecipient.mockResolvedValue({ status: 'registered', participant, providerAccountReference: 'x', raw: {} }) + h.ledger.activateByPendingState.mockRejectedValue(new Error('duplicate key value violates unique constraint "idx_connector_connections_handle"')) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(409) + expect(h.transport.unregisterRecipient).toHaveBeenCalledWith(participant) + expect(h.ledger.deletePendingConnectionById).toHaveBeenCalledWith(expect.anything(), 'pending-1') + }) + + it('refuses to touch a participant this key holds for another company', async () => { + h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-2', company_ref: 'company-2' }) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(409) + expect(h.transport.registerRecipient).not.toHaveBeenCalled() + }) + + it('re-registers an owned participant without consuming quota', async () => { + h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-1', company_ref: 'company-1' }) + h.transport.registerRecipient.mockResolvedValue({ status: 'updated', participant, providerAccountReference: 'x', raw: {} }) + const res = await PUT(req('PUT', '/recipient', body)) + expect(res.status).toBe(200) + expect((await res.json()).status).toBe('updated') + expect(h.ledger.createPendingConnection).not.toHaveBeenCalled() + expect(h.ledger.touchConnection).toHaveBeenCalledWith(expect.anything(), 'row-1') + }) + + it('DELETE unregisters only an owned participant of the header company and revokes the ledger row', async () => { + let res = await DELETE(req('DELETE', '/recipient?scheme=0007&identifier=5561234567')) + expect(res.status).toBe(404) + h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-2', company_ref: 'company-2' }) + res = await DELETE(req('DELETE', '/recipient?scheme=0007&identifier=5561234567')) + expect(res.status).toBe(404) + expect(h.transport.unregisterRecipient).not.toHaveBeenCalled() + h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-1', company_ref: 'company-1' }) + res = await DELETE(req('DELETE', '/recipient?scheme=0007&identifier=5561234567')) + expect(res.status).toBe(204) + expect(h.transport.unregisterRecipient).toHaveBeenCalledWith(participant) + expect(h.ledger.revokeByHandle).toHaveBeenCalledWith(expect.anything(), { keyId: 'key-1', service: 'peppol', handle: '0007:5561234567' }) + }) +}) + +describe('inbound documents come from the hosted archive, scoped to owned participants', () => { + it('lists only documents addressed to a participant this key holds', async () => { + h.archive.rows = [ + { provider_document_id: 'doc-1', document_type: 'Invoice', ubl_json: { a: 1 }, received_at: 't1', recipient_scheme: '0007', recipient_identifier: '5561234567' }, + { provider_document_id: 'doc-2', document_type: 'Invoice', ubl_json: { b: 2 }, received_at: 't2', recipient_scheme: '0088', recipient_identifier: '5561234567' }, + ] + const res = await POST(req('POST', '/inbound/list', { documentType: 'Invoice', limit: 10 })) + expect(res.status).toBe(200) + const items = await res.json() + expect(items).toEqual([{ provider: 'qvalia', providerDocumentId: 'doc-1', documentType: 'Invoice', payload: { a: 1 }, receivedAt: 't1' }]) + // One query per scheme with that scheme's identifiers: exact pairs, so + // neither foreign nor cross-pair rows can consume the limit. + expect(h.archive.ins).toEqual([['recipient_identifier', ['5561234567']]]) + expect(h.transport.listInboundDocuments).not.toHaveBeenCalled() + }) + + it('answers an empty list without touching the archive when the key holds no participant', async () => { + h.peppolLedger.listActivePeppolParticipants.mockResolvedValue([]) + const res = await POST(req('POST', '/inbound/list', { documentType: 'Invoice' })) + expect(await res.json()).toEqual([]) + }) + + it('serves the archived XML for an owned document and 404s otherwise', async () => { + h.archive.single = { xml_payload: '', recipient_scheme: '0007', recipient_identifier: '5561234567' } + let res = await POST(req('POST', '/inbound/xml', { providerDocumentId: 'doc-1', documentType: 'Invoice' })) + expect(await res.json()).toEqual({ xml: '' }) + h.archive.single = { xml_payload: '', recipient_scheme: '0007', recipient_identifier: '5569999999' } + res = await POST(req('POST', '/inbound/xml', { providerDocumentId: 'doc-1', documentType: 'Invoice' })) + expect(res.status).toBe(404) + }) + + it('fetches the XML live when the archive only holds JSON', async () => { + h.archive.single = { xml_payload: null, recipient_scheme: '0007', recipient_identifier: '5561234567' } + h.transport.fetchInboundDocumentXml.mockResolvedValue('') + const res = await POST(req('POST', '/inbound/xml', { providerDocumentId: 'doc-1', documentType: 'CreditNote' })) + expect(await res.json()).toEqual({ xml: '' }) + expect(h.transport.fetchInboundDocumentXml).toHaveBeenCalledWith('doc-1', 'CreditNote') + }) +}) diff --git a/app/api/connect/peppol/[...path]/route.ts b/app/api/connect/peppol/[...path]/route.ts new file mode 100644 index 00000000..5f39ae9b --- /dev/null +++ b/app/api/connect/peppol/[...path]/route.ts @@ -0,0 +1,562 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth' +import { reserveUpstream } from '@/lib/connect/hosted/upstream-budget' +import { + activateByPendingState, + countHeldConnections, + createPendingConnection, + deletePendingConnectionById, + findByAccountUid, + revokeByHandle, + touchConnection, +} from '@/lib/connect/hosted/ledger' +import { + countConnectorPeppolRegistrations, + describePeppolUpstreamFailure, + findOwnedPeppolSubmission, + getPeppolAllowedIdentifiers, + isHostedPeppolParticipantLive, + isPeppolParticipantHeld, + listActivePeppolParticipants, + peppolHandle, + recordPeppolSubmission, +} from '@/lib/connect/hosted/peppol-ledger' +import type { PeppolInboundMessage, PeppolTransport } from '@/lib/invoices/peppol-transport' +import { countLivePeppolRegistrations, getPeppolReceivingCap } from '@/lib/invoices/peppol-registration' +import { QVALIA_PROVIDER, createQvaliaTransport, readQvaliaConfigFromEnv } from '@/lib/invoices/transports/qvalia' + +/** + * Peppol proxy for self-hosted instances (WS3, Peppol upstream). + * + * Unlike the bank proxy this is not a path passthrough: the instance speaks + * the PeppolTransport operations and the hosted side talks to Qvalia with + * Arcim's partner keys. Reasons: Qvalia URLs embed Arcim's partner and + * account numbers, the account is shared by every hosted company and every + * instance (so reads must be scoped to what the caller owns), and the + * inbound "read" endpoint is destructive (it marks documents read for the + * whole account, which the hosted inbound cron already does). + * + * Ownership model: + * - a participant may only be registered when its identifier is on the + * key's allowlist (connector_keys.peppol_participants, recorded by Arcim + * at issuance) or is the licensee's own org number: the hosted side has + * no other way to know which organisations an instance legitimately + * hosts, and X-Connector-Company is caller-supplied; + * - a receiving registration is a ledger row (service 'peppol') whose + * account_uids holds the participant id; one participant, one key; + * - a document may only be SENT as a participant the key has registered + * (the registration is the identity claim, the allowlist authorizes it); + * - registrations, submissions, status polls, evidence reads and + * deregistration are bound to the (key, company_ref) pair, so one company + * on a multi-company instance cannot act on another company's + * registration through the shared key; inbound listing is key-wide + * because the instance's inbound sync routes documents to its own + * companies by its own registrations; + * - inbound documents are served from the hosted archive + * (peppol_inbound_documents, filled by /api/peppol/inbound/cron) filtered + * by the participants this key holds, never by calling Qvalia's read + * endpoint on the instance's behalf. + * + * Switch-on for third-party instances is gated on the Qvalia brokering-terms + * check (see the migration note): without the `peppol` scope on the key every + * operation answers 403. + */ + +const MAX_DOCUMENT_CHARS = 5_000_000 +const COMPANY_HEADER = 'x-connector-company' +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, +}) + +function hostedTransport(): PeppolTransport | null { + const config = readQvaliaConfigFromEnv() + return config ? createQvaliaTransport(config) : null +} + +function pathOf(request: Request): string { + const idx = request.url.indexOf('/api/connect/peppol') + const rest = idx === -1 ? '' : request.url.slice(idx + '/api/connect/peppol'.length) + return rest.split('?')[0].replace(/\/+$/, '') || '/' +} + +function companyRef(request: Request): string | null { + return request.headers.get(COMPANY_HEADER)?.trim() || null +} + +function requireScope(ctx: ConnectorContext): NextResponse | null { + if (ctx.key.scopes.includes('peppol')) return null + return NextResponse.json( + { error: 'This connector key does not include Peppol', code: 'CONNECTOR_SCOPE_MISSING' }, + { status: 403 }, + ) +} + +async function budgetOr429(ctx: ConnectorContext): Promise { + const budget = await reserveUpstream(ctx.supabase, 'peppol') + if (budget.ok) return null + ctx.log.warn('peppol connector budget exhausted', { scope: budget.scope }) + return NextResponse.json( + { error: 'Peppol connector is busy, try again shortly', code: 'CONNECTOR_RATE_LIMITED', scope: budget.scope }, + { status: 429, headers: { 'Retry-After': String(budget.retryAfterSec) } }, + ) +} + +async function parseBody(request: Request, schema: z.ZodType): Promise<{ ok: true; value: T } | { ok: false; response: NextResponse }> { + let raw: unknown + try { + raw = await request.json() + } catch { + return { ok: false, response: NextResponse.json({ error: 'Invalid JSON', code: 'BAD_REQUEST' }, { status: 400 }) } + } + const parsed = schema.safeParse(raw) + if (!parsed.success) { + return { + ok: false, + response: NextResponse.json( + { error: 'Invalid request body', code: 'BAD_REQUEST', detail: parsed.error.issues.slice(0, 5).map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') }, + { status: 400 }, + ), + } + } + return { ok: true, value: parsed.data } +} + +/** + * A provider failure is answered with the transport's retryable flag so the + * instance rethrows an equivalent PeppolTransportError. Anything else is a + * hosted bug and falls through to the wrapper's 500. + */ +function upstreamFailure(err: unknown, ctx: ConnectorContext, op: string): NextResponse { + const failure = describePeppolUpstreamFailure(err) + if (!failure) throw err + ctx.log.warn(`peppol upstream failed: ${op}`, { text: failure.text, retryable: failure.retryable }) + return NextResponse.json( + { error: failure.text, code: 'CONNECTOR_UPSTREAM_ERROR', retryable: failure.retryable, detail: failure.hint }, + { status: failure.retryable ? 502 : 422 }, + ) +} + +function unconfigured(): NextResponse { + return NextResponse.json( + { error: 'Peppol access point is not configured on the hosted service', code: 'CONNECTOR_UPSTREAM_UNCONFIGURED', retryable: true }, + { status: 503 }, + ) +} +function notAllowed(): NextResponse { + return NextResponse.json({ error: 'Not allowed', code: 'CONNECTOR_PATH_NOT_ALLOWED' }, { status: 403 }) +} +function notOwned(): NextResponse { + return NextResponse.json({ error: 'Unknown registration or submission for this key', code: 'CONNECTOR_NOT_OWNED' }, { status: 404 }) +} +function missingCompany(): NextResponse { + return NextResponse.json({ error: 'Missing X-Connector-Company header', code: 'CONNECTOR_COMPANY_MISSING' }, { status: 400 }) +} +function participantTaken(): NextResponse { + return NextResponse.json( + { error: 'That Peppol participant is already registered through another account', code: 'CONNECTOR_PEPPOL_PARTICIPANT_TAKEN', retryable: false }, + { status: 409 }, + ) +} + +function isUniqueViolation(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err) + return /23505|idx_connector_connections_handle|duplicate key/i.test(message) +} + +export const POST = withConnectorAuth('connect.peppol', async (request, ctx) => { + const scopeError = requireScope(ctx) + if (scopeError) return scopeError + const path = pathOf(request) + const transport = hostedTransport() + if (!transport) return unconfigured() + + if (path === '/lookup') { + const body = await parseBody(request, lookupSchema) + if (!body.ok) return body.response + const blocked = await budgetOr429(ctx) + if (blocked) return blocked + try { + return NextResponse.json(await transport.lookupRecipient(body.value.participant)) + } catch (err) { + return upstreamFailure(err, ctx, 'lookup') + } + } + + if (path === '/submit') { + const cref = companyRef(request) + if (!cref) return missingCompany() + const body = await parseBody(request, submissionSchema) + if (!body.ok) return body.response + // Sending as a participant is an identity claim: only participants this + // key registered (which the allowlist authorized) may appear as sender. + const senderOwned = await findByAccountUid(ctx.supabase, { keyId: ctx.key.id, accountUid: peppolHandle(body.value.sender) }) + if (!senderOwned || senderOwned.company_ref !== cref) { + return NextResponse.json( + { error: 'The sender participant is not registered through this connector key', code: 'CONNECTOR_PEPPOL_SENDER_NOT_REGISTERED', retryable: false }, + { status: 403 }, + ) + } + const blocked = await budgetOr429(ctx) + if (blocked) return blocked + // The tenant reference the instance signs its delivery with must be the + // company the request is scoped to; otherwise a key could stage under + // one company and record ownership under another. + const submission = { ...body.value, tenantReference: cref } + let receipt + try { + receipt = await transport.submit(submission) + } catch (err) { + return upstreamFailure(err, ctx, 'submit') + } + await recordPeppolSubmission(ctx.supabase, { + keyId: ctx.key.id, + companyRef: cref, + provider: QVALIA_PROVIDER, + providerSubmissionId: receipt.providerSubmissionId, + idempotencyKey: submission.idempotencyKey, + }) + return NextResponse.json(receipt) + } + + if (path === '/status' || path === '/evidence') { + const cref = companyRef(request) + if (!cref) return missingCompany() + const body = await parseBody(request, submissionRefSchema) + if (!body.ok) return body.response + const owned = await findOwnedPeppolSubmission(ctx.supabase, { + keyId: ctx.key.id, + companyRef: cref, + provider: QVALIA_PROVIDER, + providerSubmissionId: body.value.providerSubmissionId, + }) + if (!owned) return notOwned() + const blocked = await budgetOr429(ctx) + if (blocked) return blocked + try { + if (path === '/status') { + const events = transport.pollDeliveryStatus + ? await transport.pollDeliveryStatus(body.value.providerSubmissionId) + : [] + return NextResponse.json(events) + } + return NextResponse.json(await transport.retrieveEvidence(body.value.providerSubmissionId)) + } catch (err) { + return upstreamFailure(err, ctx, path.slice(1)) + } + } + + if (path === '/inbound/list') { + const body = await parseBody(request, inboundListSchema) + if (!body.ok) return body.response + const participants = await listActivePeppolParticipants(ctx.supabase, ctx.key.id) + if (participants.length === 0) return NextResponse.json([]) + const limit = Math.min(Math.max(body.value.limit ?? 25, 1), 100) + // Exact (scheme, identifier) pairs, one query per scheme: within a scheme + // the identifier list IS the pair set, so no foreign or cross-pair row can + // consume the limit. Schemes are one or two in practice (0007, 0088). + const identifiersByScheme = new Map>() + for (const p of participants) { + const set = identifiersByScheme.get(p.scheme) ?? new Set() + set.add(p.identifier) + identifiersByScheme.set(p.scheme, set) + } + type ArchiveRow = { + provider_document_id: string + document_type: 'Invoice' | 'CreditNote' + ubl_json: Record + received_at: string | null + recipient_scheme: string | null + recipient_identifier: string | null + } + const rows: ArchiveRow[] = [] + for (const [scheme, identifiers] of identifiersByScheme) { + const { data, error } = await ctx.supabase + .from('peppol_inbound_documents') + .select('provider_document_id, document_type, ubl_json, received_at, recipient_scheme, recipient_identifier') + .eq('provider', QVALIA_PROVIDER) + .eq('document_type', body.value.documentType) + .eq('recipient_scheme', scheme) + .in('recipient_identifier', [...identifiers]) + .order('received_at', { ascending: false }) + .limit(limit) + if (error) throw new Error(`inbound archive read failed: ${error.message}`) + rows.push(...((data ?? []) as ArchiveRow[])) + } + rows.sort((a, b) => (b.received_at ?? '').localeCompare(a.received_at ?? '')) + const owned = new Set(participants.map(peppolHandle)) + const messages: PeppolInboundMessage[] = [] + for (const row of rows) { + if (!row.recipient_scheme || !row.recipient_identifier) continue + if (!owned.has(peppolHandle({ scheme: row.recipient_scheme, identifier: row.recipient_identifier }))) continue + messages.push({ + provider: QVALIA_PROVIDER, + providerDocumentId: row.provider_document_id, + documentType: row.document_type, + payload: row.ubl_json ?? {}, + receivedAt: row.received_at, + }) + if (messages.length >= limit) break + } + return NextResponse.json(messages) + } + + if (path === '/inbound/xml') { + const body = await parseBody(request, inboundXmlSchema) + if (!body.ok) return body.response + const participants = await listActivePeppolParticipants(ctx.supabase, ctx.key.id) + const owned = new Set(participants.map(peppolHandle)) + const { data, error } = await ctx.supabase + .from('peppol_inbound_documents') + .select('xml_payload, recipient_scheme, recipient_identifier') + .eq('provider', QVALIA_PROVIDER) + .eq('provider_document_id', body.value.providerDocumentId) + .eq('document_type', body.value.documentType) + .maybeSingle() + if (error) throw new Error(`inbound archive read failed: ${error.message}`) + const row = data as { xml_payload: string | null; recipient_scheme: string | null; recipient_identifier: string | null } | null + if (!row || !row.recipient_scheme || !row.recipient_identifier) return notOwned() + if (!owned.has(peppolHandle({ scheme: row.recipient_scheme, identifier: row.recipient_identifier }))) return notOwned() + if (row.xml_payload) return NextResponse.json({ xml: row.xml_payload }) + // The archive kept JSON but the XML fetch failed at cron time: retry live. + if (!transport.fetchInboundDocumentXml) return NextResponse.json({ xml: null }) + const blocked = await budgetOr429(ctx) + if (blocked) return blocked + try { + return NextResponse.json({ xml: await transport.fetchInboundDocumentXml(body.value.providerDocumentId, body.value.documentType) }) + } catch (err) { + return upstreamFailure(err, ctx, 'inbound.xml') + } + } + + return notAllowed() +}) + +export const PUT = withConnectorAuth('connect.peppol', async (request, ctx) => { + const scopeError = requireScope(ctx) + if (scopeError) return scopeError + if (pathOf(request) !== '/recipient') return notAllowed() + const cref = companyRef(request) + if (!cref) return missingCompany() + const body = await parseBody(request, registrationSchema) + if (!body.ok) return body.response + const transport = hostedTransport() + if (!transport) return unconfigured() + // Both directions are required: a registration this route cannot undo + // (rollback on a lost race, DELETE later) must never be created. + if (!transport.registerRecipient || !transport.unregisterRecipient) { + return NextResponse.json({ error: 'Receiving is not supported by the hosted access point', code: 'PEPPOL_RECEIVING_UNSUPPORTED', retryable: false }, { status: 422 }) + } + const unregisterUpstream = transport.unregisterRecipient + + const participant = { scheme: body.value.participant.scheme, identifier: body.value.participant.identifier.replace(/\s/g, '') } + const handle = peppolHandle(participant) + const owned = await findByAccountUid(ctx.supabase, { keyId: ctx.key.id, accountUid: handle }) + // Held by this key for ANOTHER company on the instance: not re-registrable + // from here, and not claimable either (it is not free). + if (owned && owned.company_ref !== cref) return participantTaken() + + let pendingId: string | null = null + let pendingState: string | null = null + if (!owned) { + const allowed = await getPeppolAllowedIdentifiers(ctx.supabase, ctx.key.id) + if (!allowed.has(participant.identifier)) { + return NextResponse.json( + { error: 'This connector key is not authorized to publish that participant', code: 'CONNECTOR_PEPPOL_PARTICIPANT_NOT_ALLOWED', retryable: false }, + { status: 403 }, + ) + } + if (await isPeppolParticipantHeld(ctx.supabase, handle)) return participantTaken() + if (await isHostedPeppolParticipantLive(ctx.supabase, { provider: QVALIA_PROVIDER, participant })) return participantTaken() + + const limit = ctx.key.limits.peppol_connections_per_company + const quotaExceeded = () => + NextResponse.json( + { error: 'Peppol registration quota reached for this company', code: 'CONNECTOR_QUOTA_EXCEEDED', limit, retryable: false }, + { status: 403 }, + ) + const held = await countHeldConnections(ctx.supabase, ctx.key.id, 'peppol', cref) + if (held >= limit) return quotaExceeded() + + // The provider account is priced per registered tenant: hosted companies + // and connector instances share that cap. Fresh pending reservations + // count, and the cap is re-checked after this request's own reservation, + // so concurrent registrations cannot both squeeze past it. + const cap = getPeppolReceivingCap() + const capReached = () => + NextResponse.json( + { error: 'The access point has no free receiving slot right now', code: 'PEPPOL_REGISTRATION_CAP_REACHED', retryable: false }, + { status: 403 }, + ) + let hostedLive = 0 + if (cap !== null) { + const [hosted, connector] = await Promise.all([ + countLivePeppolRegistrations({ supabase: ctx.supabase, provider: QVALIA_PROVIDER }), + countConnectorPeppolRegistrations(ctx.supabase), + ]) + hostedLive = hosted + if (hosted + connector >= cap) return capReached() + } + + pendingState = `${PENDING_STATE_PREFIX}${crypto.randomUUID()}` + pendingId = await createPendingConnection(ctx.supabase, { + keyId: ctx.key.id, + service: 'peppol', + companyRef: cref, + provider: QVALIA_PROVIDER, + pendingState, + }) + const heldAfter = await countHeldConnections(ctx.supabase, ctx.key.id, 'peppol', cref) + if (heldAfter > limit) { + await deletePendingConnectionById(ctx.supabase, pendingId) + return quotaExceeded() + } + if (cap !== null) { + const connectorAfter = await countConnectorPeppolRegistrations(ctx.supabase) + if (hostedLive + connectorAfter > cap) { + await deletePendingConnectionById(ctx.supabase, pendingId) + return capReached() + } + } + } + + const blocked = await budgetOr429(ctx) + if (blocked) { + if (pendingId) await deletePendingConnectionById(ctx.supabase, pendingId) + return blocked + } + + let result + try { + result = await transport.registerRecipient({ + participant, + businessCard: body.value.businessCard, + documentTypes: body.value.documentTypes, + description: body.value.description ?? null, + }) + } catch (err) { + if (pendingId) await deletePendingConnectionById(ctx.supabase, pendingId) + return upstreamFailure(err, ctx, 'register') + } + + if (owned) { + await touchConnection(ctx.supabase, owned.id) + } else { + let activated = null + let activationError: unknown = null + try { + activated = await activateByPendingState(ctx.supabase, { + keyId: ctx.key.id, + pendingState: pendingState as string, + handle, + accountUids: [handle], + }) + } catch (err) { + activationError = err + } + if (!activated) { + // Lost a race for the participant (or the row vanished): the upstream + // registration must not outlive its ledger row. + if (pendingId) await deletePendingConnectionById(ctx.supabase, pendingId) + try { + await unregisterUpstream(participant) + } catch (err) { + ctx.log.error('could not roll back upstream peppol registration; participant is registered upstream without a ledger row', err as Error, { handle }) + } + if (activationError && !isUniqueViolation(activationError)) { + ctx.log.error('peppol ledger activation failed', activationError as Error) + return NextResponse.json({ error: 'Could not record the registration', code: 'CONNECTOR_LEDGER_FAILED', retryable: true }, { status: 502 }) + } + return participantTaken() + } + } + + return NextResponse.json({ + status: result.status, + participant, + // Arcim's provider account reference stays hosted-side. + providerAccountReference: 'accounted-connector', + raw: {}, + }) +}) + +export const DELETE = withConnectorAuth('connect.peppol', async (request, ctx) => { + const scopeError = requireScope(ctx) + if (scopeError) return scopeError + if (pathOf(request) !== '/recipient') return notAllowed() + const url = new URL(request.url) + const parsed = participantSchema.safeParse({ + scheme: url.searchParams.get('scheme') ?? '', + identifier: url.searchParams.get('identifier') ?? '', + }) + if (!parsed.success) { + return NextResponse.json({ error: 'scheme and identifier query parameters are required', code: 'BAD_REQUEST' }, { status: 400 }) + } + const cref = companyRef(request) + if (!cref) return missingCompany() + const participant = { scheme: parsed.data.scheme, identifier: parsed.data.identifier.replace(/\s/g, '') } + const handle = peppolHandle(participant) + const owned = await findByAccountUid(ctx.supabase, { keyId: ctx.key.id, accountUid: handle }) + if (!owned || owned.company_ref !== cref) return notOwned() + const transport = hostedTransport() + if (!transport) return unconfigured() + if (!transport.unregisterRecipient) { + return NextResponse.json( + { error: 'Deregistration is not supported by the hosted access point', code: 'PEPPOL_RECEIVING_UNSUPPORTED', retryable: false }, + { status: 422 }, + ) + } + const blocked = await budgetOr429(ctx) + if (blocked) return blocked + try { + await transport.unregisterRecipient(participant) + } catch (err) { + return upstreamFailure(err, ctx, 'unregister') + } + // Only after the upstream deregistration took: revoking first would leave a + // participant receiving at the access point that no key owns. + await revokeByHandle(ctx.supabase, { keyId: ctx.key.id, service: 'peppol', handle }) + return new NextResponse(null, { status: 204 }) +}) diff --git a/docs/SELF-HOSTING.md b/docs/SELF-HOSTING.md index 00ad7cef..a47f50d6 100644 --- a/docs/SELF-HOSTING.md +++ b/docs/SELF-HOSTING.md @@ -339,7 +339,7 @@ Set this when you have turned public signup off in GoTrue (`disable_signup`). Th ### Connector subscription (self-hosted instances) -Everything a self-hosted instance runs itself is free (AGPL). Four capabilities depend on services only Accounted operates and are therefore gated on a self-host: bank sync (our PSD2/AISP credentials), Skatteverket API submission and skattekonto sync (our API client registration), company lookup (TIC) and migration from Fortnox/Visma/Bokio/Björn Lundén (the migration gateway). A **connector key** unlocks them for every company on the instance; it is priced per active company at parity with hosted and will be issued manually by Accounted (self-serve later); no keys are issued until the instance-side client wiring described below is complete. +Everything a self-hosted instance runs itself is free (AGPL). Five capabilities depend on services only Accounted operates and are therefore gated on a self-host: bank sync (our PSD2/AISP credentials), Skatteverket API submission and skattekonto sync (our API client registration), Peppol e-invoicing (our contracted access point), company lookup (TIC) and migration from Fortnox/Visma/Bokio/Björn Lundén (the migration gateway). A **connector key** unlocks them for every company on the instance; it is priced per active company at parity with hosted and will be issued manually by Accounted (self-serve later); no keys are issued until the instance-side client wiring described below is complete. ```bash GNUBOK_CONNECTOR_KEY=gnubok_ck_... # issued by Accounted, shown once @@ -354,6 +354,8 @@ curl -sf -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/conne The **bank** and **Skatteverket** connector proxies are live (`app.gnubok.se/api/connect/bank/*` and `/api/connect/skv/*`): with `bank_sync` / `skatteverket` in your key's scopes, the instance connects a bank through Arcim's PSD2 credentials and files VAT/AGI + syncs skattekonto through Arcim's registered Skatteverket client, while all tokens (the bank session id, the SKV BankID tokens) stay encrypted in the instance's own database. Company lookup and migration through the connector ship in following releases. The instance-side client wiring is merged for both upstreams: in connector mode (key set, no own credentials for that upstream) bank sync and Skatteverket carry traffic through the hosted proxy. Keys are not yet issued: Accounted issues none until a staging end-to-end run confirms the full flow, so a key never unlocks a granted capability whose client cannot carry traffic. On the instance, Skatteverket still needs `SKATTEVERKET_ENABLED=true` and `SKATTEVERKET_TOKEN_ENCRYPTION_KEY` (the tokens are stored there, so the encryption key is the operator's). +**Peppol** through the connector works the same way once your key carries the `peppol` scope: leave every `QVALIA_*` variable and `PEPPOL_TRANSPORT_PROVIDER` unset, and the instance sends and receives e-invoices through Arcim's contracted access point (`app.gnubok.se/api/connect/peppol/*`). The hosted side enforces one receiving registration per company (`peppol_connections_per_company` on the key), a shared cap on registrations at the access point, and ownership: an instance can only poll status, fetch evidence and receive documents for registrations and submissions made through its own key. Delivery status arrives by polling (`/api/peppol/outbound/status/cron`), not by webhook. Which participant identifiers (organisation numbers, GLNs) a key may register and send as is recorded on the key when Arcim issues it; the licensee's own organisation number is always allowed, anything else is refused with `CONNECTOR_PEPPOL_PARTICIPANT_NOT_ALLOWED`. Setting `QVALIA_API_KEY` or `QVALIA_PARTNER_REG_NO` switches Peppol out of connector mode onto your own access-point account. Brokered Peppol registers your companies under Arcim's access point, so the `peppol` scope is issued only where Arcim's provider terms allow it. + With this release the self-host image also ships the `enable-banking` and `skatteverket` extensions in its preset: without a key (or own credentials) they show the connector upsell instead of being absent, and `GET /api/connector/status` shows the operator how each upstream would be routed. #### Own credentials (no connector key) diff --git a/docs/SOVEREIGN.md b/docs/SOVEREIGN.md index 9c8a57b0..2843b6ad 100644 --- a/docs/SOVEREIGN.md +++ b/docs/SOVEREIGN.md @@ -7,7 +7,7 @@ Two honest framings up front: - **What you get is regulatory-risk elimination, not a legal verdict.** Hosted Accounted runs on Supabase and Vercel in AWS eu-north-1 (Stockholm) with AI inference on AWS Bedrock inside the EU; each of those providers operates under its own GDPR transfer mechanisms and contract terms (Data Privacy Framework participation and/or standard contractual clauses, documented in their DPAs), and whether that combination satisfies your policy is your assessment to make, not a conclusion this guide draws. What a self-host on Swedish providers removes is the *exposure*: no provider in the chain is subject to US extraterritorial law (the CLOUD Act), which is exactly the risk Sweden's national cloud policy of May 2026 names. That holds only for the chain you actually run: a sovereign deployment that keeps a US-dependent service such as Resend for outbound email has that one touchpoint left (section 6 lists them). The policy is principles for the public sector, not a mandate; it is still the document a procurement officer can point at. - **Not every Swedish accounting vendor runs on US clouds**, so do not buy this guide as a claim that "everyone else does". Buy it because you want to be able to prove, provider by provider, where your books are. -Everything here is free to run under the AGPL. Services that only Accounted can operate (bank sync through our PSD2 licence, Skatteverket API submission, company lookup, provider migration) are hosted-only today; a connector subscription for self-hosted instances is **not yet available**: the infrastructure is merged, but no keys are issued until the instance-side client wiring is complete (see "What is and is not covered" below). Manual filing of VAT and AGI declarations (file generation, you upload at Skatteverket) is always free and works on a self-host. +Everything here is free to run under the AGPL. Services that only Accounted can operate (bank sync through our PSD2 licence, Skatteverket API submission, Peppol through our contracted access point, company lookup, provider migration) are hosted-only today; a connector subscription for self-hosted instances is **not yet available**: the infrastructure is merged, but no keys are issued until the instance-side client wiring is complete (see "What is and is not covered" below). Manual filing of VAT and AGI declarations (file generation, you upload at Skatteverket) is always free and works on a self-host. ## 1. What a sovereign deployment looks like @@ -40,6 +40,7 @@ Three things carry the sovereign claim, in order of how much they matter: |---|---| | Double-entry bookkeeping, invoicing, supplier invoices, reports, SIE import/export | Bank sync via Enable Banking (runs on Accounted's PSD2/AISP credentials) | | VAT and AGI file generation for manual filing at Skatteverket | Skatteverket API submission and skattekonto sync (Accounted's API client registration) | +| Peppol BIS Billing 3 invoice generation (UBL download) | Peppol sending and receiving through the network (Accounted's contracted access point; an own Qvalia account also works) | | Document archive with SHA-256 integrity and WORM bucket | Company lookup (TIC), migration from Fortnox/Visma/Bokio/Björn Lundén via the Arcim gateway | | MCP server, API keys, staged approvals | Receipt hunt in a connected mailbox (Accounted's Google OAuth app), WhatsApp intake (Accounted's Meta credentials), Stripe billing | | AI document extraction, assistant Q&A and one-tap categorization on a BYO endpoint; HTML mail invoices | Specialized conversational flows (VAT review, KPI explanation, settings help, bokslut helpers): Anthropic-family backend only (Bedrock or the direct API), not a BYO OpenAI-compatible endpoint ([#1800](https://github.com/erp-mafia/accounted/issues/1800)) | diff --git a/lib/connect/hosted/__tests__/keys.test.ts b/lib/connect/hosted/__tests__/keys.test.ts index 5e09fb7d..48680324 100644 --- a/lib/connect/hosted/__tests__/keys.test.ts +++ b/lib/connect/hosted/__tests__/keys.test.ts @@ -15,7 +15,7 @@ const ROW = { status: 'active', current_period_end: '2027-01-01T00:00:00.000Z', rate_limited: false, - limits: { bank_connections_per_company: 2, skv_connections_per_company: 1, sync_min_interval_s: 3600 }, + limits: { bank_connections_per_company: 2, skv_connections_per_company: 1, peppol_connections_per_company: 1, sync_min_interval_s: 3600 }, } describe('connector key primitives', () => { @@ -57,7 +57,7 @@ describe('validateConnectorKey', () => { scopes: ['bank_sync', 'skatteverket'], status: 'active', currentPeriodEnd: '2027-01-01T00:00:00.000Z', - limits: { bank_connections_per_company: 2, skv_connections_per_company: 1, sync_min_interval_s: 3600 }, + limits: { bank_connections_per_company: 2, skv_connections_per_company: 1, peppol_connections_per_company: 1, sync_min_interval_s: 3600 }, }, }) }) @@ -65,7 +65,7 @@ describe('validateConnectorKey', () => { it('fills default limits when the RPC returns null limits', async () => { const { key } = generateConnectorKey() const result = await validateConnectorKey(key, supabaseWithRpc({ data: [{ ...ROW, limits: null }] }).supabase) - expect(result.ok && result.key.limits).toEqual({ bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 }) + expect(result.ok && result.key.limits).toEqual({ bank_connections_per_company: 1, skv_connections_per_company: 1, peppol_connections_per_company: 1, sync_min_interval_s: 0 }) }) it('maps no row (unknown/revoked) to 401, but an RPC error to 503', async () => { diff --git a/lib/connect/hosted/keys.ts b/lib/connect/hosted/keys.ts index 4775e55f..ddc24e83 100644 --- a/lib/connect/hosted/keys.ts +++ b/lib/connect/hosted/keys.ts @@ -28,12 +28,15 @@ export function isConnectorKeyFormat(key: string): boolean { export interface ConnectorKeyLimits { bank_connections_per_company: number skv_connections_per_company: number + /** Active Peppol receiving registrations per company ("one address"). */ + peppol_connections_per_company: number sync_min_interval_s: number } export const DEFAULT_CONNECTOR_LIMITS: ConnectorKeyLimits = { bank_connections_per_company: 1, skv_connections_per_company: 1, + peppol_connections_per_company: 1, sync_min_interval_s: 0, } diff --git a/lib/connect/hosted/ledger.ts b/lib/connect/hosted/ledger.ts index 396ccd34..34ea5ab4 100644 --- a/lib/connect/hosted/ledger.ts +++ b/lib/connect/hosted/ledger.ts @@ -9,7 +9,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' * session id, SKV access token) is hashed; the value never rests here. */ -export type ConnectorService = 'bank' | 'skatteverket' +export type ConnectorService = 'bank' | 'skatteverket' | 'peppol' export function hashHandle(handle: string): string { return crypto.createHash('sha256').update(handle).digest('hex') diff --git a/lib/connect/hosted/peppol-ledger.ts b/lib/connect/hosted/peppol-ledger.ts new file mode 100644 index 00000000..1c98b145 --- /dev/null +++ b/lib/connect/hosted/peppol-ledger.ts @@ -0,0 +1,204 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { isPeppolTransportError, type PeppolParticipant } from '@/lib/invoices/peppol-transport' +import { hashHandle } from './ledger' + +/** + * Peppol-specific reads over the connector ledger (hosted side). + * + * A Peppol "connection" is a receiving registration: one participant id + * (scheme:identifier) published under Arcim's access point on behalf of one + * company on one instance. The ledger row stores the participant id in + * `account_uids` (it is public directory data, not a secret) so inbound + * documents can be routed to the key that owns the recipient, and its sha256 + * in `handle_hash` so the partial unique index makes a participant claimable + * by exactly one key at a time. + * + * Outbound submissions are tracked in `connector_peppol_submissions`: the + * hosted Qvalia account is shared, so status polls and evidence reads must + * prove the caller submitted the document. + */ + +export function peppolHandle(participant: PeppolParticipant): string { + return `${participant.scheme}:${participant.identifier.replace(/\s/g, '')}` +} + +export function parsePeppolHandle(handle: string): PeppolParticipant | null { + const colon = handle.indexOf(':') + if (colon === -1) return null + const scheme = handle.slice(0, colon) + const identifier = handle.slice(colon + 1) + // ISO 6523 ICD scheme: four digits (not a BAS account, hence no shared schema). + if (scheme.length !== 4 || !/^\d+$/.test(scheme) || !identifier) return null + return { scheme, identifier } +} + +/** + * Every participant this key currently holds an active registration for, + * optionally narrowed to one company on the instance. Inbound routing is + * key-wide (the instance's inbound sync lists for all its companies and + * routes by its own registrations); everything else is company-bound. + */ +export async function listActivePeppolParticipants( + supabase: SupabaseClient, + keyId: string, + companyRef?: string, +): Promise { + let query = supabase + .from('connector_connections') + .select('account_uids') + .eq('connector_key_id', keyId) + .eq('service', 'peppol') + .eq('status', 'active') + if (companyRef) query = query.eq('company_ref', companyRef) + const { data, error } = await query + if (error) throw new Error(`ledger read failed: ${error.message}`) + const participants: PeppolParticipant[] = [] + for (const row of (data ?? []) as Array<{ account_uids: string[] | null }>) { + for (const uid of row.account_uids ?? []) { + const parsed = parsePeppolHandle(uid) + if (parsed) participants.push(parsed) + } + } + return participants +} + +/** Whether ANY key holds an active registration for this participant. */ +export async function isPeppolParticipantHeld(supabase: SupabaseClient, handle: string): Promise { + const { data, error } = await supabase + .from('connector_connections') + .select('id') + .eq('service', 'peppol') + .eq('status', 'active') + .eq('handle_hash', hashHandle(handle)) + .limit(1) + .maybeSingle() + if (error) throw new Error(`ledger read failed: ${error.message}`) + return !!data +} + +/** Whether a HOSTED company holds a live registration for this participant at the provider. */ +export async function isHostedPeppolParticipantLive( + supabase: SupabaseClient, + params: { provider: string; participant: PeppolParticipant }, +): Promise { + const { data, error } = await supabase + .from('peppol_registrations') + .select('id') + .eq('provider', params.provider) + .eq('participant_scheme', params.participant.scheme) + .eq('participant_identifier', params.participant.identifier.replace(/\s/g, '')) + .in('status', ['pending', 'registered']) + .limit(1) + .maybeSingle() + if (error) throw new Error(`peppol registration read failed: ${error.message}`) + return !!data +} + +/** Same reservation window as countHeldConnections in ./ledger.ts. */ +const PENDING_CAP_WINDOW_MS = 15 * 60 * 1000 + +/** + * Connector-held registrations across every key, for the provider-account + * cap: active rows plus fresh pending reservations, so two concurrent + * registrations cannot both pass the cap check and both land. + */ +export async function countConnectorPeppolRegistrations(supabase: SupabaseClient, now: Date = new Date()): Promise { + const freshPendingSince = new Date(now.getTime() - PENDING_CAP_WINDOW_MS).toISOString() + const [active, pending] = await Promise.all([ + supabase + .from('connector_connections') + .select('id', { count: 'exact', head: true }) + .eq('service', 'peppol') + .eq('status', 'active'), + supabase + .from('connector_connections') + .select('id', { count: 'exact', head: true }) + .eq('service', 'peppol') + .eq('status', 'pending') + .gte('created_at', freshPendingSince), + ]) + if (active.error) throw new Error(`ledger count failed: ${active.error.message}`) + if (pending.error) throw new Error(`ledger count failed: ${pending.error.message}`) + return (active.count ?? 0) + (pending.count ?? 0) +} + +/** + * Participant identifiers a key may publish under Arcim's access point: the + * allowlist Arcim recorded at issuance plus the licensee's own org number. + * Whitespace is stripped the same way peppolHandle() does. + */ +export async function getPeppolAllowedIdentifiers(supabase: SupabaseClient, keyId: string): Promise> { + const { data, error } = await supabase + .from('connector_keys') + .select('org_number, peppol_participants') + .eq('id', keyId) + .maybeSingle() + if (error) throw new Error(`connector key read failed: ${error.message}`) + const row = data as { org_number: string | null; peppol_participants: string[] | null } | null + const allowed = new Set() + for (const value of [row?.org_number ?? '', ...(row?.peppol_participants ?? [])]) { + const cleaned = value.replace(/\s/g, '') + if (cleaned) allowed.add(cleaned) + } + return allowed +} + +export async function recordPeppolSubmission( + supabase: SupabaseClient, + params: { keyId: string; companyRef: string; provider: string; providerSubmissionId: string; idempotencyKey: string }, +): Promise { + const { error } = await supabase + .from('connector_peppol_submissions') + .upsert( + { + connector_key_id: params.keyId, + company_ref: params.companyRef, + provider: params.provider, + provider_submission_id: params.providerSubmissionId, + idempotency_key: params.idempotencyKey, + }, + { onConflict: 'provider,provider_submission_id', ignoreDuplicates: true }, + ) + if (error) throw new Error(`submission record failed: ${error.message}`) +} + +/** The submission row for (key, company, provider submission id): both the key and the company must match. */ +export async function findOwnedPeppolSubmission( + supabase: SupabaseClient, + params: { keyId: string; companyRef: string; provider: string; providerSubmissionId: string }, +): Promise<{ id: string; company_ref: string } | null> { + const { data, error } = await supabase + .from('connector_peppol_submissions') + .select('id, company_ref') + .eq('connector_key_id', params.keyId) + .eq('company_ref', params.companyRef) + .eq('provider', params.provider) + .eq('provider_submission_id', params.providerSubmissionId) + .maybeSingle() + if (error) throw new Error(`submission read failed: ${error.message}`) + return (data as { id: string; company_ref: string } | null) ?? null +} + +export interface PeppolUpstreamFailure { + /** Short, adapter-classified text (never a raw provider body). */ + text: string + retryable: boolean + /** Adapter detail, capped; the instance surfaces it as PeppolTransportError.detail. */ + hint: string | null +} + +/** + * Summarize a transport failure for the connector response. The Qvalia + * adapter already classifies failures (network, auth, protocol, rejected, + * duplicate) into its own message text; only that classified text and its + * capped detail cross to the instance. Anything that is not a transport + * error is a hosted bug and is not summarized here (the caller rethrows). + */ +export function describePeppolUpstreamFailure(err: unknown): PeppolUpstreamFailure | null { + if (!isPeppolTransportError(err)) return null + return { + text: err.message.slice(0, 200), + retryable: err.retryable, + hint: err.detail ? err.detail.slice(0, 300) : null, + } +} diff --git a/lib/connect/hosted/upstream-budget.ts b/lib/connect/hosted/upstream-budget.ts index bee4b398..c1090b55 100644 --- a/lib/connect/hosted/upstream-budget.ts +++ b/lib/connect/hosted/upstream-budget.ts @@ -35,6 +35,14 @@ export function budgetFor(service: UpstreamService): Budget { hourMax: intFromEnv('CONNECT_BANK_RPH_BUDGET', 3000), // ~30% of EB's 10 000/h } } + if (service === 'peppol') { + // Peppol is low-volume (invoices, not polling), so a modest ceiling well + // under Qvalia's limits is plenty; tune via env if a busy byrå needs more. + return { + minuteMax: intFromEnv('CONNECT_PEPPOL_RPM_BUDGET', 60), + hourMax: intFromEnv('CONNECT_PEPPOL_RPH_BUDGET', 1000), + } + } return { minuteMax: intFromEnv('CONNECT_SKV_RPM_BUDGET', 120), hourMax: intFromEnv('CONNECT_SKV_RPH_BUDGET', 4000), diff --git a/lib/connect/instance/__tests__/upstreams.test.ts b/lib/connect/instance/__tests__/upstreams.test.ts index 1732e545..150cf9cd 100644 --- a/lib/connect/instance/__tests__/upstreams.test.ts +++ b/lib/connect/instance/__tests__/upstreams.test.ts @@ -1,12 +1,14 @@ import { describe, it, expect, afterEach, vi } from 'vitest' import { bankConnectorMode, + peppolConnectorMode, skatteverketConnectorMode, hasOwnEnableBankingCredentials, + hasOwnPeppolCredentials, hasOwnSkatteverketCredentials, } from '../upstreams' -const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'ENABLE_BANKING_PRIVATE_KEY', 'ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_APP_ID_PRODUCTION', 'SKATTEVERKET_OAUTH2_CLIENT_ID', 'SKATTEVERKET_APIGW_CLIENT_ID'] as const +const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'ENABLE_BANKING_PRIVATE_KEY', 'ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_APP_ID_PRODUCTION', 'SKATTEVERKET_OAUTH2_CLIENT_ID', 'SKATTEVERKET_APIGW_CLIENT_ID', 'QVALIA_API_KEY', 'QVALIA_PARTNER_REG_NO'] as const afterEach(() => vi.unstubAllEnvs()) function clear() { @@ -56,3 +58,15 @@ describe('connector-mode detection', () => { expect(skatteverketConnectorMode()).not.toBeNull() }) }) + +describe('peppol connector mode', () => { + it('is off without a key and off with own Qvalia keys, on otherwise', () => { + clear() + expect(peppolConnectorMode()).toBeNull() + vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x') + expect(peppolConnectorMode()).toEqual({ baseUrl: 'https://app.gnubok.se/api/connect/peppol', key: 'gnubok_ck_x' }) + vi.stubEnv('QVALIA_PARTNER_REG_NO', '5560000000') + expect(hasOwnPeppolCredentials()).toBe(true) + expect(peppolConnectorMode()).toBeNull() + }) +}) diff --git a/lib/connect/instance/upstreams.ts b/lib/connect/instance/upstreams.ts index ba862913..9c925fd3 100644 --- a/lib/connect/instance/upstreams.ts +++ b/lib/connect/instance/upstreams.ts @@ -1,6 +1,7 @@ import { getConnectorConfig } from './config' import { hasOwnEnableBankingCredentials, + hasOwnPeppolCredentials, hasOwnSkatteverketCredentials, } from '@/lib/entitlements/own-credentials' @@ -24,7 +25,7 @@ export const CONNECTOR_COMPANY_HEADER = 'X-Connector-Company' export const CONNECTOR_UPSTREAM_AUTH_HEADER = 'X-Connector-Upstream-Authorization' export const CONNECTOR_UPSTREAM_CONTENT_TYPE_HEADER = 'X-Connector-Upstream-Content-Type' -export { hasOwnEnableBankingCredentials, hasOwnSkatteverketCredentials } +export { hasOwnEnableBankingCredentials, hasOwnPeppolCredentials, hasOwnSkatteverketCredentials } export interface ConnectorUpstream { /** Base URL to send upstream requests to (the hosted proxy). */ @@ -46,3 +47,15 @@ export function skatteverketConnectorMode(): ConnectorUpstream | null { if (!cfg) return null return { baseUrl: `${cfg.baseUrl}/api/connect/skv`, key: cfg.key } } + +/** + * Peppol through Arcim's contracted access point. Same rule as the other + * upstreams: an instance with its own Qvalia partner keys runs Peppol itself + * and is never routed here. + */ +export function peppolConnectorMode(): ConnectorUpstream | null { + if (hasOwnPeppolCredentials()) return null + const cfg = getConnectorConfig() + if (!cfg) return null + return { baseUrl: `${cfg.baseUrl}/api/connect/peppol`, key: cfg.key } +} diff --git a/lib/entitlements/__tests__/capability-maps.test.ts b/lib/entitlements/__tests__/capability-maps.test.ts index 3008a1ca..ae68170c 100644 --- a/lib/entitlements/__tests__/capability-maps.test.ts +++ b/lib/entitlements/__tests__/capability-maps.test.ts @@ -91,7 +91,7 @@ describe('CONNECTOR_CAPABILITIES', () => { const { CAPABILITY, CONNECTOR_CAPABILITIES, PAID_CAPABILITIES, isConnectorCapability } = await import('../keys') const all = new Set(Object.values(CAPABILITY)) for (const key of CONNECTOR_CAPABILITIES) expect(all.has(key), key).toBe(true) - expect(CONNECTOR_CAPABILITIES).toEqual(['bank_sync', 'skatteverket', 'org_lookup', 'migration']) + expect(CONNECTOR_CAPABILITIES).toEqual(['bank_sync', 'skatteverket', 'org_lookup', 'migration', 'peppol']) // org_lookup and migration stay free on hosted (not PAID) but still need // Accounted's services, hence connector-gated on a self-host. expect(PAID_CAPABILITIES).not.toContain('org_lookup') diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts index 3c4f520b..392e1cd6 100644 --- a/lib/entitlements/keys.ts +++ b/lib/entitlements/keys.ts @@ -51,6 +51,8 @@ export const CAPABILITY = { * mail keeps leaving from the platform sender. */ custom_sender_domain: 'custom_sender_domain', + /** Peppol e-invoicing (send/receive via a Peppol Access Point). Free on hosted (Arcim's own AP); on self-host brokered through the connector: Arcim's Qvalia AP with a per-key one-address + volume quota. */ + peppol: 'peppol', } as const export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY] @@ -105,6 +107,7 @@ export const CONNECTOR_CAPABILITIES: readonly CapabilityKey[] = [ CAPABILITY.skatteverket, CAPABILITY.org_lookup, CAPABILITY.migration, + CAPABILITY.peppol, ] as const export function isConnectorCapability(key: CapabilityKey): boolean { diff --git a/lib/entitlements/own-credentials.ts b/lib/entitlements/own-credentials.ts index 42039ad0..d2f8f018 100644 --- a/lib/entitlements/own-credentials.ts +++ b/lib/entitlements/own-credentials.ts @@ -33,6 +33,11 @@ export function hasOwnSkatteverketCredentials(): boolean { return !!(process.env.SKATTEVERKET_OAUTH2_CLIENT_ID || process.env.SKATTEVERKET_APIGW_CLIENT_ID) } +/** True when the instance would use its own Peppol access point (Qvalia partner keys). */ +export function hasOwnPeppolCredentials(): boolean { + return !!(process.env.QVALIA_API_KEY || process.env.QVALIA_PARTNER_REG_NO) +} + /** * Whether this instance provides the given connector capability from its own * credentials. org_lookup and migration have no own-credentials form: they @@ -41,5 +46,6 @@ export function hasOwnSkatteverketCredentials(): boolean { export function hasOwnCredentialsFor(key: CapabilityKey): boolean { if (key === CAPABILITY.bank_sync) return hasOwnEnableBankingCredentials() if (key === CAPABILITY.skatteverket) return hasOwnSkatteverketCredentials() + if (key === CAPABILITY.peppol) return hasOwnPeppolCredentials() return false } diff --git a/lib/invoices/__tests__/peppol-registration.test.ts b/lib/invoices/__tests__/peppol-registration.test.ts index ce5a7c68..ccd37af0 100644 --- a/lib/invoices/__tests__/peppol-registration.test.ts +++ b/lib/invoices/__tests__/peppol-registration.test.ts @@ -113,6 +113,7 @@ describe('registerCompanyForPeppolReceiving', () => { participant: { scheme: '0007', identifier: '5595386219' }, businessCard: expect.objectContaining({ companyName: 'Arcim Technology AB', orgNumber: '5595386219' }), documentTypes: PEPPOL_RECEIVING_DOCUMENT_TYPES, + tenantReference: 'company-1', }) const inserted = calls.find((c) => c.method === 'insert') expect(inserted?.args[0]).toMatchObject({ status: 'pending', participant_identifier: '5595386219', company_id: 'company-1' }) diff --git a/lib/invoices/__tests__/peppol-transport.test.ts b/lib/invoices/__tests__/peppol-transport.test.ts index df67cbc3..5e2bc8f9 100644 --- a/lib/invoices/__tests__/peppol-transport.test.ts +++ b/lib/invoices/__tests__/peppol-transport.test.ts @@ -36,6 +36,18 @@ describe('Peppol transport registry', () => { } }) + it('defaults to the connector transport when that is the only registered adapter and no provider is selected', () => { + cleanups.push(registerPeppolTransport(makeTransport('connector'))) + expect(getPeppolTransportAvailability()).toEqual({ available: true, provider: 'connector' }) + // An explicit selection still wins, and still refuses an absent adapter. + process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia' + expect(getPeppolTransportAvailability()).toEqual({ + available: false, + provider: null, + reason: 'provider_adapter_unavailable', + }) + }) + it('stays truthfully unavailable until a provider is selected', () => { expect(getPeppolTransportAvailability()).toEqual({ available: false, diff --git a/lib/invoices/peppol-registration.ts b/lib/invoices/peppol-registration.ts index 169f577e..5d1b2991 100644 --- a/lib/invoices/peppol-registration.ts +++ b/lib/invoices/peppol-registration.ts @@ -205,6 +205,7 @@ export async function registerCompanyForPeppolReceiving(args: { participant: prepared.participant, businessCard: prepared.businessCard, documentTypes: PEPPOL_RECEIVING_DOCUMENT_TYPES, + tenantReference: companyId, }) const { data, error } = await service .from('peppol_registrations') diff --git a/lib/invoices/peppol-transport.ts b/lib/invoices/peppol-transport.ts index d03c82b9..efafd14d 100644 --- a/lib/invoices/peppol-transport.ts +++ b/lib/invoices/peppol-transport.ts @@ -118,6 +118,12 @@ export interface PeppolRecipientRegistrationInput { businessCard: PeppolBusinessCard documentTypes: PeppolDocumentTypeRegistration[] description?: string | null + /** + * The registering tenant (company id). Providers that hold one account per + * installation ignore it; the connector transport needs it because the + * hosted access point enforces a per-company registration quota. + */ + tenantReference?: string | null } export interface PeppolRecipientRegistration { @@ -196,6 +202,13 @@ export interface PeppolTransport { pollDeliveryStatus?(providerSubmissionId: string): Promise } +/** + * Provider id of the instance-side transport that reaches Arcim's access + * point through the hosted connector (lib/invoices/transports/connector.ts). + * Declared here so availability resolution needs no import of that module. + */ +export const CONNECTOR_PEPPOL_PROVIDER = 'connector' + const transports = new Map() export function registerPeppolTransport(transport: PeppolTransport): () => void { @@ -226,6 +239,12 @@ export type PeppolTransportAvailability = export function getPeppolTransportAvailability(): PeppolTransportAvailability { const configuredProvider = process.env.PEPPOL_TRANSPORT_PROVIDER?.trim().toLowerCase() if (!configuredProvider) { + // A self-hosted instance in connector mode has exactly one possible + // provider, so it needs no PEPPOL_TRANSPORT_PROVIDER. Hosted never + // registers the connector transport, so this branch is inert there. + if (transports.has(CONNECTOR_PEPPOL_PROVIDER)) { + return { available: true, provider: CONNECTOR_PEPPOL_PROVIDER } + } return { available: false, provider: null, reason: 'provider_selection_required' } } diff --git a/lib/invoices/transports/__tests__/connector.test.ts b/lib/invoices/transports/__tests__/connector.test.ts new file mode 100644 index 00000000..55962d09 --- /dev/null +++ b/lib/invoices/transports/__tests__/connector.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi } from 'vitest' +import { createConnectorPeppolTransport, CONNECTOR_PROVIDER } from '../connector' +import { isPeppolTransportError } from '@/lib/invoices/peppol-transport' + +const upstream = { baseUrl: 'https://app.gnubok.se/api/connect/peppol', key: 'gnubok_ck_test' } +const participant = { scheme: '0007', identifier: '5561234567' } + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} + +function build(fetchImpl: typeof fetch) { + return createConnectorPeppolTransport(upstream, { fetch: fetchImpl }) +} + +describe('connector Peppol transport', () => { + it('identifies as the connector provider and rewrites provider on everything it returns', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ provider: 'qvalia', providerSubmissionId: 'int-1', idempotencyKey: 'k', tenantReference: 'c1', acceptedAt: 't' })) + const transport = build(fetchMock as unknown as typeof fetch) + expect(transport.provider).toBe(CONNECTOR_PROVIDER) + const receipt = await transport.submit({ + idempotencyKey: 'k', tenantReference: 'c1', sender: participant, recipient: participant, + documentTypeId: 'd', processId: 'p', filename: 'f.xml', contentType: 'application/xml', document: '', documentSha256: 'a'.repeat(64), + }) + expect(receipt.provider).toBe('connector') + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://app.gnubok.se/api/connect/peppol/submit') + expect(init.method).toBe('POST') + expect(init.redirect).toBe('error') + const headers = init.headers as Record + expect(headers.Authorization).toBe('Bearer gnubok_ck_test') + expect(headers['X-Connector-Company']).toBe('c1') + }) + + it('sends the tenant as the company header on registration and refuses without one', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ status: 'registered', participant, providerAccountReference: 'accounted-connector', raw: {} })) + const transport = build(fetchMock as unknown as typeof fetch) + const input = { participant, businessCard: { companyName: 'AB', countryCode: 'SE' }, documentTypes: [{ processId: 'p', documentTypeId: 'd' }] } + await expect(transport.registerRecipient!(input)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && !e.retryable) + const result = await transport.registerRecipient!({ ...input, tenantReference: 'company-1' }) + expect(result.participant).toEqual(participant) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://app.gnubok.se/api/connect/peppol/recipient') + expect(init.method).toBe('PUT') + expect((init.headers as Record)['X-Connector-Company']).toBe('company-1') + }) + + it('unregisters through query parameters and lists inbound via the archive operation', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', providerDocumentId: 'doc-1', documentType: 'Invoice', payload: {}, receivedAt: null }])) + .mockResolvedValueOnce(jsonResponse({ xml: '' })) + .mockResolvedValueOnce(jsonResponse({ xml: null })) + const transport = build(fetchMock as unknown as typeof fetch) + await transport.unregisterRecipient!(participant) + expect((fetchMock.mock.calls[0] as [string])[0]).toBe('https://app.gnubok.se/api/connect/peppol/recipient?scheme=0007&identifier=5561234567') + expect((fetchMock.mock.calls[0] as [string, RequestInit])[1].method).toBe('DELETE') + const inbound = await transport.listInboundDocuments!({ documentType: 'Invoice', limit: 5 }) + expect(inbound).toEqual([{ provider: 'connector', providerDocumentId: 'doc-1', documentType: 'Invoice', payload: {}, receivedAt: null }]) + expect(await transport.fetchInboundDocumentXml!('doc-1', 'Invoice')).toBe('') + expect(await transport.fetchInboundDocumentXml!('doc-1', 'Invoice')).toBeNull() + }) + + it('polls status and evidence with the connector provider stamped on and the owning company resolved', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', eventCode: 'status_poll' }])) + .mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', evidenceType: 'qvalia_message_record' }])) + 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' }]) + for (const call of fetchMock.mock.calls as Array<[string, RequestInit]>) { + expect((call[1].headers as Record)['X-Connector-Company']).toBe('company-7') + } + }) + + it('turns hosted refusals into PeppolTransportErrors carrying the retryable flag and code', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ error: 'quota', code: 'CONNECTOR_QUOTA_EXCEEDED', retryable: false }, 403)) + .mockResolvedValueOnce(jsonResponse({ error: 'busy', code: 'CONNECTOR_RATE_LIMITED' }, 429)) + .mockRejectedValueOnce(new TypeError('fetch failed')) + const transport = build(fetchMock as unknown as typeof fetch) + await expect(transport.lookupRecipient(participant)).rejects.toSatisfy( + (e: unknown) => isPeppolTransportError(e) && e.retryable === false && /CONNECTOR_QUOTA_EXCEEDED/.test(e.detail ?? ''), + ) + await expect(transport.lookupRecipient(participant)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && e.retryable === true) + await expect(transport.lookupRecipient(participant)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && e.retryable === true) + }) + + it('does not verify webhooks: the hosted service owns them', async () => { + const transport = build(vi.fn() as unknown as typeof fetch) + await expect(transport.verifyWebhook({ headers: new Headers(), rawBody: new Uint8Array() })).rejects.toSatisfy( + (e: unknown) => isPeppolTransportError(e) && e.retryable === false, + ) + }) +}) + +describe('transport security', () => { + it('refuses a plain-http hosted URL except for loopback', () => { + expect(() => createConnectorPeppolTransport({ baseUrl: 'http://connect.example.se/api/connect/peppol', key: 'k' })).toThrow(/https/) + expect(() => createConnectorPeppolTransport({ baseUrl: 'http://localhost:3000/api/connect/peppol', key: 'k' })).not.toThrow() + }) + + it('maps a stalled or failing body read to a retryable transport error', async () => { + const stalled = { ok: true, status: 200, text: () => Promise.reject(new Error('body stalled')) } as unknown as Response + const transport = build(vi.fn().mockResolvedValue(stalled) as unknown as typeof fetch) + await expect(transport.lookupRecipient(participant)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && e.retryable === true) + }) +}) diff --git a/lib/invoices/transports/__tests__/index.test.ts b/lib/invoices/transports/__tests__/index.test.ts new file mode 100644 index 00000000..de94e0c2 --- /dev/null +++ b/lib/invoices/transports/__tests__/index.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * registerConfiguredPeppolTransports() wires the connector transport only on + * a self-hosted instance in connector mode (connector key, no own Qvalia + * keys). Hosted keeps its own keys, so nothing changes there. The registry is + * module state, so every case gets a fresh module graph. + */ + +const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'QVALIA_API_KEY', 'QVALIA_PARTNER_REG_NO', 'QVALIA_BASE_URL', 'PEPPOL_TRANSPORT_PROVIDER'] as const + +beforeEach(() => { + vi.resetModules() + for (const key of ENV) vi.stubEnv(key, '') +}) +afterEach(() => vi.unstubAllEnvs()) + +async function load() { + const registry = await import('@/lib/invoices/peppol-transport') + const transports = await import('@/lib/invoices/transports') + return { ...registry, ...transports } +} + +describe('registerConfiguredPeppolTransports in connector mode', () => { + it('registers nothing without keys of any kind', async () => { + const m = await load() + expect(m.registerConfiguredPeppolTransports({}).map((t) => t.provider)).toEqual([]) + expect(m.getPeppolTransportAvailability().available).toBe(false) + }) + + it('registers the connector transport when a connector key is set and no Qvalia keys are', async () => { + vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_test') + const m = await load() + expect(m.registerConfiguredPeppolTransports({}).map((t) => t.provider)).toEqual(['connector']) + expect(m.getPeppolTransportAvailability()).toEqual({ available: true, provider: 'connector' }) + // Idempotent: a second call registers nothing new. + expect(m.registerConfiguredPeppolTransports({})).toEqual([]) + }) + + it('prefers own Qvalia keys over the connector (hosted, or a self-host with its own access point)', async () => { + vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_test') + const env = { QVALIA_API_KEY: 'k', QVALIA_PARTNER_REG_NO: '5560000000', QVALIA_BASE_URL: 'https://api-test.qvalia.com' } + vi.stubEnv('QVALIA_API_KEY', env.QVALIA_API_KEY) + vi.stubEnv('QVALIA_PARTNER_REG_NO', env.QVALIA_PARTNER_REG_NO) + const m = await load() + expect(m.registerConfiguredPeppolTransports(env).map((t) => t.provider)).toEqual(['qvalia']) + expect(m.getPeppolTransport('connector')).toBeNull() + // Own keys still need the explicit provider selection, exactly as before. + expect(m.getPeppolTransportAvailability()).toEqual({ available: false, provider: null, reason: 'provider_selection_required' }) + }) +}) diff --git a/lib/invoices/transports/connector.ts b/lib/invoices/transports/connector.ts new file mode 100644 index 00000000..ee56b084 --- /dev/null +++ b/lib/invoices/transports/connector.ts @@ -0,0 +1,228 @@ +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 PeppolRecipientRegistrationInput, + type PeppolSubmission, + type PeppolSubmissionReceipt, + type PeppolTransport, + type PeppolVerifiedEvent, + type PeppolWebhookRequest, +} from '@/lib/invoices/peppol-transport' + +/** + * Instance-side Peppol transport for connector mode (WS3). + * + * A self-hosted instance with a connector key and no Qvalia keys of its own + * reaches Arcim's contracted access point through the hosted proxy + * (`app/api/connect/peppol/*`). The proxy speaks the PeppolTransport + * operations, not Qvalia paths, so the instance never learns Arcim's partner + * or account numbers and the hosted side can enforce ownership: a key can + * only poll, fetch evidence for, or receive documents belonging to + * participants and submissions it registered itself. + * + * Webhooks are not brokered: Qvalia posts to the hosted webhook, which only + * knows hosted deliveries. The instance learns outbound status by polling + * (`pollDeliveryStatus`, already driven by /api/peppol/outbound/status/cron). + */ + +export const CONNECTOR_PROVIDER = CONNECTOR_PEPPOL_PROVIDER + +const FETCH_TIMEOUT_MS = 60_000 + +export interface ConnectorTransportDeps { + fetch?: typeof fetch + /** + * Resolve the instance company that made a submission (from + * peppol_deliveries) so status and evidence reads carry the company the + * hosted side bound the submission to. Optional for tests; the production + * factory in transports/index.ts wires it to the instance database. + */ + companyFor?: (providerSubmissionId: string) => Promise + /** Same for a receiving registration (from peppol_registrations). */ + 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 + try { + return JSON.parse(text) + } catch { + return { error: text.slice(0, 500) } + } +} + +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(': ') + return new PeppolTransportError(`Connector: ${message}`, { retryable, detail: detail || null }) +} + +/** + * 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 + * a hosted dev server), the same rule getConnectorConfig() applies when it + * reads GNUBOK_CONNECT_URL. + */ +function assertTransportSecurity(baseUrl: string): void { + let url: URL + try { + url = new URL(baseUrl) + } catch { + throw new PeppolTransportError('Connector: invalid hosted URL', { retryable: false }) + } + const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' + if (url.protocol === 'https:' || (url.protocol === 'http:' && loopback)) return + throw new PeppolTransportError('Connector: the hosted URL must be https (the connector key is a bearer token)', { + retryable: false, + }) +} + +export function createConnectorPeppolTransport( + upstream: ConnectorUpstream, + deps: ConnectorTransportDeps = {}, +): PeppolTransport { + const fetchImpl = deps.fetch ?? globalThis.fetch + const baseUrl = upstream.baseUrl.replace(/\/+$/, '') + assertTransportSecurity(baseUrl) + + async function call( + method: 'POST' | 'PUT' | 'DELETE', + path: string, + body: unknown, + options: { companyRef?: string | null } = {}, + ): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + // The body is read INSIDE the timeout window: a response whose headers + // arrive and whose body then stalls must not hold the caller forever, + // and a body-read failure is a transport failure like any other. + try { + const response = await fetchImpl(`${baseUrl}${path}`, { + method, + signal: controller.signal, + redirect: 'error', + cache: 'no-store', + headers: { + Authorization: `Bearer ${upstream.key}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(options.companyRef ? { [CONNECTOR_COMPANY_HEADER]: options.companyRef } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const json = await readJson(response) + if (!response.ok) throw failureFromResponse(response.status, json) + return json as T + } catch (error) { + if (error instanceof PeppolTransportError) throw error + throw new PeppolTransportError('Connector: could not reach the hosted service', { retryable: true, cause: error }) + } finally { + clearTimeout(timeout) + } + } + + function withProvider(value: T): T { + return { ...value, provider: CONNECTOR_PROVIDER } + } + + async function lookupRecipient(participant: PeppolParticipant): Promise { + return call('POST', '/lookup', { participant }) + } + + async function submit(submission: PeppolSubmission): Promise { + const receipt = await call('POST', '/submit', submission, { + companyRef: submission.tenantReference, + }) + return withProvider(receipt) + } + + async function verifyWebhook(_webhook: PeppolWebhookRequest): Promise { + throw new PeppolTransportError('Connector: delivery webhooks are handled by the hosted service; poll instead', { + retryable: false, + }) + } + + // The PeppolTransport read methods carry no tenant, but the hosted side + // binds submissions to (key, company). The instance resolves the company + // from its own peppol_deliveries row before polling, via deps.companyFor. + async function companyFor(providerSubmissionId: string): Promise { + return deps.companyFor ? deps.companyFor(providerSubmissionId) : null + } + + async function retrieveEvidence(providerSubmissionId: string): Promise { + const items = await call('POST', '/evidence', { providerSubmissionId }, { + companyRef: await companyFor(providerSubmissionId), + }) + return (items ?? []).map(withProvider) + } + + async function pollDeliveryStatus(providerSubmissionId: string): Promise { + const events = await call('POST', '/status', { providerSubmissionId }, { + companyRef: await companyFor(providerSubmissionId), + }) + return (events ?? []).map(withProvider) + } + + async function registerRecipient(input: PeppolRecipientRegistrationInput): Promise { + if (!input.tenantReference) { + throw new PeppolTransportError('Connector: a tenant reference is required to register a recipient', { + retryable: false, + }) + } + const result = await call('PUT', '/recipient', input, { + companyRef: input.tenantReference, + }) + return { ...result, participant: input.participant } + } + + async function unregisterRecipient(participant: PeppolParticipant): Promise { + const query = new URLSearchParams({ scheme: participant.scheme, identifier: participant.identifier }) + await call('DELETE', `/recipient?${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) + return (items ?? []).map(withProvider) + } + + async function fetchInboundDocumentXml( + providerDocumentId: string, + documentType: PeppolInboundListOptions['documentType'], + ): Promise { + const result = await call<{ xml: string | null }>('POST', '/inbound/xml', { providerDocumentId, documentType }) + return typeof result?.xml === 'string' && result.xml.trim().startsWith('<') ? result.xml : null + } + + return { + provider: CONNECTOR_PROVIDER, + lookupRecipient, + submit, + verifyWebhook, + retrieveEvidence, + pollDeliveryStatus, + registerRecipient, + unregisterRecipient, + listInboundDocuments, + fetchInboundDocumentXml, + } +} diff --git a/lib/invoices/transports/index.ts b/lib/invoices/transports/index.ts index b62cb040..8d98d559 100644 --- a/lib/invoices/transports/index.ts +++ b/lib/invoices/transports/index.ts @@ -5,11 +5,45 @@ * configured (for the probe script, for a preview) without being switched on. */ +import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { peppolConnectorMode } from '@/lib/connect/instance/upstreams' import { + CONNECTOR_PEPPOL_PROVIDER, getPeppolTransport, registerPeppolTransport, + type PeppolParticipant, type PeppolTransport, } from '@/lib/invoices/peppol-transport' +import { createConnectorPeppolTransport } from '@/lib/invoices/transports/connector' + +/** + * The hosted connector binds submissions and registrations to the instance + * company that made them. PeppolTransport's read methods carry no tenant, so + * the instance looks the company up in its own tables before each call. + */ +async function connectorCompanyForSubmission(providerSubmissionId: string): Promise { + const { data } = await createServiceClientNoCookies() + .from('peppol_deliveries') + .select('company_id') + .eq('provider', CONNECTOR_PEPPOL_PROVIDER) + .eq('provider_submission_id', providerSubmissionId) + .limit(1) + .maybeSingle() + return (data as { company_id: string } | null)?.company_id ?? null +} + +async function connectorCompanyForParticipant(participant: PeppolParticipant): Promise { + const { data } = await createServiceClientNoCookies() + .from('peppol_registrations') + .select('company_id') + .eq('provider', CONNECTOR_PEPPOL_PROVIDER) + .eq('participant_scheme', participant.scheme) + .eq('participant_identifier', participant.identifier.replace(/\s/g, '')) + .in('status', ['pending', 'registered']) + .limit(1) + .maybeSingle() + return (data as { company_id: string } | null)?.company_id ?? null +} import { QVALIA_PROVIDER, createQvaliaTransport, @@ -30,5 +64,20 @@ export function registerConfiguredPeppolTransports( } } + // Self-hosted instance in connector mode (connector key, no own Qvalia + // keys): reach Arcim's access point through the hosted proxy. Hosted has + // its own keys, so peppolConnectorMode() is null there and nothing changes. + if (!getPeppolTransport(CONNECTOR_PEPPOL_PROVIDER)) { + const connector = peppolConnectorMode() + if (connector) { + const transport = createConnectorPeppolTransport(connector, { + companyFor: connectorCompanyForSubmission, + companyForParticipant: connectorCompanyForParticipant, + }) + registerPeppolTransport(transport) + registered.push(transport) + } + } + return registered } diff --git a/scripts/issue-connector-key.ts b/scripts/issue-connector-key.ts index 1ae8ed0c..e4616223 100644 --- a/scripts/issue-connector-key.ts +++ b/scripts/issue-connector-key.ts @@ -9,7 +9,13 @@ * Usage: * npx tsx scripts/issue-connector-key.ts --org 5561234567 --name "Byrå AB" \ * --instance https://bokforing.byra.se [--months 12] \ - * [--scopes bank_sync,skatteverket,org_lookup,migration] [--notes "..."] --confirm + * [--scopes bank_sync,skatteverket,org_lookup,migration,peppol] \ + * [--peppol-participants 5561234567,7350000000000] [--notes "..."] --confirm + * + * --peppol-participants lists the participant identifiers (org numbers, GLNs) + * the licensee may register and send as through Arcim's Peppol access point; + * the --org number is always allowed. Only issue the peppol scope where the + * Qvalia brokering terms permit it. * * Reads .env.local (which points at PRODUCTION in this repo: the script * refuses to write without --confirm and prints the target host first). @@ -41,6 +47,11 @@ async function main(): Promise { const scopes = (arg('scopes') ?? 'bank_sync,skatteverket').split(',').map((s) => s.trim()).filter(Boolean) const bankPerCompany = Number(arg('bank-connections-per-company') ?? '1') const skvPerCompany = Number(arg('skv-connections-per-company') ?? '1') + const peppolPerCompany = Number(arg('peppol-connections-per-company') ?? '1') + const peppolParticipants = (arg('peppol-participants') ?? '') + .split(',') + .map((v) => v.replace(/\s/g, '')) + .filter(Boolean) const syncMinInterval = Number(arg('sync-min-interval') ?? '0') const notes = arg('notes') ?? null @@ -56,9 +67,15 @@ async function main(): Promise { if (!Number.isInteger(months) || months <= 0 || months > 120) problems.push('--months must be an integer 1..120') const unknown = scopes.filter((s) => !(CONNECTOR_CAPABILITIES as readonly string[]).includes(s)) if (unknown.length) problems.push(`unknown scopes: ${unknown.join(', ')} (allowed: ${CONNECTOR_CAPABILITIES.join(', ')})`) - for (const [name, v] of [['bank-connections-per-company', bankPerCompany], ['skv-connections-per-company', skvPerCompany]] as const) { + for (const [name, v] of [ + ['bank-connections-per-company', bankPerCompany], + ['skv-connections-per-company', skvPerCompany], + ['peppol-connections-per-company', peppolPerCompany], + ] as const) { if (!Number.isFinite(v) || v < 0 || v > 100) problems.push(`--${name} must be 0..100`) } + const badParticipants = peppolParticipants.filter((v) => !/^[0-9]{6,20}$/.test(v)) + if (badParticipants.length) problems.push(`--peppol-participants must be digit-only identifiers: ${badParticipants.join(', ')}`) if (!Number.isFinite(syncMinInterval) || syncMinInterval < 0) problems.push('--sync-min-interval must be >= 0 seconds') if (problems.length) { console.error(problems.map((p) => ` x ${p}`).join('\n')) @@ -78,7 +95,8 @@ async function main(): Promise { console.log(`Licensee: ${name} (${org})`) console.log(`Instance: ${new URL(instance).origin}`) console.log(`Scopes: ${scopes.join(', ')}`) - console.log(`Limits: ${bankPerCompany} bank + ${skvPerCompany} SKV connection(s)/company, min sync interval ${syncMinInterval}s`) + console.log(`Limits: ${bankPerCompany} bank + ${skvPerCompany} SKV + ${peppolPerCompany} Peppol connection(s)/company, min sync interval ${syncMinInterval}s`) + console.log(`Peppol: ${peppolParticipants.length ? peppolParticipants.join(', ') : '(own org number only)'}`) console.log(`Period: until ${periodEnd.toISOString().slice(0, 10)} (${months} months)`) if (!flag('confirm')) { console.log('\nDry run. Re-run with --confirm to issue the key.') @@ -101,8 +119,10 @@ async function main(): Promise { limits: { bank_connections_per_company: Math.floor(bankPerCompany), skv_connections_per_company: Math.floor(skvPerCompany), + peppol_connections_per_company: Math.floor(peppolPerCompany), sync_min_interval_s: Math.floor(syncMinInterval), }, + peppol_participants: peppolParticipants, notes, }) .select('id') diff --git a/supabase/migrations/20260902190000_connector_peppol.sql b/supabase/migrations/20260902190000_connector_peppol.sql new file mode 100644 index 00000000..020f23c1 --- /dev/null +++ b/supabase/migrations/20260902190000_connector_peppol.sql @@ -0,0 +1,56 @@ +-- Peppol as a connector upstream (WS3, follow-up to 20260820124000; originally drafted 2026-08-23, renumbered to keep versions monotonic). +-- +-- Lets a self-hosted instance send/receive Peppol through Arcim's contracted +-- Qvalia Access Point with the SAME shape as bank/skatteverket: a per-company +-- connection quota ("one address" = one active Peppol participant registration) +-- and a global upstream rate budget. The hosted proxy route and the +-- instance-side transport reroute are code (lib/connect + lib/invoices); this +-- migration only extends the storage shape. +-- +-- pg-test: covered-by tests/pg/connector-proxy-ledger.pg.test.ts +-- NOTE: switch-on for real third-party instances is gated on the Qvalia +-- brokering-terms check (brokering Qvalia's AP registers those companies under +-- Arcim's AP). The schema is inert until a connector key carries a peppol scope. + +-- 1. Allow 'peppol' in the ownership ledger. The inline CHECK from +-- 20260820124000 is auto-named connector_connections_service_check. +ALTER TABLE public.connector_connections + DROP CONSTRAINT IF EXISTS connector_connections_service_check; +ALTER TABLE public.connector_connections + ADD CONSTRAINT connector_connections_service_check + CHECK (service IN ('bank', 'skatteverket', 'peppol')); + +-- 2. Add the peppol per-company connection quota to the sellable package shape. +-- "One address" = one active Peppol participant registration per company. +ALTER TABLE public.connector_keys + ALTER COLUMN limits SET DEFAULT + '{"bank_connections_per_company": 1, "skv_connections_per_company": 1, "peppol_connections_per_company": 1, "sync_min_interval_s": 0}'::jsonb; + +-- Backfill existing keys so the proxy can read the quota without a code fallback. +UPDATE public.connector_keys + SET limits = limits || '{"peppol_connections_per_company": 1}'::jsonb + WHERE NOT (limits ? 'peppol_connections_per_company'); + +-- 3. Ownership of outbound submissions made through the connector. The hosted +-- Qvalia account is shared by every hosted company and every instance, so +-- status polls and evidence retrieval must prove the caller submitted the +-- document. Secret-free: the provider submission id is a provider-side +-- handle, not a credential. Service-role only, like the ledger. +CREATE TABLE public.connector_peppol_submissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + connector_key_id uuid NOT NULL REFERENCES public.connector_keys(id) ON DELETE CASCADE, + company_ref text NOT NULL, + provider text NOT NULL DEFAULT 'qvalia', + provider_submission_id text NOT NULL CHECK (length(btrim(provider_submission_id)) BETWEEN 1 AND 128), + idempotency_key text, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT connector_peppol_submissions_provider_submission_unique UNIQUE (provider, provider_submission_id) +); +CREATE INDEX idx_connector_peppol_submissions_key + ON public.connector_peppol_submissions (connector_key_id, created_at DESC); +ALTER TABLE public.connector_peppol_submissions ENABLE ROW LEVEL SECURITY; +-- No policies: service role only (same posture as connector_connections). +COMMENT ON TABLE public.connector_peppol_submissions IS + 'Which connector key submitted which Peppol document through the shared hosted access point; gates status/evidence reads. No secrets.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260902191000_connector_keys_peppol_participants.sql b/supabase/migrations/20260902191000_connector_keys_peppol_participants.sql new file mode 100644 index 00000000..fe28365b --- /dev/null +++ b/supabase/migrations/20260902191000_connector_keys_peppol_participants.sql @@ -0,0 +1,17 @@ +-- Peppol participant allowlist per connector key (review follow-up on #2177). +-- +-- A hosted company registers the participant its own settings declare, after +-- BankID/TIC-verified onboarding. An instance is only a licensee: nothing on +-- the hosted side knows which organisations it hosts, so without this column a +-- key could publish (and send under) ANY organisation number through Arcim's +-- access point. Arcim records, at issuance, which participant identifiers the +-- licensee may use; the key's own org_number is always allowed. +-- +-- pg-test: covered-by tests/pg/connector-proxy-ledger.pg.test.ts +ALTER TABLE public.connector_keys + ADD COLUMN IF NOT EXISTS peppol_participants text[] NOT NULL DEFAULT '{}'; + +COMMENT ON COLUMN public.connector_keys.peppol_participants IS + 'Participant identifiers (org numbers, GLNs) this key may register and send as through the hosted Peppol access point; the key org_number is implicitly allowed.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/connector-proxy-ledger.pg.test.ts b/tests/pg/connector-proxy-ledger.pg.test.ts index e1e393de..08f50fe5 100644 --- a/tests/pg/connector-proxy-ledger.pg.test.ts +++ b/tests/pg/connector-proxy-ledger.pg.test.ts @@ -27,7 +27,7 @@ describe('validate_and_increment_connector_key v2', () => { it('returns the key limits (default when unset)', async () => { const { hash: h } = await insertKey() const { rows } = await getPool().query(`SELECT * FROM public.validate_and_increment_connector_key($1)`, [h]) - expect(rows[0].limits).toEqual({ bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 }) + expect(rows[0].limits).toEqual({ bank_connections_per_company: 1, skv_connections_per_company: 1, peppol_connections_per_company: 1, sync_min_interval_s: 0 }) }) it('returns custom limits verbatim', async () => { @@ -64,6 +64,23 @@ describe('connector_reserve_upstream', () => { }) describe('connector_connections ledger', () => { + it('accepts peppol as a ledger service (migration 20260902190000)', async () => { + const { id: keyId } = await insertKey() + const participant = `0007:${randomBytes(6).toString('hex')}` + const { rows } = await getPool().query<{ id: string }>( + `INSERT INTO public.connector_connections (connector_key_id, service, company_ref, handle_hash, account_uids, status) + VALUES ($1, 'peppol', 'c1', $2, ARRAY[$3::text], 'active') RETURNING id`, + [keyId, hash(participant), participant], + ) + expect(rows[0].id).toBeTruthy() + await expect( + getPool().query( + `INSERT INTO public.connector_connections (connector_key_id, service, company_ref, status) VALUES ($1, 'kivra', 'c1', 'pending')`, + [keyId], + ), + ).rejects.toThrow(/connector_connections_service_check|check constraint/) + }) + it('enforces the handle uniqueness per service and cascades with the key', async () => { const { id: keyId } = await insertKey() await getPool().query( @@ -96,3 +113,33 @@ describe('connector_connections ledger', () => { }) }) }) + +describe('connector_peppol_submissions (migration 20260902190000)', () => { + it('is unique per provider submission, cascades with the key, and is invisible to authenticated', async () => { + const { id: keyId } = await insertKey() + const submissionId = `int-${randomBytes(6).toString('hex')}` + await getPool().query( + `INSERT INTO public.connector_peppol_submissions (connector_key_id, company_ref, provider_submission_id, idempotency_key) + VALUES ($1, 'c1', $2, 'idem-1')`, + [keyId, submissionId], + ) + await expect( + getPool().query( + `INSERT INTO public.connector_peppol_submissions (connector_key_id, company_ref, provider_submission_id) VALUES ($1, 'c2', $2)`, + [keyId, submissionId], + ), + ).rejects.toThrow(/connector_peppol_submissions_provider_submission_unique|duplicate key/) + const userId = await insertAuthUser() + await withUserContext(userId, async (client) => { + const r = await client.query(`SELECT id FROM public.connector_peppol_submissions`) + expect(r.rows).toEqual([]) + }) + // The participant allowlist (20260902191000) defaults to empty: a fresh key may + // publish nothing beyond its own org number until Arcim records participants. + const { rows: keyRows } = await getPool().query(`SELECT peppol_participants FROM public.connector_keys WHERE id = $1`, [keyId]) + expect(keyRows[0].peppol_participants).toEqual([]) + await getPool().query(`DELETE FROM public.connector_keys WHERE id = $1`, [keyId]) + const { rows } = await getPool().query(`SELECT count(*)::int AS n FROM public.connector_peppol_submissions WHERE connector_key_id = $1`, [keyId]) + expect(rows[0].n).toBe(0) + }) +})