docs(self-hosting): own-credentials section, stale connector lines, complete .env.example (#2146)

* docs(self-hosting): own-credentials section, stale connector lines, complete .env.example (#2131)

SELF-HOSTING.md said the Skatteverket client wiring "ships in a following
release"; PR #2103 merged it, so both bank sync and Skatteverket now carry
traffic through the hosted proxy with a key. The two stale sentences are
replaced and SOVEREIGN.md line 48 says the same thing.

New "Own credentials (no connector key)" subsection documents the path an
operator takes without a key: Enable Banking app in restricted production
mode with the callback URL, the Skatteverket developer-portal application
with the redirect URI, every variable the code reads, the five production
base URLs (all defaults point at the test environment), the kill switch,
and the rule that any own credential switches that upstream out of
connector mode.

.env.example gains the Skatteverket block, the optional Enable Banking
variables, and RESEND_INBOUND_DOMAIN / RESEND_INBOUND_WEBHOOK_SECRET, which
the invoice-inbox manifest requires but the example never listed.
DOCKER.md no longer claims Enable Banking is excluded from the self-host
preset (docker/extensions.self-hosted.json ships it).

ENABLE_BANKING_SANDBOX is removed from the enable-banking manifest and the
index.ts header: declared as optional, never read anywhere; the sandbox is
selected by ENABLE_BANKING_API_URL. Logged in DECISIONS.md.

Closes #2131

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C6M2ZoZc6QDRxU3m9WzgE
Signed-off-by: Emil <emilmattsson14@gmail.com>

* docs(self-hosting): correct key format, AISP scope caveat, SKV scopes and rotation note (#2131)

Skeptic findings on PR #2146, one pass:
- ENABLE_BANKING_PRIVATE_KEY: the decoder base64-decodes first and wraps
  anything else as DER, so a raw PEM fails at JWT signing. The docs and
  .env.example no longer claim it is accepted.
- Enable Banking restricted mode covers the operator's own accounts only;
  an instance hosting client companies is doing licensed AIS and needs
  the connector key or its own AISP registration. Said so.
- Listed the OAuth scopes the app requests (both AGI scopes), noted that
  the kill switch gates API calls, not the BankID login, and that the
  token encryption key has no dual-key rotation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C6M2ZoZc6QDRxU3m9WzgE
Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 23:08:47 +02:00
committed by GitHub
parent 97107398c0
commit d8cf78330e
7 changed files with 75 additions and 8 deletions
+28 -2
View File
@@ -116,6 +116,8 @@ RECEIPT_HUNT_COMPANY_IDS=
# EMAIL_PROVIDER=resend|smtp # optional; RESEND_API_KEY wins, else SMTP_HOST
# RESEND_API_KEY=
# RESEND_FROM_EMAIL=
# RESEND_INBOUND_DOMAIN= # invoice-inbox extension: per-company inbox addresses are {local-part}@{this domain}
# RESEND_INBOUND_WEBHOOK_SECRET= # invoice-inbox extension: verifies the Resend inbound webhook signature
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_SECURE=false # true = implicit TLS on 465, false = STARTTLS required (set SMTP_REQUIRE_TLS=false only for a plaintext LAN relay)
@@ -124,9 +126,33 @@ RECEIPT_HUNT_COMPANY_IDS=
# SMTP_FROM_EMAIL=
# SMTP_REQUIRE_TLS=true # false only for a plaintext relay on a trusted LAN
# SMTP_TLS_REJECT_UNAUTHORIZED=true
# Bank connections (Enable Banking)
# Bank connections (Enable Banking), own credentials. Set APP_ID and PRIVATE_KEY
# as a pair: any one of them (or a _PRODUCTION variant) switches the bank
# upstream out of connector mode. Leave all unset to route bank sync through
# GNUBOK_CONNECTOR_KEY. See docs/SELF-HOSTING.md, "Own credentials".
# ENABLE_BANKING_APP_ID=
# ENABLE_BANKING_PRIVATE_KEY=
# ENABLE_BANKING_PRIVATE_KEY= # base64-encoded PEM (bare base64 DER also works; raw PEM does not)
# ENABLE_BANKING_API_URL=https://api.enablebanking.com # default; api.tilisy.com = sandbox
# ENABLE_BANKING_PSU_TYPE=business
# Skatteverket API (VAT/AGI submission, skattekonto), own credentials. Setting
# either client id switches the upstream out of connector mode. Every base URL
# defaults to Skatteverket's TEST environment (test BankID only): set all five
# for production. SKATTEVERKET_ENABLED and the encryption key are needed in
# connector mode too. SKATTEVERKET_SYSTEM_* and SKATTEVERKET_OMBUD_ORG_NUMBER
# are hosted-only. See docs/SELF-HOSTING.md, "Own credentials".
# SKATTEVERKET_ENABLED=true
# SKATTEVERKET_OAUTH2_CLIENT_ID=
# SKATTEVERKET_OAUTH2_CLIENT_SECRET=
# SKATTEVERKET_APIGW_CLIENT_ID=
# SKATTEVERKET_APIGW_CLIENT_SECRET=
# SKATTEVERKET_TOKEN_ENCRYPTION_KEY= # openssl rand -base64 32; rotating it forces every user to reconnect
# SKATTEVERKET_ENV=production # test | production; defaults to test
# SKATTEVERKET_OAUTH_BASE_URL=https://peroauth2.skatteverket.se/oauth2/v1/per
# SKATTEVERKET_API_BASE_URL=https://api.skatteverket.se/momsdeklaration/v1
# SKATTEVERKET_AGD_INLAMNING_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
# SKATTEVERKET_AGD_PERIOD_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1
# SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/skattekonto/v2
# SKATTEVERKET_DISABLED=true # emergency kill switch
# Peppol e-invoicing via Qvalia (certified Access Point + SMP, partner model).
# The adapter registers itself when QVALIA_API_KEY, QVALIA_PARTNER_REG_NO and
# QVALIA_BASE_URL are all set; PEPPOL_TRANSPORT_PROVIDER=qvalia switches it on
+2
View File
@@ -1473,3 +1473,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-01] mcp.tool_called gets errorCause = errorCauseTag(err) on the two execution catch paths only (#2051): SQLSTATE or coded-error code, else the error class name, capped at 64 chars; a plain Error deliberately tags null because the class name 'Error' is noise, and pre-execution denials pass nothing since their errorCode already IS the vocabulary. Raw driver messages stay out of event_log on purpose: a constraint-violation message can quote row values.
[2026-09-01] counterparty_aliases joins the categorization_templates audit-trigger strip list (20260901200000) instead of staying logged: prod falsified the original exclusion list within 30 minutes of 20260901103000 going live (15 of the first 16 UPDATE audit rows were alias+learning noise, ~800/day projected vs ~50/day of real rule changes), because the learning path merges aliases in the same write that bumps occurrence_count. Explicit trade-off: a human editing ONLY aliases is no longer logged; accepted since alias growth is overwhelmingly automatic and any change also touching accounts/VAT/pattern/active still logs (first real one, 19:02:17Z same day, captured correctly). Pre-fix noise rows stay in audit_log (append-only) and the read model stops labelling the column so they render as no-ops.
[2026-09-01] MCP catalog budget attacked at the duplicated staged envelope rather than by demoting more reads: measuring the payload by segment showed outputSchema is 38 % of the whole catalog (23 290 tokens) and STAGED_OPERATION_SCHEMA alone 14 736 of it, the same envelope transmitted 58 times, while descriptions (what the three previous rounds trimmed) are only 10 %. period_status now carries its shape in one sentence instead of declared JSON Schema, matching actor/approve/preview which were always bare objects; 2 552 tokens reclaimed with no tool demoted and no field removed. Every edit is in the LOOSER direction because the server emits structuredContent for every tool and the documented failure mode is a declaration too tight making a strict client reject a successful call. next kept additionalProperties: false: staging.test.ts pins it closed and a guard whose reason is not in front of you is not one to loosen for 420 tokens. Ceiling ratcheted to 60 000 rather than the usual ~300 margin, leaving ~1 070 deliberate working margin: server.ts took 70 commits in 14 days and the previous 116-token margin is what starts the ratchet-block-bump-demote cycle visible in the bench log.
[2026-09-01] ENABLE_BANKING_SANDBOX removed from the enable-banking manifest and the index.ts header (#2131): the variable was declared as optional but never read anywhere; sandbox vs production is decided by ENABLE_BANKING_API_URL (api.tilisy.com vs api.enablebanking.com, api-client.ts derives isSandbox from the host). A dead variable declared in the manifest is what the self-hosting docs would otherwise have copied. The manifest now lists the two optional variables the code actually reads (API_URL, PSU_TYPE); the _PRODUCTION aliases stay undeclared on purpose, they are a hosted Vercel convention, not an operator contract.
+1 -1
View File
@@ -166,7 +166,7 @@ If you already have nginx / a managed load balancer / Cloudflare in front, skip
## Optional Extensions
The self-hosted image ships with a curated set of general extensions, including email, invoice inbox, document extraction, push notifications, calendar, and the MCP server. Enable Banking is excluded because it requires private PSD2 credentials. Each extension activates when you provide its env vars: without them, the app works normally and the feature is simply unavailable.
The self-hosted image ships with a curated set of general extensions, including email, invoice inbox, document extraction, push notifications, calendar, and the MCP server. Enable Banking and Skatteverket are in the preset too: they run on a connector key or on your own credentials (see [SELF-HOSTING.md](SELF-HOSTING.md), "Connector subscription"). Each extension activates when you provide its env vars: without them, the app works normally and the feature is simply unavailable.
### AI Features (document-extraction, invoice-inbox, AI assistant)
+38 -2
View File
@@ -352,9 +352,45 @@ The cron sidecar calls `/api/connector/sync/cron` hourly (it is listed in `docke
curl -sf -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/connector/sync/cron
```
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 lands in halves: the bank client already routes through the hosted proxy in connector mode (key set, no own Enable Banking credentials), while the Skatteverket client wiring still ships in a following release; until it lands no keys are issued, so a key never yields 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).
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).
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. The bank client wiring is merged; the Skatteverket client wiring still ships in a following release, and keys are not issued until it lands.
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)
Both gated upstreams also run on credentials you register yourself. An instance with its own credentials for an upstream provides that service itself and is never connector-gated for it, with or without a key: the app routes that upstream directly and ignores the connector for it.
**Enable Banking (bank sync).** Create an application in the Enable Banking control panel. Production access in restricted mode covers your own company's accounts and needs no AISP licence of your own. It does not cover other parties' accounts: an instance that hosts client companies (a byrå, a consultant team) is connecting accounts it does not own, which is licensed account-information service under PSD2 and lag (2010:751) om betaltjänster. For that instance, use the connector key (Arcim's AISP registration) or register as an AISP yourself. Register the redirect URL `${NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback` on the application, then set:
```bash
ENABLE_BANKING_APP_ID=... # application id from the control panel
ENABLE_BANKING_PRIVATE_KEY=... # the application's private key as base64-encoded PEM (a bare base64 DER body also works; a raw PEM does not)
# ENABLE_BANKING_API_URL=https://api.enablebanking.com # default; https://api.tilisy.com is the sandbox
# ENABLE_BANKING_PSU_TYPE=business # default; enskild firma companies are sent as personal automatically
```
The `_PRODUCTION` variants (`ENABLE_BANKING_APP_ID_PRODUCTION`, `ENABLE_BANKING_PRIVATE_KEY_PRODUCTION`, `ENABLE_BANKING_API_URL_PRODUCTION`) win over the plain names when both are set. Setting any one of the four id/key variables switches the bank upstream out of connector mode, so always set the id and the key as a pair: a lone `ENABLE_BANKING_APP_ID` leaves you with neither the connector nor a working own client.
**Skatteverket (VAT and AGI submission, skattekonto sync).** Apply for API access in Skatteverket's developer portal (Utvecklarportalen): a separate OAuth2 client and API gateway credentials, one integration agreement per API (momsdeklaration, the two arbetsgivardeklaration APIs, skattekonto), and Skatteverket's approval test before production access is granted. Request the scopes the app sends on every authorization: `momsdeklaration inkforetag skahmst skattekonto ska agd agdredovisningperiod` (the two AGI scopes are both required: `agd` for inlämning and `agdredovisningperiod` for kvittenser; a token missing the second one files fine and then fails on the receipt). Register the redirect URI `${NEXT_PUBLIC_APP_URL}/api/extensions/ext/skatteverket/callback` on the client, then set:
```bash
SKATTEVERKET_ENABLED=true
SKATTEVERKET_OAUTH2_CLIENT_ID=...
SKATTEVERKET_OAUTH2_CLIENT_SECRET=...
SKATTEVERKET_APIGW_CLIENT_ID=...
SKATTEVERKET_APIGW_CLIENT_SECRET=...
SKATTEVERKET_TOKEN_ENCRYPTION_KEY=... # openssl rand -base64 32; encrypts the BankID tokens at rest (any string, hashed to the AES key)
SKATTEVERKET_ENV=production # drives payload limits; defaults to test
SKATTEVERKET_OAUTH_BASE_URL=https://peroauth2.skatteverket.se/oauth2/v1/per
SKATTEVERKET_API_BASE_URL=https://api.skatteverket.se/momsdeklaration/v1
SKATTEVERKET_AGD_INLAMNING_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
SKATTEVERKET_AGD_PERIOD_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1
SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/skattekonto/v2
```
Set all five base URLs: every default points at Skatteverket's test environment, which only accepts a test BankID, so a production client with a missing URL fails at login. `SKATTEVERKET_DISABLED=true` is the emergency kill switch: every Skatteverket API call fails closed until you remove it (the BankID login itself is not blocked, only what follows it). There is no dual-key rotation for the token encryption key: changing it makes every stored token undecryptable, and every user reconnects with BankID. The `SKATTEVERKET_SYSTEM_*` variables and `SKATTEVERKET_OMBUD_ORG_NUMBER` belong to Accounted's hosted ombud certificate and stay unset on a self-host. Setting either `SKATTEVERKET_OAUTH2_CLIENT_ID` or `SKATTEVERKET_APIGW_CLIENT_ID` switches the Skatteverket upstream out of connector mode.
**Connector mode, for comparison.** With a key you set `GNUBOK_CONNECTOR_KEY` and, if you are not on the default hosted origin, `GNUBOK_CONNECT_URL` (https only; plain http is accepted for loopback only, and an invalid URL disables the connector with a warning in the log). Skatteverket in connector mode still needs `SKATTEVERKET_ENABLED=true` and `SKATTEVERKET_TOKEN_ENCRYPTION_KEY`: the BankID tokens are stored in your database, so the encryption key stays operator-side. Leave every other Enable Banking and Skatteverket variable unset.
### Push Notifications
+1 -1
View File
@@ -45,7 +45,7 @@ Three things carry the sovereign claim, in order of how much they matter:
| 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)) |
| Push notifications (your VAPID keys), invoice email via your own SMTP relay (`EMAIL_PROVIDER=smtp`) or Resend (section 6) | |
The hosted-only rows (everything in the right column except the AI row, which is a backend restriction a connector key would not change) are what a connector subscription for self-hosted instances would unlock (priced at parity with hosted, per active company). The connector-key infrastructure is now merged: `GNUBOK_CONNECTOR_KEY` exists (see SELF-HOSTING.md), the hourly sync validates the key and writes the capability grants, and the bank/Skatteverket proxies are live server-side. Keys are **not yet issued**: the bank client wiring is merged (with a key, bank sync carries traffic through the hosted proxy), the Skatteverket client wiring ships in a following release, and Accounted issues no keys until it lands, so a key never unlocks a granted capability whose client cannot carry traffic. When issuance opens it is manual, priced per active company at parity with hosted (see SELF-HOSTING.md). Without a key (or the instance's own upstream credentials) the settings screens show a connector-key note.
The hosted-only rows (everything in the right column except the AI row, which is a backend restriction a connector key would not change) are what a connector subscription for self-hosted instances would unlock (priced at parity with hosted, per active company). The connector-key infrastructure is now merged: `GNUBOK_CONNECTOR_KEY` exists (see SELF-HOSTING.md), the hourly sync validates the key and writes the capability grants, and the bank/Skatteverket proxies are live server-side. The client wiring is merged too: with a key, both 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. When issuance opens it is manual, priced per active company at parity with hosted (see SELF-HOSTING.md). Without a key (or the instance's own upstream credentials) the settings screens show a connector-key note.
## 3. Choosing Swedish infrastructure
+4 -1
View File
@@ -47,7 +47,10 @@ const MAX_ENABLED_UIDS = 50
* Required environment variables:
* - ENABLE_BANKING_APP_ID
* - ENABLE_BANKING_PRIVATE_KEY (base64-encoded PEM)
* - ENABLE_BANKING_SANDBOX (optional, for sandbox mode)
*
* Optional:
* - ENABLE_BANKING_API_URL (default https://api.enablebanking.com; api.tilisy.com = sandbox)
* - ENABLE_BANKING_PSU_TYPE (default business)
*/
export const enableBankingExtension: Extension = {
id: 'enable-banking',
@@ -5,7 +5,7 @@
"entryPoint": "@/extensions/general/enable-banking",
"workspace": "@/components/extensions/general/EnableBankingWorkspace",
"requiredEnvVars": ["ENABLE_BANKING_APP_ID", "ENABLE_BANKING_PRIVATE_KEY"],
"optionalEnvVars": ["ENABLE_BANKING_SANDBOX"],
"optionalEnvVars": ["ENABLE_BANKING_API_URL", "ENABLE_BANKING_PSU_TYPE"],
"npmDependencies": [],
"definition": {
"name": "Bankintegration (PSD2)",