diff --git a/DECISIONS.md b/DECISIONS.md index d53fe463..53ce4b63 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1143,6 +1143,8 @@ One line per decision: `[YYYY-MM-DD] : `. 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// 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// 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). @@ -1416,3 +1418,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-31] POST /sessions binds the code exchange to its state's own pending row (Superagent P1 on PR #1751): verified signature + key/service match + existing pending row are preconditions for calling EB, and a concurrent consumption of the same state after exchange closes the upstream session and answers 409 instead of handing out a session the ledger never recorded. Full code-to-state binding at the callback (recording a code hash on the pending row) would need a column and is deferred; the pre-exchange binding plus one-shot pending->active activation removes the cross-key and stateless-exchange paths, and same-key crossover between an instance's own concurrent flows only relabels its own sessions. [2026-08-31] Ten rarely-called READ tools demoted to catalogVisibility 'search', reclaiming 3 763 tokens of the tools/list context budget (65 046 -> 61 283) and ratcheting the bench ceiling 65 000 -> 61 600. This is the demotion the payload bench deferred twice, explicitly, for want of prod usage data. Picked from 60 days of mcp.tool_called: default-catalog reads with <= 25 calls, excluding anything in RECOMMENDED_WORKFLOW_LOADOUTS. Usage data is necessary but NOT sufficient, and three classes were kept despite low counts: gnubok_call_tool (it IS the search-only bridge), the `connect_*` and `lookup_company` onboarding entries (a fresh agent has not learned to search yet, which is why #1936 put them in the catalog), and every arsredovisning/dispositioner/depreciation/accrual proposal tool (a 60-day window ending in August cannot see bokslut season, since most Swedish companies close 31 Dec and do the work Jan-Jun, so summer counts understate them to near zero). Widget tools are a hard exclusion, found the hard way: gnubok_receipt_matcher and gnubok_vat_review_widget were demoted in a first pass and their own suites failed, because a widget is rendered from the _meta.ui it publishes in tools/list and search-only hides it there. Added a general invariant so the next widget tool cannot repeat it. [2026-08-31] SKV broker hardening (two skeptic refutations on PR #1757, same classes as the bank fixes): the token route's code exchange now requires a verified connector state (signature, key, svc 'skv') plus an existing pending row before spending Arcim's client secret, and a concurrently consumed state withholds the tokens with 409 (SKV has no revoke endpoint; the pair expires unused); refresh requires the presented token's hash to match an ACTIVE ledger row under the presenting key (it was an open refresh oracle for any leaked token); the metering redaction gains a 10+-digit rule because personnummer/orgnr/redovisare12 in SKV data-proxy paths slipped the EB-tuned thresholds and rested in cleartext; authorize-url adopts the countHeldConnections reservation re-count; all SKV base URLs are https-only (loopback excepted). SELF-HOSTING.md's connector section collapsed from three contradictory copies (accreted across the stack merges) to one. +[2026-08-31] lib/connect/instance/upstreams.ts reuses lib/entitlements/own-credentials.ts instead of duplicating the own-credentials checks: the seam was forward-ported into the entitlement partition during the stack's bottom-up merge (skeptic refutation on PR #1747), and two copies of "what counts as own credentials" would eventually disagree, splitting the gate from the routing. upstreams.ts re-exports the two functions for its instance-side callers. +[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. diff --git a/app/api/connector/status/__tests__/route.test.ts b/app/api/connector/status/__tests__/route.test.ts new file mode 100644 index 00000000..1460eb89 --- /dev/null +++ b/app/api/connector/status/__tests__/route.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +let selfHosted = true +vi.mock('@/lib/env/public-flags', () => ({ isSelfHosted: () => selfHosted })) +vi.mock('@/lib/api/with-route-context', () => ({ + withRouteContext: (_op: string, handler: (req: unknown, ctx: unknown) => unknown) => (req: unknown) => + handler(req, { supabase: {}, companyId: 'company-1', user: { id: 'u1' } }), +})) +const held = vi.fn() +vi.mock('@/lib/entitlements/has-capability', () => ({ getCompanyIdsWithCapability: (...a: unknown[]) => held(...a) })) + +import { GET } from '../route' + +const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'ENABLE_BANKING_APP_ID', 'SKATTEVERKET_APIGW_CLIENT_ID'] as const +beforeEach(() => { + vi.clearAllMocks() + selfHosted = true + for (const k of ENV) vi.stubEnv(k, '') + held.mockResolvedValue(new Set()) +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('GET /api/connector/status', () => { + it('reports self_hosted:false on hosted', async () => { + selfHosted = false + const { body } = await parseJsonResponse<{ data: { self_hosted: boolean } }>(await GET(createMockRequest('/api/connector/status'), { params: Promise.resolve({}) })) + expect(body.data.self_hosted).toBe(false) + }) + + it('reports unconfigured when no key is set', async () => { + const { body } = await parseJsonResponse<{ data: { configured: boolean; upstreams: Record } }>(await GET(createMockRequest('/x'), { params: Promise.resolve({}) })) + expect(body.data.configured).toBe(false) + expect(body.data.upstreams).toEqual({ bank: 'unconfigured', skatteverket: 'unconfigured' }) + }) + + it('reports connector mode per upstream and the key prefix (never the key)', async () => { + vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_secretsecret') + held.mockImplementation((_s: unknown, _ids: unknown, cap: string) => Promise.resolve(cap === 'bank_sync' ? new Set(['company-1']) : new Set())) + const { body } = await parseJsonResponse<{ data: { configured: boolean; key_prefix: string; upstreams: Record; granted_capabilities: string[] } }>(await GET(createMockRequest('/x'), { params: Promise.resolve({}) })) + expect(body.data.configured).toBe(true) + expect(body.data.key_prefix).toBe('gnubok_ck_sec') + expect(body.data.key_prefix).not.toContain('secretsecret') + expect(body.data.upstreams).toEqual({ bank: 'connector', skatteverket: 'connector' }) + expect(body.data.granted_capabilities).toEqual(['bank_sync']) + }) + + 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') + const { body } = await parseJsonResponse<{ data: { upstreams: Record } }>(await GET(createMockRequest('/x'), { params: Promise.resolve({}) })) + expect(body.data.upstreams.bank).toBe('own_credentials') + expect(body.data.upstreams.skatteverket).toBe('connector') + }) +}) diff --git a/app/api/connector/status/route.ts b/app/api/connector/status/route.ts new file mode 100644 index 00000000..7d1bade2 --- /dev/null +++ b/app/api/connector/status/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { isSelfHosted } from '@/lib/env/public-flags' +import { getConnectorConfig } from '@/lib/connect/instance/config' +import { bankConnectorMode, skatteverketConnectorMode } from '@/lib/connect/instance/upstreams' +import { CONNECTOR_CAPABILITIES } from '@/lib/entitlements/keys' +import { getCompanyIdsWithCapability } from '@/lib/entitlements/has-capability' + +/** + * GET /api/connector/status: the self-hosted operator's "is the connector + * wired up" view. Reports whether a connector key is configured, which + * upstreams are in connector mode (routed through the hosted proxy) vs run on + * the instance's own credentials, and, for the caller's own company, which + * connector capabilities are currently granted (written by the hourly sync). + * + * 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. + */ +export const GET = withRouteContext('connector.status', async (_request, { supabase, companyId }) => { + if (!isSelfHosted()) { + return NextResponse.json({ data: { self_hosted: false } }) + } + const cfg = getConnectorConfig() + const bank = bankConnectorMode() + const skv = skatteverketConnectorMode() + + const grants = companyId + ? await getConnectorGrantsFor(supabase, companyId) + : [] + + return NextResponse.json({ + data: { + self_hosted: true, + configured: !!cfg, + connect_url: cfg?.baseUrl ?? null, + key_prefix: cfg ? cfg.key.slice(0, 13) : null, + upstreams: { + bank: cfg ? (bank ? 'connector' : 'own_credentials') : 'unconfigured', + skatteverket: cfg ? (skv ? 'connector' : 'own_credentials') : 'unconfigured', + }, + granted_capabilities: grants, + }, + }) +}) + +async function getConnectorGrantsFor( + supabase: Parameters[0], + companyId: string, +): Promise { + const held: string[] = [] + for (const cap of CONNECTOR_CAPABILITIES) { + const ids = await getCompanyIdsWithCapability(supabase, [companyId], cap) + if (ids.has(companyId)) held.push(cap) + } + return held +} diff --git a/components/billing/UpgradeNote.tsx b/components/billing/UpgradeNote.tsx index 3357846d..1df419a4 100644 --- a/components/billing/UpgradeNote.tsx +++ b/components/billing/UpgradeNote.tsx @@ -1,15 +1,22 @@ import Link from 'next/link' import { Lock } from 'lucide-react' import { cn } from '@/lib/utils' +import { isSelfHosted } from '@/lib/env/public-flags' /** * Inline paywall note for a feature that is visible but not entitled. * The feature stays on screen (conversion surface: never hide, disable * with an upsell), this note explains why it's disabled and links to * /settings/billing. Copy mirrors CAPABILITY_BLOCKED_MESSAGE_SV. + * + * On a SELF-HOST the note never mentions a subscription or links to the + * hosted Stripe billing page: the only capabilities that can be blocked + * there are the connector ones (everything local is always on), and their + * remedy is a connector key or the instance's own upstream credentials. + * Copy mirrors CAPABILITY_BLOCKED_MESSAGE_SELF_HOSTED_SV. */ export function UpgradeNote({ children, className }: { children?: React.ReactNode; className?: string }) { - return ( + const frame = (content: React.ReactNode) => (
- - {children ?? 'Den här funktionen kräver ett abonnemang.'}{' '} - - Uppgradera - - + {content}
) + if (isSelfHosted()) { + return frame( + <> + Den här funktionen kräver en connector-nyckel från Accounted (GNUBOK_CONNECTOR_KEY) eller + instansens egna API-uppgifter för tjänsten. + , + ) + } + return frame( + <> + {children ?? 'Den här funktionen kräver ett abonnemang.'}{' '} + + Uppgradera + + , + ) } diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index d9781293..2dd6db76 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' import { useCapability } from '@/contexts/CompanyContext' import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin' +import { isSelfHosted } from '@/lib/env/public-flags' import { CAPABILITY } from '@/lib/entitlements/keys' import { UpgradeNote } from '@/components/billing/UpgradeNote' import { @@ -305,7 +306,13 @@ function SkatteverketPersonalConnectionCard() { size="sm" onClick={startConnect} disabled={status?.disabled || !hasSkatteverket || connecting} - title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined} + title={ + !hasSkatteverket + ? isSelfHosted() + ? 'Anslutning till Skatteverket kräver en connector-nyckel eller egna API-uppgifter' + : 'Anslutning till Skatteverket kräver ett abonnemang' + : undefined + } > {connecting ? t('connect_waiting') : t('connect_with_bankid')} @@ -364,7 +371,13 @@ function SkatteverketPersonalConnectionCard() { size="sm" onClick={startConnect} disabled={status.disabled || !hasSkatteverket || connecting} - title={!hasSkatteverket ? 'Anslutning till Skatteverket kräver ett abonnemang' : undefined} + title={ + !hasSkatteverket + ? isSelfHosted() + ? 'Anslutning till Skatteverket kräver en connector-nyckel eller egna API-uppgifter' + : 'Anslutning till Skatteverket kräver ett abonnemang' + : undefined + } > {connecting ? t('connect_waiting') : t('reconnect')} diff --git a/components/transactions/BankSyncNowButton.tsx b/components/transactions/BankSyncNowButton.tsx index 7176a608..b902503f 100644 --- a/components/transactions/BankSyncNowButton.tsx +++ b/components/transactions/BankSyncNowButton.tsx @@ -30,6 +30,7 @@ import { } from '@/lib/transactions/bank-sync-store' import { useCompany, useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' +import { isSelfHosted } from '@/lib/env/public-flags' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' export type { BankConn } @@ -245,14 +246,25 @@ export default function BankSyncNowButton() { // Bank sync (and reconnect) is a paid external PSD2 call. Without the // capability we keep the button VISIBLE as the conversion surface but inert, // and surface an Uppgradera link. CSV/SIE import stays free (separate UI). - const gateTitle = !hasBankSync ? 'Bankkoppling kräver ett abonnemang' : undefined + // Self-host: the remedy is a connector key (or own EB credentials), never + // the hosted Stripe billing page; same branch as UpgradeNote. + const selfHosted = isSelfHosted() + const gateTitle = !hasBankSync + ? selfHosted + ? 'Bankkoppling kräver en connector-nyckel eller egna Enable Banking-uppgifter' + : 'Bankkoppling kräver ett abonnemang' + : undefined const upsellNote = !hasBankSync ? ( - - Kräver abonnemang.{' '} - - Uppgradera - - + selfHosted ? ( + Kräver connector-nyckel (GNUBOK_CONNECTOR_KEY). + ) : ( + + Kräver abonnemang.{' '} + + Uppgradera + + + ) ) : null if (connections.length === 1) { diff --git a/docker/extensions.self-hosted.json b/docker/extensions.self-hosted.json index 1cc00025..82d7f618 100644 --- a/docker/extensions.self-hosted.json +++ b/docker/extensions.self-hosted.json @@ -1 +1 @@ -{"extensions": ["email", "invoice-inbox", "document-extraction", "push-notifications", "calendar", "mcp-server"]} +{"extensions": ["email", "invoice-inbox", "document-extraction", "push-notifications", "calendar", "mcp-server", "enable-banking", "skatteverket"]} diff --git a/docs/SELF-HOSTING.md b/docs/SELF-HOSTING.md index 59d7b7e2..c02aa09b 100644 --- a/docs/SELF-HOSTING.md +++ b/docs/SELF-HOSTING.md @@ -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, 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). +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. + ### Push Notifications ```bash diff --git a/docs/SOVEREIGN.md b/docs/SOVEREIGN.md index 3b21bb30..2c93fff0 100644 --- a/docs/SOVEREIGN.md +++ b/docs/SOVEREIGN.md @@ -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). That connector-key registry is proposed (PRs #1747, #1748, #1751, #1757 and #1758, none merged) and **not yet available**: today there is no instance-side variable to set and nothing to subscribe to, and the extensions' settings screens will tell you those services are unconfigured. +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. ## 3. Choosing Swedish infrastructure diff --git a/lib/connect/instance/__tests__/upstreams.test.ts b/lib/connect/instance/__tests__/upstreams.test.ts new file mode 100644 index 00000000..1732e545 --- /dev/null +++ b/lib/connect/instance/__tests__/upstreams.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { + bankConnectorMode, + skatteverketConnectorMode, + hasOwnEnableBankingCredentials, + 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 + +afterEach(() => vi.unstubAllEnvs()) +function clear() { + for (const k of ENV) vi.stubEnv(k, '') +} + +describe('connector-mode detection', () => { + it('is off when no connector key is set (hosted, or a self-host without a subscription)', () => { + clear() + vi.stubEnv('ENABLE_BANKING_PRIVATE_KEY', 'pk') + expect(bankConnectorMode()).toBeNull() + expect(skatteverketConnectorMode()).toBeNull() + }) + + // The load-bearing guard: an instance (or hosted) that has its OWN + // credentials never routes through the proxy, even with a key present. + it('is off for an upstream the instance has its own credentials for', () => { + clear() + vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x') + vi.stubEnv('ENABLE_BANKING_APP_ID', 'app-id') + vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', 'gw') + expect(hasOwnEnableBankingCredentials()).toBe(true) + expect(hasOwnSkatteverketCredentials()).toBe(true) + expect(bankConnectorMode()).toBeNull() + expect(skatteverketConnectorMode()).toBeNull() + }) + + it('routes to the hosted proxy when a key is set and no own credentials exist', () => { + clear() + vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x') + expect(bankConnectorMode()).toEqual({ baseUrl: 'https://app.gnubok.se/api/connect/bank', key: 'gnubok_ck_x' }) + expect(skatteverketConnectorMode()).toEqual({ baseUrl: 'https://app.gnubok.se/api/connect/skv', key: 'gnubok_ck_x' }) + }) + + it('honours GNUBOK_CONNECT_URL and strips a trailing slash', () => { + clear() + vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x') + vi.stubEnv('GNUBOK_CONNECT_URL', 'https://connect.example.se/') + expect(bankConnectorMode()?.baseUrl).toBe('https://connect.example.se/api/connect/bank') + }) + + it('treats the _PRODUCTION EB variants as own credentials', () => { + clear() + vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x') + vi.stubEnv('ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'pk') + expect(bankConnectorMode()).toBeNull() + expect(skatteverketConnectorMode()).not.toBeNull() + }) +}) diff --git a/lib/connect/instance/upstreams.ts b/lib/connect/instance/upstreams.ts new file mode 100644 index 00000000..ba862913 --- /dev/null +++ b/lib/connect/instance/upstreams.ts @@ -0,0 +1,48 @@ +import { getConnectorConfig } from './config' +import { + hasOwnEnableBankingCredentials, + hasOwnSkatteverketCredentials, +} from '@/lib/entitlements/own-credentials' + +/** + * Instance-side connector routing for the bank and Skatteverket upstreams. + * + * An upstream is in "connector mode" when this instance has a connector key + * AND no own credentials for that upstream. Hosted always has its own + * credentials, so hosted is never in connector mode: the check is what keeps + * hosted byte-identical. A self-host that pastes GNUBOK_CONNECTOR_KEY and + * leaves ENABLE_BANKING_PRIVATE_KEY / SKATTEVERKET_OAUTH2_CLIENT_ID unset gets + * routed through the hosted proxy instead. + * + * The own-credentials checks live in lib/entitlements/own-credentials.ts: + * they were forward-ported into the entitlement partition (PR #1747) so the + * gate and this routing seam can never disagree about what "own credentials" + * means. Re-exported here for the instance-side callers. + */ + +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 interface ConnectorUpstream { + /** Base URL to send upstream requests to (the hosted proxy). */ + baseUrl: string + /** The connector key, sent as Authorization: Bearer for proxy auth. */ + key: string +} + +export function bankConnectorMode(): ConnectorUpstream | null { + if (hasOwnEnableBankingCredentials()) return null + const cfg = getConnectorConfig() + if (!cfg) return null + return { baseUrl: `${cfg.baseUrl}/api/connect/bank`, key: cfg.key } +} + +export function skatteverketConnectorMode(): ConnectorUpstream | null { + if (hasOwnSkatteverketCredentials()) return null + const cfg = getConnectorConfig() + if (!cfg) return null + return { baseUrl: `${cfg.baseUrl}/api/connect/skv`, key: cfg.key } +} diff --git a/scripts/generate-crontabs.ts b/scripts/generate-crontabs.ts index 0d166036..a0c40bda 100644 --- a/scripts/generate-crontabs.ts +++ b/scripts/generate-crontabs.ts @@ -61,7 +61,7 @@ const CURL_PREFIX = 'curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_UR * Empty, and that is the reviewed answer rather than an oversight: * * - Extension endpoints stay in, including extensions the self-hosted preset - * does not enable. docker/extensions.self-hosted.json turns on five + * does not enable. docker/extensions.self-hosted.json turns on eight * extensions, but the preset only drives the runtime registry: the * Dockerfile builds the whole app/ tree, so every extension cron route is * compiled into the image either way. And every one of them answers HTTP @@ -72,7 +72,7 @@ const CURL_PREFIX = 'curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_UR * cadence, and a self-hoster who enables it later does not have to discover * that the schedule was never there. This also matches the pre-existing * intent: the hand-written crontabs already listed enable-banking and - * skattekonto, neither of which is in the self-hosted preset. + * skattekonto, both of which are now in the self-hosted preset (unconfigured extensions no-op). * - /api/sandbox/cleanup/cron stays in. The sandbox is a database flag * (company_settings.is_sandbox), not a hosted-only build flag, and the * cleanup_expired_sandbox_users RPC ships in supabase/migrations