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 -1
View File
@@ -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)
+2 -1
View File
@@ -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)) |