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

* 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>

* fix(entitlements): self-host connector gate honors only source=connector grants

The trial-seed trigger (seed_trial_capability_grants) writes 30-day
source='trial' rows for bank_sync and skatteverket on every company
insert, self-hosts included. The partitioned self-host gate read every
active grant, so a fresh self-host company held every connector
capability for a month with no connector key (CodeRabbit finding on
#1747, verified against migration 20260818170000).

hasCapability and getCompanyIdsWithCapability now add
.eq('source', 'connector') on a self-host; getCompanyEntitlements skips
non-connector rows there. Hosted reads every source exactly as before
(covered by a test asserting no source filter is applied).

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

* test(entitlements): pin the early-grants wave on the self-host partition, fix stale docstrings

Address the review round on the readGrants() merge resolution:

- Add two recording-mock tests for getCompanyEntitlements with teamId
  (the dashboard layout path): on a self-host the early grants read must
  narrow to the connector keys and carry source = 'connector'; on hosted
  it reads every paid key with no source filter. The in-loop source check
  masked a lost narrowing, so this pins the query itself.
- Move the getCompanyEntitlements docstring back above the function and
  replace "Self-hosted holds everything" with the actual partition.
- keys.ts: say that lib/connect/instance arrives with stack PR #1748 so a
  reader on main does not chase a module that is not there yet.
- DECISIONS.md: describe the InvoiceInboxWorkspace.tsx merge resolution
  accurately (set to the #1753 blob origin/main carried at push time, not
  the merged parent's) and note that stack children must merge this
  branch forward.

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

* chore(connector): merge origin/main, re-version connector migration to 20260831170000

Migration 20260820122000 predates versions already applied to prod
(latest 20260831150000); renamed to keep Supabase branching history
monotonic. Test reference updated.

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

* fix(entitlements): own-credentials seam so self-hosts running their own EB/SKV are never connector-gated

Forward-ports the connector-mode seam's own-credentials half from the
instance-wiring layer: a self-host with its own Enable Banking or
Skatteverket credentials holds that capability outright, exactly like
every other local capability. Without this, upgrading an own-credentials
self-host silently killed working bank sync and SKV integrations and
showed a hosted subscription upsell whose remedy does not exist for a
self-host. capability_blocked copy gains a self-host variant naming
GNUBOK_CONNECTOR_KEY; stale all-on comments updated at the two gate
call sites.

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

* fix(entitlements): route the pending-op capability block through capabilityBlockedError for self-host copy

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

* fix(entitlements): PR #1747 review batch: connector-grant expiry CHECK, test cleanup, DECISIONS correction

- Migration 20260831180000: CHECK (source <> 'connector' OR expires_at IS
  NOT NULL); connector grants are a short-lived offline cache, a NULL
  expiry would be a permanent unlock nothing revokes. pg test added.
- beforeEach vi.clearAllMocks() in the three entitlements test files.
- DECISIONS entry corrected: the trial seed DOES write trial rows for
  bank_sync/skatteverket; what it never writes is source='connector' or
  the connector-only keys.

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 17:55:29 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg Emil
parent 1d63e0f72b
commit cfce2de925
11 changed files with 883 additions and 39 deletions
+5
View File
@@ -1140,6 +1140,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-20] Reconciliation opens on the FULL year and keeps its own range-preset memory, separate from the shared report-family key. Inheriting a "Denna manad" preset last used on Resultatrapport would show an alarming difference for a window the user never chose on this page, and a part-year reconciliation window answers a question nobody asked.
[2026-08-20] The matcher now runs automatically once per window+account when there is unmatched work, instead of waiting for a button many users never found. It is a dry run: nothing is written, and Tillampa still requires an explicit click. ?autorun=1 keeps a distinct meaning (run even on a clean window) so the transactions-inbox deep link still produces a result rather than silence.
[2026-08-20] Unmatched bank rows that no voucher on the account could settle (direction-compatible and equal to the ore) get "Bokfor" linking to /transactions?highlight=<id> instead of a match picker. They are unbooked affarshandelser, not reconciliation work, and the picker held nothing for them. The rule is deliberately strict: a false negative offers booking on a pairable row (a legitimate outcome), a false positive sends the user into an empty picker.
[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).
[2026-08-20] Vercel build heap is raised through vercel.json `buildCommand` (`NODE_OPTIONS=--max-old-space-size=6144 npm run build`), not a project env var and not `build.env`: a project-level NODE_OPTIONS also reaches function runtime (V8 sizes the heap against a limit the function does not have), and `build.env` is marked deprecated in the vercel.json schema; `buildCommand` scopes the flag to the build exactly like core-build.yml's 8192 does for CI. 6144 fits the standard 4-core/8 GB build machine next to the main next process; the type-check needs ~4.5 GB and was hanging at V8's ~4 GB default ceiling (4 production timeouts 2026-08-14..20).
[2026-08-20] The production build type-checks tsconfig.build.json (tsconfig.json minus tests and mocks) via typescript.tsconfigPath; tsconfig.json stays the editor/ESLint view of the whole repo. Why: Next 16.3 runs the project-local tsc CLI by default (prerequisite for TypeScript 7's native checker, which has no JS API) and that checker checks the complete project it is given, whereas the old API checker silently dropped diagnostics from __tests__/*.test.* files. ~490 real type errors live in test files today (mostly route handlers called without the ctx argument); vitest never type-checks them, so nothing caught them. Excluding tests from the build keeps that debt where it was (invisible) instead of turning it into a red deploy; a separate tsc job for tests is the follow-up that makes it visible. Measured: tests are ~10% of the check's memory, so this is correctness, not the memory fix (that is the vercel.json heap bump).
@@ -1271,6 +1272,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent.
[2026-08-26] Support-dialog attachments use the existing email delivery path without storage or schema changes: this keeps the feature scoped to the contact form. The budget is 5 files / 4 MB total under the 4.5 MB hosted request-body ceiling, with client-side image shrinking when needed.
[2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/<mcp path>, and <mcp url>/.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised.
[2026-08-26] Self-host connector gate honors only capability_grants rows with source='connector' (hasCapability, getCompanyIdsWithCapability, getCompanyEntitlements): the trial-seed trigger grants bank_sync/skatteverket to every new company for 30 days, self-hosts included, which must not unlock connectors without a hosted key. Hosted reads every source as before.
[2026-08-26] npm publishing of packages/accounted-mcp and packages/gnubok-mcp is gated on "package.json version not on the registry" (push to main touching packages/*/package.json), not on git tags: the repo's v*.*.* tags belong to the Docker image, the bridges version independently, and a version gate makes re-runs and unrelated package.json edits no-ops. Auth is an NPM_TOKEN secret rather than npm trusted publishing (OIDC) because accounted-mcp has never been published and npm cannot bind a trusted publisher to a package that does not exist yet; --provenance still attaches the Sigstore attestation, which is why gnubok-mcp's repository.url had to move from erp-mafia/gnubok to erp-mafia/accounted (the registry rejects a mismatch).
[2026-08-26] Archived customers/suppliers hidden via archived_at IS NULL on every non-v1 list/picker (not is_active): customers have no is_active column and v1 already treats archived_at as canonical; is_active on suppliers stays a legacy mirror. MCP list tools got a bare include_archived boolean and the tools/list ceiling moved 60.7K to 60.8K instead of trimming unrelated tool prose: main had ~6 tokens of headroom, so any contract at all crossed.
[2026-08-26] MCP serverInfo.version, extension version and /api/health version reuse currentAppVersion() (12-char SHA, '1.0.0' fallback) instead of a new 7-char slice: one identifier across behandlingshistorik, health and MCP so a support thread can match a deploy by a single string; gnubok_get_vacation_balance got a real estimated_liability_sek by exporting semesterberedning's dayValueSek rather than dropping the description's promise, with descriptions trimmed to stay under the tools/list ceiling.
@@ -1343,6 +1345,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring.
[2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card.
[2026-08-29] Merge of main (#1946 readGrants/earlyGrants) into feat/connector-entitlements resolved by giving readGrants() a keys parameter and applying the source='connector' filter inside it, rather than re-inlining the grants query: the early-grants wave (teamId known) and the fallback read must narrow to the same key list or the self-host partition silently reads every paid key on the layout path. The in-loop source check in getCompanyEntitlements stays as a second line of defence (the unit mocks return rows regardless of filter, so it is also what the tests pin). Hosted query shape is unchanged: connectorGrantsOnly() is false there.
[2026-08-29] components/extensions/general/InvoiceInboxWorkspace.tsx set to the #1753 inline-proxy version in the feat/connector-entitlements merge (blob bdd7668c1, which origin/main carried at push time as 7e76961da; the merged parent 338ac4e91 still had the pre-#1753 <iframe> variant): the branch's earlier merge da71d38e2 had silently kept the old preview code (signed URL + <embed>) over main's fix, and git carried that mis-resolution forward as the PR's intent. The file is outside this PR's scope, so any diff against main there is a regression, not a change to keep. Stack children (#1748 onward) do not contain this merge and must merge feat/connector-entitlements forward before their CI is trusted.
[2026-08-29] Inbox underlag lock-state read names the FK (fiscal_periods!journal_entries_fiscal_period_id_fkey) instead of the bare embed: fiscal_periods has two FKs back to journal_entries (closing_entry_id, opening_balance_entry_id), so PostgREST rejected the embed as ambiguous and the first underlag-reconcile cron run (2026-08-29) classified 34 locked-period items as retryable and hit the period-lock trigger on every one; the select string is pinned in the unit test since mocks cannot see PostgREST ambiguity.
[2026-08-29] Issue #1661 resolved as option (a): a private marking (is_business=false) STAYS a real booking (eget uttag/insättning on 2013/2018, 2893 for an AB) and therefore stays blocked in a locked or closed period; the actionable path for a row that is no affärshändelse (PSD2 ghost row, duplicate from a reconnect, never-executed transfer) is ignore, which writes no verifikat and is allowed in any period. No ledger or schema semantics changed. A "no-booking private flag" (option b) was rejected: an owner withdrawal IS an affärshändelse under BFL 5 kap 1 § and must be booked, so skipping the verifikat for unbooked private rows would let real withdrawals vanish from a period with nothing in the ledger; the 2026-07-26 lock-guard entry already judged ignore the legal escape hatch and the DB CHECK transactions_is_ignored_no_journal_entry guarantees an ignored row is unbooked. Shipped as: TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED (400, details.suggested_action 'ignore') on all four categorize surfaces instead of a bare PERIOD_LOCKED, a v1 ignore verb (POST/DELETE /transactions/{id}/ignore), a staged MCP tool gnubok_ignore_transaction (op type ignore_transaction, risk low), and an "Ignorera i stället" toast action on the Transaktioner page. The regex-matched phrases in period-service.ts lock/close messages are untouched.
[2026-08-29] gnubok_ignore_transaction ships in the DEFAULT MCP catalog (payload ceiling 64.4K to 65K with a ledger entry, measured 64 863), not with catalogVisibility 'search' as the #1661 plan preferred: gnubok_call_tool bridges READ tools only, so a search-only WRITE is uncallable on Claude.ai (the 2026-08-26 connect-tools and 2026-08-27 update_customer entries), and the TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED remediation, three workflow loadouts and the reconcile-month skill instruct agents to CALL it; a search-only tool would reproduce exactly the dead end those entries fixed. Extending gnubok_categorize_transaction with an ignore flag was rejected: it would muddle a booking tool with triage (the error came from that very call), still needs the new op type, and the dashboard and v1 doors have a distinct verb. The read-demotion rule in the bench is skipped for the same reason as the set_run_salary entry: picking the read needs prod usage data.
@@ -1397,3 +1401,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-31] Own-company-as-supplier guard nulls the supplier block instead of flagging or substituting the issuer: an empty LEVERANTOR is always safe, a guessed issuer is not; BYO/agent-supplied extraction paths are deliberately exempt (explicit input, not a model misread).
[2026-08-31] gnubok-home-ok cache cookie is user-scoped (userId~host) instead of cleared on sign-out: sign-out happens client-side via supabase.auth.signOut so no server surface reliably sees it, while a value bound to the session's user makes any inherited verdict miss the cache by construction. Separator ~ because it is unreserved under encodeURIComponent AND a legal raw cookie octet, so the value round-trips identically whether or not the cookie layer percent-encodes. Old host-only cookies never match and self-heal; found via the amnas account-switch repro (two logins 9 s apart shared the verdict).
[2026-08-31] Bookkeeping digest email is per-user per-COMPANY per-day (not one aggregated mail across companies): notification_log.company_id anchors the claim, subject lines stay unambiguous, and most users have one company; consultants can opt in and get one short mail per client. Window is a fixed last-24h (cron cadence) rather than tracking last-sent state. Settings toggle stays hardcoded Swedish like the rest of the push-notifications extension UI (no next-intl wiring in extension components); revisit if that surface is ever translated.
[2026-08-31] Own-credentials seam forward-ported into the entitlement partition (skeptic refutation on PR #1747): a self-host serving bank_sync/skatteverket from its OWN env credentials (the same vars the extensions activate on) counts those as local capabilities, so upgrading an own-credentials self-host never dark-launches the connector gate against a working integration (the 2026-08-17 folded-flag incident shape). lib/entitlements/own-credentials.ts mirrors the connector-mode seam arriving in the instance-wiring PR (connector mode = key AND no own creds) and must stay in sync with it. org_lookup/migration have no own-credentials form. capability_blocked copy now has a self-host variant naming GNUBOK_CONNECTOR_KEY instead of the hosted subscription upsell, which misled operators toward a product they cannot buy for a self-host.