feat(connect): self-host connector enablement: EB/SKV in the preset, connector-mode seam, status endpoint (#1758)

* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector'

Sovereign plan WS3 PR3: ships dark, nothing changes for hosted.

- lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync,
  skatteverket, org_lookup, migration (services Accounted operates that a
  self-hosted instance cannot provide itself) + isConnectorCapability().
  Separate from PAID_CAPABILITIES and outside the trial-seed trigger on
  purpose: a hosted company can never hold a connector grant.
- lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key).
  Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins,
  else the grant lookup). Self-host: local capabilities always on
  (FORCE_PAYWALL included, as the existing test demands); connector
  capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the
  grant lookup where the connector sync will write source='connector' rows.
  getCompanyEntitlements on a self-host: local paid keys + active connector
  keys, state 'paid' with an active connector grant else 'none' (never the
  hosted trial copy).
- Migration 20260820122000: capability_grants.source CHECK gains
  'connector', found through pg_constraint (the CHECK was declared inline
  and auto-named; Postgres stores IN as = ANY, matched accordingly).
  pg-real test: connector accepted, unknown source rejected, upsert on the
  (scope, key, source) identity, trial seed writes no connector rows.
- Tests: self-hosted connector matrix (local all-on without DB, connector
  gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector
  keys only, bulk resolution, entitlements shape); two pre-existing tests
  that asserted the old "self-host holds connector keys" contract updated
  to the new one.

Verified: full unit suite green, pg-real suite for lib/entitlements green
against a local supabase/postgres with every migration applied, lint
ratchet, guards. Deferred to the instance-wiring PR: adding the connector
extensions to the self-host Docker preset (dead-end upsells until a key can
be issued).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(entitlements): fold the self-host branch into the existing grants query

One .or(scopeFilter), not two: the duplicated helper pushed the
no-phantom-columns unresolvable-expression count to 380/379. Behaviour is
unchanged; the self-host matrix tests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly

Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the
entitlement partition (#1747). Nothing is purchasable yet; this is the
plumbing both ends need before the first manually issued key.

Hosted side:
- Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix,
  org_number, pinned instance_url, scopes, status, Stripe ids,
  current_period_end, per-minute rate limit, active_company_count,
  last_seen/synced) and connector_usage_events (per-request metering,
  separate from metered_events whose company_id references hosted
  companies). RLS on, NO policies: service role only. RPC
  validate_and_increment_connector_key copies the api_keys pattern (FOR
  UPDATE, minute window, suspended reported not counted, revoked = no row)
  and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role.
  pg-real test covers validate/count, unknown+revoked, suspended, rate
  limit, execute privileges per role, RLS invisibility, usage cascade.
- lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts
  (generate/hash/validate -> 401/403/429 mapping),
  with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per
  request, 500 envelope on handler throw), /api/connect/entitlements GET +
  POST (records active_company_count, pins instance_url on first report,
  never moves a pinned one), scripts/issue-connector-key.ts (dry run unless
  --confirm, prints the key once + the .env lines).

Instance side:
- lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL
  default https://app.gnubok.se), sync.ts: reports the active company count
  and writes source='connector' grants for every company x covered scope,
  expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status
  deletes them (freeze-and-retain); network/5xx/429 leave them alone.
  /api/connector/sync/cron (hourly) runs it; not_configured without a key.
- Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json,
  with reasons) + drift tests; docker/crontab.self-hosted regenerated with
  the hourly sync. Docs (SELF-HOSTING connector section, env templates),
  DECISIONS.

Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes
and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests
run locally against supabase/postgres with every migration applied.
no-phantom-columns ceiling +1 with a reason (the bulk grant upsert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget

Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A
self-hosted instance with a `bank_sync`-scoped connector key can now connect
a bank through Arcim's PSD2 credentials; the bank session id and all
transaction data stay in the instance's own database (founder decision:
tokens on the instance, proxy stateless).

- Migration 20260820124000: `connector_connections` (secret-free ledger:
  sha256 of the EB session id + account uids, service-role only),
  `connector_upstream_counters` + RPC `connector_reserve_upstream` (global
  budget under EB Annex 1 §5's 300/min, shared with hosted), and
  `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs
  REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real
  covers all of it.
- EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core
  must not import @/extensions/); the extension re-exports it, tests
  unchanged.
- lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed
  connector state (15-min TTL) so the consent redirect can use OUR
  registered EB callback and bounce back to the instance, no per-instance
  redirect URI at EB; the callback route gains that connector branch.
- app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions,
  accounts/{uid}/{balances,transactions}), never open passthrough. POST
  /auth enforces the per-company connection quota + rewrites redirect/state;
  reads/deletes verify ledger ownership; every upstream call takes the
  global budget (429 + Retry-After when exhausted).
- issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of
  v1), --bank/skv-connections-per-company + --sync-min-interval.
- Docs (SELF-HOSTING: bank connector live), DECISIONS.

Verified: 52 connect unit tests + 13 pg-real (run locally against
supabase/postgres with all migrations) + EB extension suite (225, jwt
relocation intact); full unit suite 15 979 green; tsc, guards, lint clean.
Not in this PR: SKV broker (PR5b) and instance wiring (PR6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(connect): Skatteverket broker + data proxy for self-hosted instances (tokens stay on the instance)

Sovereign plan WS3 PR5b, stacked on the bank proxy (#1751). A self-hosted
instance with a `skatteverket`-scoped connector key can now run the BankID
consent, file VAT/AGI and sync skattekonto through Arcim's registered
Skatteverket client; the SKV tokens are returned to the instance and stored
(encrypted) there.

- lib/connect/upstreams/skatteverket-oauth.ts: core-side SKV OAuth + data
  helpers (authorize URL, code/refresh exchange with Arcim's client secret,
  the four backing-API base URLs, the API-gateway Client_Id/Client_Secret
  headers). Core can't import @/extensions/, so this duplicates the
  extension's endpoints/scope set (one integrator = Arcim), mirroring the EB
  JWT relocation.
- app/api/connect/skv/oauth/authorize-url: builds the authorize URL against
  OUR registered redirect_uri + a signed connector state, per-company SKV
  connection quota, pending ledger row.
- app/api/connect/skv/oauth/token: exchanges/refreshes and RETURNS the tokens
  to the instance; the ledger keeps only sha256(access_token) +
  sha256(refresh_token).
- app/api/connect/skv/api/[...path]: allowlist over moms / skattekonto /
  agd-inlamning / agd-period. The instance sends the user's SKV Bearer (as
  X-Connector-Upstream-Authorization) + X-Connector-Key; the proxy checks the
  token hash against the ledger, adds Arcim's gateway credentials (never
  exposed to the instance), forwards. Same per-key + global budget as bank.
- The Skatteverket extension /callback gains the connector branch
  (isConnectorState -> 302 back to the instance; code never exchanged there).
- Docs (SELF-HOSTING: SKV connector live) + DECISIONS.

Tests: SKV oauth lib, authorize-url, token, data proxy, callback connector
branch (all green; 74 connect + 425 connect/SKV). tsc, guards, lint clean;
no-phantom-columns held at 380 (literal update branches). Not in this PR:
instance-side wiring (PR6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(connect): self-host connector enablement: EB/SKV in the preset, connector-mode seam, status endpoint

Sovereign plan WS3 PR6 (enablement layer), stacked on the SKV broker (#1757).

- docker/extensions.self-hosted.json += enable-banking, skatteverket: a
  connector-key self-host now ships the bank + Skatteverket extensions; a key
  with the matching scope makes them work, without one they show the existing
  capability_blocked upsell (unconfigured extensions no-op).
- lib/connect/instance/upstreams.ts: 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). Hosted always has own credentials, so hosted
  is provably never in connector mode: the guard is what keeps hosted
  byte-identical. Base URLs GNUBOK_CONNECT_URL/api/connect/{bank,skv}, headers
  X-Connector-Company / X-Connector-Upstream-Authorization.
- GET /api/connector/status: the operator's wiring view (self_hosted, per
  upstream own_credentials|connector|unconfigured, key prefix never the key,
  granted connector capabilities). Hosted returns self_hosted:false.
- Docs (SELF-HOSTING: status endpoint + extensions ship in the image),
  DECISIONS.

Tests: connector-mode detection matrix (off without a key, off with own
creds incl. the _PRODUCTION EB variants, on via the proxy, CONNECT_URL
override) + status route (self-host vs hosted, unconfigured, per-upstream
mode, prefix-not-key). 83 connect/connector tests green; tsc, guards, lint.

DEFERRED to PR6b (needs a live connector key + a real bank/SKV to verify
end to end, touches the live consent path): wiring the EB api-client /
consent callback and the SKV oauth / api-client to call the proxy in
connector mode, and the "Synka nu" settings row (UI, needs visual sign-off).
The seam + preset + status route make PR6b a contained follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(connect): upstreams seam reuses lib/entitlements/own-credentials

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

* test(connector): status route tests pass the Next params argument (post-merge withRouteContext signature)

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

* docs(self-host): collapse the re-duplicated connector section; correct the crontab generator's preset comment

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

* fix(self-host): UpgradeNote and SKV tooltip name the connector key, never the hosted subscription; SOVEREIGN.md updated to merged reality

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

* fix(self-host): BankSyncNowButton gate copy branches like UpgradeNote (connector key, not hosted billing)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Jakob Wennberg
2026-08-31 23:18:43 +02:00
committed by GitHub
parent b643e6ce64
commit 50f13cf198
12 changed files with 287 additions and 20 deletions
+4
View File
@@ -1143,6 +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] 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] <decision>: <why>`. 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.
@@ -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<string, string> } }>(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<string, string>; 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<string, string> } }>(await GET(createMockRequest('/x'), { params: Promise.resolve({}) }))
expect(body.data.upstreams.bank).toBe('own_credentials')
expect(body.data.upstreams.skatteverket).toBe('connector')
})
})
+57
View File
@@ -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<typeof getCompanyIdsWithCapability>[0],
companyId: string,
): Promise<string[]> {
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
}
+25 -7
View File
@@ -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) => (
<div
className={cn(
'flex items-start gap-2.5 rounded-lg border border-border bg-secondary/40 px-3 py-2.5 text-sm text-muted-foreground',
@@ -17,12 +24,23 @@ export function UpgradeNote({ children, className }: { children?: React.ReactNod
)}
>
<Lock className="h-4 w-4 mt-0.5 shrink-0" />
<span>
{children ?? 'Den här funktionen kräver ett abonnemang.'}{' '}
<Link href="/settings/billing" className="underline underline-offset-2 text-foreground">
Uppgradera
</Link>
</span>
<span>{content}</span>
</div>
)
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.'}{' '}
<Link href="/settings/billing" className="underline underline-offset-2 text-foreground">
Uppgradera
</Link>
</>,
)
}
@@ -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
}
>
<ExternalLink className="mr-2 h-4 w-4" />
{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
}
>
<ExternalLink className="mr-2 h-4 w-4" />
{connecting ? t('connect_waiting') : t('reconnect')}
+19 -7
View File
@@ -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 ? (
<span className="text-xs text-muted-foreground">
Kräver abonnemang.{' '}
<a href="/settings/billing" className="underline underline-offset-2">
Uppgradera
</a>
</span>
selfHosted ? (
<span className="text-xs text-muted-foreground">Kräver connector-nyckel (GNUBOK_CONNECTOR_KEY).</span>
) : (
<span className="text-xs text-muted-foreground">
Kräver abonnemang.{' '}
<a href="/settings/billing" className="underline underline-offset-2">
Uppgradera
</a>
</span>
)
) : null
if (connections.length === 1) {
+1 -1
View File
@@ -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"]}
+2
View File
@@ -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
+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). 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
@@ -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()
})
})
+48
View File
@@ -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 }
}
+2 -2
View File
@@ -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