fix(connect): PR #1758 CodeRabbit follow-up: connector status hardening, i18n strings, doc alignment (#2098)

* fix(connect): PR #1758 CodeRabbit follow-up: harden connector status, i18n the connector-mode strings, align docs

- getConnectorConfig() rebuilds baseUrl as origin + path: userinfo, query
  and fragment are stripped (warn-logged without the raw value) so nothing
  secret-shaped pasted into GNUBOK_CONNECT_URL survives into the
  /api/connector/status echo or the derived proxy URLs (CWE-200)
- /api/connector/status responds Cache-Control: no-store on both branches
  (key prefix + wiring layout out of shared browser caches, CWE-525)
- CWE-319 thread verified as no-change: both connector-mode helpers derive
  from getConnectorConfig(), which fails closed on non-https
- SkatteverketConnectPanel tooltips and BankSyncNowButton gate/upsell
  strings moved to messages/sv.json + messages/en.json keys
- DECISIONS.md: MD037 fix on line 1146 (backtick the glob), line 1147
  reworded to grants-written-wiring-pending, decision lines appended
  (incl. declining the UpgradeNote children-append suggestion)
- docs/SOVEREIGN.md availability wording aligned with SELF-HOSTING.md:
  infra merged, keys issued manually on request, client wiring pending

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKZUp1nePr8sVDoLkMSxbS

* docs(connect): skeptic follow-up: bank client wiring is merged (#2094), SKV pending, no keys issued until it lands

Skeptic refutation on PR #2098: SOVEREIGN.md claimed the services 'do not
carry traffic' while this branch already contains #2094 (EB client proxy
routing), and 'issued manually on request' contradicted the standing
no-key-before-full-PR6b rule while skatteverketConnectorMode() has no
client consumer yet. SOVEREIGN.md, SELF-HOSTING.md and DECISIONS.md line
1147 now all say: bank client wiring merged and carries traffic with a
key, Skatteverket client wiring ships in a following release, keys are
not issued until it lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKZUp1nePr8sVDoLkMSxbS

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 11:16:37 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b74b5e3c0d
commit ca12b1855e
11 changed files with 86 additions and 19 deletions
+4 -2
View File
@@ -1143,8 +1143,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES and outside the trial-seed trigger, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells.
[2026-08-20] Connector-key infrastructure (plan WS3 PR4) ships the hosted registry + the instance sync, nothing a customer can buy yet: connector_keys / connector_usage_events are service-role-only tables (RLS on, no policies) with an atomic validate_and_increment_connector_key RPC that copies the api_keys pattern (SHA-256 at rest, FOR UPDATE row lock, per-minute window) and is REVOKEd from PUBLIC, anon and authenticated before anyone can call it (the SECURITY DEFINER exposure lesson applied up front); /api/connect/entitlements behind withConnectorAuth (Bearer or X-Connector-Key, 401/403/429, one usage row per request); keys issued by scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once). The instance side writes source='connector' grants expiring at min(now+72h, period_end+3d) on every hourly sync, deletes them on 401/403 or a non-active status, and leaves them alone on network/5xx errors: the grant rows ARE the offline cache, no new cache code. The hourly job lives only in docker/crontab.self-hosted through a new EXTRA_JOBS table in the crontab generator (with its own drift tests), because vercel.json is the hosted schedule and hosted has no connector key. connector_usage_events is a separate table because metered_events.company_id references hosted companies and a connector key belongs to an instance, not a company here. Deferred: the proxy routes (bank/skv/org/migration: founder legal check with Enable Banking/SKV/TIC is the launch blocker), a connect.gnubok.se host rewrite (the instance calls app.gnubok.se/api/connect directly; a dedicated host is a later DNS decision), the self-host Docker preset change and the settings row.
[2026-08-20] Connector bank proxy (plan WS3 PR5a): app/api/connect/bank/[...path] brokers Enable Banking for self-hosted instances with tokens staying on the instance (founder decision) and the proxy stateless apart from a secret-free connection ledger. Design that keeps EB Annex 1 §3/§7 satisfied: the instance never holds the EB JWT (minting moved to lib/connect/upstreams/enable-banking-jwt.ts so core does not import @/extensions/; the extension re-exports it); the consent redirect goes to OUR already-registered EB callback, which detects an HMAC-signed connector state (lib/connect/hosted/state.ts, 15-min TTL, CONNECTOR_STATE_SECRET or a one-way derivation of the service-role key) and 302s the browser back to the instance, so no per-instance redirect URI is registered at EB. Ownership: connector_connections ledger stores sha256(session_id) and the account uids, never the session; GET/DELETE /sessions and /accounts/{uid} calls verify the handle/account belongs to the presenting key. Quotas: per-company bank connection limit (connector_keys.limits, sold in the package, checked at POST /auth), per-key RPM (validate RPC), and a GLOBAL budget (connector_reserve_upstream RPC, connector_upstream_counters, ~30% of EB's 300/min so hosted is never starved; fail-open on a counter error). validate_and_increment_connector_key gained a limits column (v2). issue-connector-key.ts scopes default to bank_sync,skatteverket (TIC/org_lookup out of v1 per founder) and take --bank-connections-per-company etc. Path allowlist only, never an open passthrough. Deferred: the SKV broker (PR5b, same pattern, callback branch on the SKV extension) and the instance-side wiring (PR6: EB client connector-mode branch, self-host preset, settings row).
[2026-08-20] Connector Skatteverket broker (plan WS3 PR5b, stacked on PR5a): the SKV OAuth/data path is core-side (lib/connect/upstreams/skatteverket-oauth.ts) so app/api/connect/skv/* does not import @/extensions/; it duplicates the extension's endpoints/scope set (one integrator = Arcim's registered SKV client) rather than sharing, matching the EB JWT relocation pattern. Tokens stay on the instance: POST /api/connect/skv/oauth/token exchanges the code/refresh with Arcim's client secret and RETURNS the tokens to the instance (which encrypts+stores them), the ledger keeps only sha256(access_token) and sha256(refresh_token). authorize-url uses OUR registered redirect_uri + a signed connector state; the SKV extension /callback gained the same connector branch as EB (isConnectorState → 302 back to the instance, code never exchanged there). The data proxy app/api/connect/skv/api/<service>/<path> is an allowlist over the four backing APIs (moms, skattekonto, agd-inlamning, agd-period): the instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key, the proxy verifies the token hash against the ledger and adds Arcim's Client_Id/Client_Secret gateway headers (never exposed to the instance). Same per-key/global budget as bank. SKATTEVERKET_ENABLED + SKATTEVERKET_TOKEN_ENCRYPTION_KEY stay operator-set on the instance since the tokens live there. Refresh update written as two literal .update() branches to keep the no-phantom-columns ceiling at 380.
[2026-08-20] Connector instance enablement (plan WS3 PR6): the self-host Docker preset (docker/extensions.self-hosted.json) now includes enable-banking + skatteverket, so a connector-key self-host gets working bank/SKV instead of hidden extensions; without a key they show the existing capability_blocked upsell (unconfigured extensions no-op). lib/connect/instance/upstreams.ts is the connector-mode seam: an upstream is in connector mode only when GNUBOK_CONNECTOR_KEY is set AND the instance has no own credentials for it (hasOwnEnableBankingCredentials / hasOwnSkatteverketCredentials), which is what keeps hosted byte-identical (hosted always has own creds). GET /api/connector/status is the operator's wiring view (self_hosted flag, per-upstream own_credentials|connector|unconfigured, key prefix never the key, granted connector capabilities). DEFERRED to PR6b (needs a live connector key + a real bank/SKV to verify end-to-end, and touches the live EB/SKV consent path): wiring the EB api-client/consent-callback and the SKV oauth/api-client to actually call the proxy in connector mode (base-URL + header swap for data reads; redirect_url=instance callback + X-Connector-Company + connector_state round-trip for consent), plus the "Synka nu" settings row (UI, needs visual sign-off). The seam, preset and status route make PR6b a contained follow-up.
[2026-08-20] Connector Skatteverket broker (plan WS3 PR5b, stacked on PR5a): the SKV OAuth/data path is core-side (lib/connect/upstreams/skatteverket-oauth.ts) so `app/api/connect/skv/*` does not import @/extensions/; it duplicates the extension's endpoints/scope set (one integrator = Arcim's registered SKV client) rather than sharing, matching the EB JWT relocation pattern. Tokens stay on the instance: POST /api/connect/skv/oauth/token exchanges the code/refresh with Arcim's client secret and RETURNS the tokens to the instance (which encrypts+stores them), the ledger keeps only sha256(access_token) and sha256(refresh_token). authorize-url uses OUR registered redirect_uri + a signed connector state; the SKV extension /callback gained the same connector branch as EB (isConnectorState → 302 back to the instance, code never exchanged there). The data proxy app/api/connect/skv/api/<service>/<path> is an allowlist over the four backing APIs (moms, skattekonto, agd-inlamning, agd-period): the instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key, the proxy verifies the token hash against the ledger and adds Arcim's Client_Id/Client_Secret gateway headers (never exposed to the instance). Same per-key/global budget as bank. SKATTEVERKET_ENABLED + SKATTEVERKET_TOKEN_ENCRYPTION_KEY stay operator-set on the instance since the tokens live there. Refresh update written as two literal .update() branches to keep the no-phantom-columns ceiling at 380.
[2026-08-20] Connector instance enablement (plan WS3 PR6): the self-host Docker preset (docker/extensions.self-hosted.json) now includes enable-banking + skatteverket, so a connector-key self-host has the bank/SKV extensions enabled instead of hidden (grants are written; carrying actual traffic waited on PR6b's client wiring: the bank half landed via #2094, the SKV half still pending); without a key they show the existing capability_blocked upsell (unconfigured extensions no-op). lib/connect/instance/upstreams.ts is the connector-mode seam: an upstream is in connector mode only when GNUBOK_CONNECTOR_KEY is set AND the instance has no own credentials for it (hasOwnEnableBankingCredentials / hasOwnSkatteverketCredentials), which is what keeps hosted byte-identical (hosted always has own creds). GET /api/connector/status is the operator's wiring view (self_hosted flag, per-upstream own_credentials|connector|unconfigured, key prefix never the key, granted connector capabilities). DEFERRED to PR6b (needs a live connector key + a real bank/SKV to verify end-to-end, and touches the live EB/SKV consent path): wiring the EB api-client/consent-callback and the SKV oauth/api-client to actually call the proxy in connector mode (base-URL + header swap for data reads; redirect_url=instance callback + X-Connector-Company + connector_state round-trip for consent), plus the "Synka nu" settings row (UI, needs visual sign-off). The seam, preset and status route make PR6b a contained follow-up.
[2026-08-20] Connector Skatteverket broker (plan WS3 PR5b, stacked on PR5a): the SKV OAuth/data path is core-side (lib/connect/upstreams/skatteverket-oauth.ts) so `app/api/connect/skv/*` does not import @/extensions/; it duplicates the extension's endpoints/scope set (one integrator = Arcim's registered SKV client) rather than sharing, matching the EB JWT relocation pattern. Tokens stay on the instance: POST /api/connect/skv/oauth/token exchanges the code/refresh with Arcim's client secret and RETURNS the tokens to the instance (which encrypts+stores them), the ledger keeps only sha256(access_token) and sha256(refresh_token). authorize-url uses OUR registered redirect_uri + a signed connector state; the SKV extension /callback gained the same connector branch as EB (isConnectorState → 302 back to the instance, code never exchanged there). The data proxy app/api/connect/skv/api/<service>/<path> is an allowlist over the four backing APIs (moms, skattekonto, agd-inlamning, agd-period): the instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key, the proxy verifies the token hash against the ledger and adds Arcim's Client_Id/Client_Secret gateway headers (never exposed to the instance). Same per-key/global budget as bank. SKATTEVERKET_ENABLED + SKATTEVERKET_TOKEN_ENCRYPTION_KEY stay operator-set on the instance since the tokens live there. Refresh update written as two literal .update() branches to keep the no-phantom-columns ceiling at 380.
[2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES; the trial-seed trigger does seed 30-day source='trial' rows for bank_sync/skatteverket (they are PAID keys) but never writes source='connector' and never seeds the connector-only keys (org_lookup, migration), and on a self-host only source='connector' rows unlock a connector capability, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells.
[2026-08-20] Sovereign package docs (plan WS2 PR1): docs/SOVEREIGN.md is written as regulatory-risk elimination with a per-provider fact sheet checked on the vendors' own pages (Elastx CaaS/DBaaS/3 Stockholm AZs/ISO 27001:2022; GleSYS VPS + S3, no managed k8s, EU-owned not Swedish-owned; Safespring S3 with Object Lock COMPLIANCE/GOVERNANCE; Berget api.berget.ai/v1 with gemma-4-31B-it vision and an SLA that excludes serverless; evroc Think Models EU-only), never as "US cloud is illegal", and it leads with the MCP server as the agent surface that needs no AI provider at all (alignment rule R5). The connector subscription is described as planned and not yet available rather than documented as if it shipped. Vercel Speed Insights is now gated behind !isSelfHosted() in app/layout.tsx (the last ungated hosted-only telemetry; read via lib/env/public-flags per the folded-flag rule). Backup/restore ship as scripts/self-host/{backup,restore}.sh (pg_dump custom format + storage volume tar + optional db-config volume for the pgsodium root key, SHA-256 manifest, AWS CLI v2 against any S3-compatible endpoint, optional COMPLIANCE-mode Object Lock) with a bash -n + refusal-path test, because self-hosted Supabase has no managed backups and BFL 7 kap needs a credible 7-year archive. Stale self-host docs fixed: the 4-of-23 cron table replaced by a pointer to the generated crontab and the pgvector line corrected (nothing stores embeddings).
@@ -1422,3 +1422,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-31] UpgradeNote is self-host-aware (operator-skeptic refutation on PR #1758): on a self-host every UpgradeNote surface is by definition a connector capability (local capabilities are always on), so the component centrally swaps the hosted subscription copy + /settings/billing link for the connector-key note, mirroring CAPABILITY_BLOCKED_MESSAGE_SELF_HOSTED_SV; the SKV connect button tooltip branches the same way. Fixed centrally rather than per panel so the EB/SKV/AGI/reports surfaces can never drift. SOVEREIGN.md updated to the merged reality (infra live, keys not sold, wiring pending). Standing rule reaffirmed: no connector key is issued before the instance-side wiring (PR6b) lands, or a paying customer sees granted capabilities with clients that still call the upstreams directly and fail on missing env credentials.
[2026-09-01] EU reverse-charge packs book directly on 4515/4535 instead of adding a 45xx D / 4598 K basbelopp pair (Anders' literal suggestion): same ruta 20/21 outcome, standard BAS practice for a template that owns the cost account anyway, and a 3-business-line pack would return null from convertLibraryToBookingTemplate and silently vanish from the transaction picker. The 4598 motkonto pattern remains the right tool only where the user's own cost account must be preserved (engine-generated bookings, supplier invoices).
[2026-09-01] Floating supplier-invoice underlag gets a standing daily reanchor cron (/api/documents/reanchor/cron) instead of another one-off repair migration: prod case 2026-08-28 (kontantmetod payment verifikat, doc eligible on every static condition, inline anchor silently did nothing, no log line recorded why) is the second time a hand-written sweep (20260727180000, 20260824150000) was needed; the inline anchor is best-effort by design, so the retry belongs in infrastructure. anchorSupplierInvoiceDocument also stops claiming success on a zero-row guarded update and logs its silent bail branches.
[2026-09-01] Declined CodeRabbit's UpgradeNote suggestion (PR #1758 follow-up) to append the self-host connector sentence to children instead of replacing them: every caller's children is hosted subscription copy ("... kräver ett abonnemang"), so appending would show subscription wording on a self-host, the exact thing the branch exists to avoid; the "CSV/SIE import stays free" text it cited is a code comment in BankSyncNowButton, not children. Replace-on-self-host stays; a dedicated selfHosted children prop can come when a caller actually needs per-panel reassurance there.
[2026-09-01] getConnectorConfig() rebuilds baseUrl as origin + path (userinfo/query/fragment stripped, warn-logged without the raw value): /api/connector/status echoes baseUrl to the operator and the sync/proxy URLs get paths appended, so nothing secret-shaped pasted into GNUBOK_CONNECT_URL may survive; the stripped parts were never meaningful in a base URL. The status route is also Cache-Control: no-store (key prefix + wiring layout out of shared browser caches).
@@ -45,6 +45,21 @@ describe('GET /api/connector/status', () => {
expect(body.data.granted_capabilities).toEqual(['bank_sync'])
})
it('is Cache-Control: no-store on both branches', async () => {
const selfHost = await GET(createMockRequest('/x'), { params: Promise.resolve({}) })
expect(selfHost.headers.get('Cache-Control')).toBe('no-store')
selfHosted = false
const hosted = await GET(createMockRequest('/x'), { params: Promise.resolve({}) })
expect(hosted.headers.get('Cache-Control')).toBe('no-store')
})
it('echoes connect_url without userinfo, query or fragment', async () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
vi.stubEnv('GNUBOK_CONNECT_URL', 'https://op:secret@connect.example.se/?token=secret')
const { body } = await parseJsonResponse<{ data: { connect_url: string } }>(await GET(createMockRequest('/x'), { params: Promise.resolve({}) }))
expect(body.data.connect_url).toBe('https://connect.example.se')
})
it('reports own_credentials for an upstream configured directly on the instance', async () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
vi.stubEnv('ENABLE_BANKING_APP_ID', 'app')
+6 -2
View File
@@ -16,10 +16,14 @@ import { getCompanyIdsWithCapability } from '@/lib/entitlements/has-capability'
* Hosted returns { self_hosted: false }: the connector product is a
* self-host-only concept. Any authenticated member may read it; it exposes no
* secret (never the key itself), only booleans and the key's non-secret prefix.
* Still no-store: the key prefix and wiring layout have no business sitting in
* a shared browser cache.
*/
const NO_STORE = { headers: { 'Cache-Control': 'no-store' } }
export const GET = withRouteContext('connector.status', async (_request, { supabase, companyId }) => {
if (!isSelfHosted()) {
return NextResponse.json({ data: { self_hosted: false } })
return NextResponse.json({ data: { self_hosted: false } }, NO_STORE)
}
const cfg = getConnectorConfig()
const bank = bankConnectorMode()
@@ -41,7 +45,7 @@ export const GET = withRouteContext('connector.status', async (_request, { supab
},
granted_capabilities: grants,
},
})
}, NO_STORE)
})
async function getConnectorGrantsFor(
@@ -309,8 +309,8 @@ function SkatteverketPersonalConnectionCard() {
title={
!hasSkatteverket
? isSelfHosted()
? 'Anslutning till Skatteverket kräver en connector-nyckel eller egna API-uppgifter'
: 'Anslutning till Skatteverket kräver ett abonnemang'
? t('connect_requires_connector_key')
: t('connect_requires_subscription')
: undefined
}
>
@@ -374,8 +374,8 @@ function SkatteverketPersonalConnectionCard() {
title={
!hasSkatteverket
? isSelfHosted()
? 'Anslutning till Skatteverket kräver en connector-nyckel eller egna API-uppgifter'
: 'Anslutning till Skatteverket kräver ett abonnemang'
? t('connect_requires_connector_key')
: t('connect_requires_subscription')
: undefined
}
>
@@ -251,17 +251,17 @@ export default function BankSyncNowButton() {
const selfHosted = isSelfHosted()
const gateTitle = !hasBankSync
? selfHosted
? 'Bankkoppling kräver en connector-nyckel eller egna Enable Banking-uppgifter'
: 'Bankkoppling kräver ett abonnemang'
? t('bank_sync_requires_connector_key')
: t('bank_sync_requires_subscription')
: undefined
const upsellNote = !hasBankSync ? (
selfHosted ? (
<span className="text-xs text-muted-foreground">Kräver connector-nyckel (GNUBOK_CONNECTOR_KEY).</span>
<span className="text-xs text-muted-foreground">{t('bank_sync_upsell_connector_key')}</span>
) : (
<span className="text-xs text-muted-foreground">
Kräver abonnemang.{' '}
{t('bank_sync_upsell_subscription')}{' '}
<a href="/settings/billing" className="underline underline-offset-2">
Uppgradera
{t('bank_sync_upsell_upgrade')}
</a>
</span>
)
+3 -3
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 is issued manually by Accounted for now (self-serve later).
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.
```bash
GNUBOK_CONNECTOR_KEY=gnubok_ck_... # issued by Accounted, shown once
@@ -352,9 +352,9 @@ 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, and so does the instance-side client wiring that makes the bank/Skatteverket clients call the proxies: until that wiring lands, a key is validated and its grants are written, and the services stay unconfigured on the instance. 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 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).
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 client wiring that makes a scoped key actually carry bank/Skatteverket traffic still ships in a following release.
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.
### Push Notifications
+2 -2
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 planned but **not yet available** (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, 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
@@ -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 sold**: the instance-side client wiring that makes the bank and Skatteverket clients actually call the proxies ships in a following release, so until then a key unlocks the settings surfaces but the services do not carry traffic. 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. 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.
## 3. Choosing Swedish infrastructure
@@ -27,6 +27,25 @@ describe('getConnectorConfig', () => {
}
})
it('strips userinfo, query and fragment from the base URL (status echoes it back)', () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
for (const [dirty, clean] of [
['https://user:secret@connect.example.se', 'https://connect.example.se'],
['https://connect.example.se?token=secret', 'https://connect.example.se'],
['https://connect.example.se/base#fragment', 'https://connect.example.se/base'],
['https://user:secret@connect.example.se/base/?token=s#f', 'https://connect.example.se/base'],
]) {
vi.stubEnv('GNUBOK_CONNECT_URL', dirty)
expect(getConnectorConfig()?.baseUrl, dirty).toBe(clean)
}
})
it('keeps a clean override byte-identical (no surprise normalization)', () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
vi.stubEnv('GNUBOK_CONNECT_URL', 'https://connect.example.se/base')
expect(getConnectorConfig()?.baseUrl).toBe('https://connect.example.se/base')
})
it('allows plain http for loopback development hosts only', () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
for (const ok of ['http://localhost:3000', 'http://127.0.0.1:3000']) {
+14 -1
View File
@@ -17,6 +17,11 @@ const log = createLogger('connect/config')
* ship the credential in plaintext. Plain http is allowed only for loopback
* hosts (local development against a dev server). An invalid or non-https
* URL disables the connector entirely (fail closed, nothing is sent).
*
* The returned baseUrl is rebuilt as origin + path: userinfo, query and
* fragment are stripped. They have no meaning in a base URL that gets paths
* appended to it, and /api/connector/status echoes baseUrl back to the
* operator, so anything secret-shaped pasted into the URL must not survive.
*/
export interface ConnectorConfig {
key: string
@@ -44,7 +49,15 @@ export function getConnectorConfig(): ConnectorConfig | null {
})
return null
}
return { key, baseUrl: raw }
const baseUrl = `${url.origin}${url.pathname.replace(/\/+$/, '')}`
if (baseUrl !== raw) {
// Log only the surviving value: the dropped parts are exactly what an
// operator might have pasted a credential into.
log.warn('GNUBOK_CONNECT_URL normalized to origin + path (userinfo/query/fragment stripped)', {
baseUrl,
})
}
return { key, baseUrl }
}
export function isConnectorConfigured(): boolean {
+7
View File
@@ -2930,6 +2930,8 @@
"needs_reconsent_message": "The Skatteverket connection needs to be renewed. Automatic syncing is paused until you reconnect with BankID.",
"missing_scope_message": "The connection lacks permission for one or more services, for example the tax account. Reconnect and approve all permissions on Skatteverket's consent page.",
"connect_waiting": "Waiting for BankID…",
"connect_requires_connector_key": "Connecting to Skatteverket requires a connector key or your own API credentials",
"connect_requires_subscription": "Connecting to Skatteverket requires a subscription",
"scope_momsdeklaration": "VAT declaration",
"scope_inkforetag": "Company information",
"scope_ska": "Tax account: balance & transactions",
@@ -6145,6 +6147,11 @@
"bank_sync_latency_hint": "Banks report transactions with up to 48 hours of delay. Today's transactions often only appear the next morning.",
"bank_sync_button_now": "Sync now",
"bank_sync_button_syncing": "Syncing…",
"bank_sync_requires_connector_key": "Bank sync requires a connector key or your own Enable Banking credentials",
"bank_sync_requires_subscription": "Bank sync requires a subscription",
"bank_sync_upsell_connector_key": "Requires a connector key (GNUBOK_CONNECTOR_KEY).",
"bank_sync_upsell_subscription": "Requires a subscription.",
"bank_sync_upsell_upgrade": "Upgrade",
"bank_sync_new_since_last_visit_one": "1 new bank transaction since your last visit",
"bank_sync_new_since_last_visit_many": "{count} new bank transactions since your last visit",
"bank_sync_new_since_last_visit_dismiss": "Dismiss",
+7
View File
@@ -2930,6 +2930,8 @@
"needs_reconsent_message": "Anslutningen till Skatteverket behöver förnyas. Den automatiska synkroniseringen är pausad tills du ansluter igen med BankID.",
"missing_scope_message": "Anslutningen saknar behörighet för en eller flera tjänster, till exempel skattekontot. Anslut igen och godkänn alla behörigheter på Skatteverkets samtyckessida.",
"connect_waiting": "Väntar på BankID…",
"connect_requires_connector_key": "Anslutning till Skatteverket kräver en connector-nyckel eller egna API-uppgifter",
"connect_requires_subscription": "Anslutning till Skatteverket kräver ett abonnemang",
"scope_momsdeklaration": "Momsdeklaration",
"scope_inkforetag": "Företagsinformation",
"scope_ska": "Skattekonto: saldo & transaktioner",
@@ -6145,6 +6147,11 @@
"bank_sync_latency_hint": "Banker rapporterar transaktioner med upp till 48 timmars fördröjning. Dagens transaktioner syns ofta först nästa morgon.",
"bank_sync_button_now": "Synka nu",
"bank_sync_button_syncing": "Synkar…",
"bank_sync_requires_connector_key": "Bankkoppling kräver en connector-nyckel eller egna Enable Banking-uppgifter",
"bank_sync_requires_subscription": "Bankkoppling kräver ett abonnemang",
"bank_sync_upsell_connector_key": "Kräver connector-nyckel (GNUBOK_CONNECTOR_KEY).",
"bank_sync_upsell_subscription": "Kräver abonnemang.",
"bank_sync_upsell_upgrade": "Uppgradera",
"bank_sync_new_since_last_visit_one": "1 ny banktransaktion sen ditt senaste besök",
"bank_sync_new_since_last_visit_many": "{count} nya banktransaktioner sen ditt senaste besök",
"bank_sync_new_since_last_visit_dismiss": "Stäng",