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 <jakob.wennberg@arcim.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <jakob.wennberg@arcim.io>
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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 20:57:57 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent fcfa1ba974
commit f31eeaa603
27 changed files with 1875 additions and 13 deletions
+3 -3
View File
@@ -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 () => {
+3
View File
@@ -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,
}
+1 -1
View File
@@ -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')
+204
View File
@@ -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<PeppolParticipant[]> {
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<boolean> {
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<boolean> {
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<number> {
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<Set<string>> {
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<string>()
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<void> {
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,
}
}
+8
View File
@@ -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),
@@ -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()
})
})
+14 -1
View File
@@ -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 }
}
@@ -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')
+3
View File
@@ -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 {
+6
View File
@@ -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
}
@@ -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' })
@@ -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,
+1
View File
@@ -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')
+19
View File
@@ -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<PeppolVerifiedEvent[]>
}
/**
* 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<string, PeppolTransport>()
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' }
}
@@ -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: '<x/>', 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<string, string>
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<string, string>)['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: '<Invoice/>' }))
.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('<Invoice/>')
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<string, string>)['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)
})
})
@@ -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' })
})
})
+228
View File
@@ -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<string | null>
/** Same for a receiving registration (from peppol_registrations). */
companyForParticipant?: (participant: PeppolParticipant) => Promise<string | null>
}
interface ConnectorErrorBody {
error?: string
code?: string
retryable?: boolean
detail?: string | null
}
async function readJson(response: Response): Promise<unknown> {
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<T>(
method: 'POST' | 'PUT' | 'DELETE',
path: string,
body: unknown,
options: { companyRef?: string | null } = {},
): Promise<T> {
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<T extends { provider: string }>(value: T): T {
return { ...value, provider: CONNECTOR_PROVIDER }
}
async function lookupRecipient(participant: PeppolParticipant): Promise<PeppolRecipientLookup> {
return call<PeppolRecipientLookup>('POST', '/lookup', { participant })
}
async function submit(submission: PeppolSubmission): Promise<PeppolSubmissionReceipt> {
const receipt = await call<PeppolSubmissionReceipt>('POST', '/submit', submission, {
companyRef: submission.tenantReference,
})
return withProvider(receipt)
}
async function verifyWebhook(_webhook: PeppolWebhookRequest): Promise<PeppolVerifiedEvent[]> {
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<string | null> {
return deps.companyFor ? deps.companyFor(providerSubmissionId) : null
}
async function retrieveEvidence(providerSubmissionId: string): Promise<PeppolDeliveryEvidence[]> {
const items = await call<PeppolDeliveryEvidence[]>('POST', '/evidence', { providerSubmissionId }, {
companyRef: await companyFor(providerSubmissionId),
})
return (items ?? []).map(withProvider)
}
async function pollDeliveryStatus(providerSubmissionId: string): Promise<PeppolVerifiedEvent[]> {
const events = await call<PeppolVerifiedEvent[]>('POST', '/status', { providerSubmissionId }, {
companyRef: await companyFor(providerSubmissionId),
})
return (events ?? []).map(withProvider)
}
async function registerRecipient(input: PeppolRecipientRegistrationInput): Promise<PeppolRecipientRegistration> {
if (!input.tenantReference) {
throw new PeppolTransportError('Connector: a tenant reference is required to register a recipient', {
retryable: false,
})
}
const result = await call<PeppolRecipientRegistration>('PUT', '/recipient', input, {
companyRef: input.tenantReference,
})
return { ...result, participant: input.participant }
}
async function unregisterRecipient(participant: PeppolParticipant): Promise<void> {
const query = new URLSearchParams({ scheme: participant.scheme, identifier: participant.identifier })
await call<unknown>('DELETE', `/recipient?${query.toString()}`, undefined, {
companyRef: deps.companyForParticipant ? await deps.companyForParticipant(participant) : null,
})
}
async function listInboundDocuments(options: PeppolInboundListOptions): Promise<PeppolInboundMessage[]> {
const items = await call<PeppolInboundMessage[]>('POST', '/inbound/list', options)
return (items ?? []).map(withProvider)
}
async function fetchInboundDocumentXml(
providerDocumentId: string,
documentType: PeppolInboundListOptions['documentType'],
): Promise<string | null> {
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,
}
}
+49
View File
@@ -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<string | null> {
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<string | null> {
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
}