Commit Graph

1495 Commits

Author SHA1 Message Date
Mattsson 6a85efb00a feat(mcp): allowlist Grok's connector callback and document the Grok path (#2158)
* feat(mcp): allowlist Grok's connector callback and document the Grok path

Grok custom connectors self-register through /api/mcp-oauth/register with
redirect_uri https://grok.com/connectors-oauth-exchange-code/, which the
built-in allowlist rejected with invalid_redirect_uri before consent. Add
the callback as an exact-path BUILT_IN_PATTERNS entry (trailing slash
optional, no prefix) with provider 'grok', named "Grok (xAI)" on the
consent page. Tests: accept, foreign-host and other-path rejection,
provider mapping, and a register route test for the Grok DCR shape.

Surface Grok next to ChatGPT: a "Using Grok?" side door on the onboarding
Claude step (one side door open at a time, telemetry step grok), a Grok row
under "Other clients" in the API & MCP settings tab using ?client=grok, and
sv/en strings for both. Docs: mcp-server rule, ARCHITECTURE, README,
registry entry (install section), DECISIONS.

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

* fix(mcp): cite X Corp's published Grok callback, test the consent label

Review pass on #2158: the allowlist comment and DECISIONS entry claimed
xAI publishes no callback and the value came from a live observation; X
Corp lists https://grok.com/connectors-oauth-exchange-code/ as the "Grok
(web)" redirect URL at docs.x.com/x-ads-api/mcp, and grok.com serves the
path itself (slash form 308s to no-slash on the same origin). Reworded
both to cite that. Adds the consent-page test for "Grok (xAI)" next to
the ChatGPT one and a JSDoc on the onboarding side-door toggle.

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:42:39 +02:00
Jakob Wennberg 61a76b1669 feat(parties): phase 0 prerequisites, pg_trgm and golden-set draw (#2157)
* feat(parties): phase 0 prerequisites, pg_trgm and golden-set draw

Phase 0 of the Kontakter plan: make the counterparty resolver measurable
before building it.

- Migration 20260902120000 enables pg_trgm (trigram blocking of
  counterparty keys) and drops the two context-graph tables from
  20260706193007 whose feature code was never merged and which prod no
  longer has, so fresh replays agree with prod.
- tests/pg/parties-phase0.pg.test.ts pins the extension, a sanity check on
  trigram ranking, and the absence of the graph tables.
- scripts/parties/draw-golden-set.sql is the reproducible, read-only draw
  of the 200-key labelling sample (three strata, md5-ordered) and the
  payee-identity base rate. The drawn rows contain customer voucher text
  and are kept in gitignored dev_docs, never in this public repo.
- scripts/parties/README.md records the label vocabulary and the numbers
  measured on prod on 2026-09-02.

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

* fix(archive): drop the two context-graph tables from the archive contract

The migration in this PR removes graph_counterparties and
graph_transaction_counterparties, so the full-archive contract must stop
classifying them: tests/schema/no-phantom-columns.test.ts asserts that
every classified table exists in the migration replay, and the live-DB
twin in tests/pg/full-archive-coverage.pg.test.ts asserts the same against
information_schema.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:33:45 +02:00
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00
Jakob Wennberg 18cbc4c30a fix(security): audit remediation 2026-09-01: api_keys identity, viewer gates, OAuth binding, XSS, MFA gate (#2155)
* fix(security): bind api_keys to the caller, lock hash-as-bearer RPCs and provider token tables

Security audit 2026-09-01, critical items.

- api_keys INSERT requires user_id = auth.uid() again (an admin could
  forge a key for any co-member and act as them in every company they
  belong to); SELECT is own-keys-or-admin; a BEFORE trigger freezes the
  identity and credential columns against user-session UPDATEs.
- rotate_mcp_refresh_token and validate_and_increment_api_key become
  service_role only: they match rows by a presented SHA-256, so a hash
  readable by co-members was a bearer credential.
- validate_and_increment_api_key fails closed when the key's user is no
  longer a member of the key's company.
- provider_consent_tokens and provider_otc: the DELETE policies collapsed
  to "caller has any team row" (correlated subquery on a non-existent
  team_members.company_id). All member policies dropped; service_role
  only, matching every existing code path.

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

* fix(security): role gates, ownership guards and posting integrity in the database

Security audit 2026-09-01, high items at the database layer.

- One table-level guard, enforce_company_writer_role(), blocks the
  read-only viewer role on 55 company-scoped tables including through
  the 15 membership-only SECURITY DEFINER writers. Keyed on the JWT role
  claim so it fires inside definer bodies; no-op for service_role and
  trigger cascades.
- company_members user_id/company_id immutable from user sessions;
  invitations can never grant owner; team_members gains a transition
  guard (admins keep non-owner role moves); companies team_id and
  archiving are owner-only and team attachment needs team membership.
- Direct statements (current_user = authenticated) can no longer insert
  posted headers, add lines under posted verifikat, or post a draft with
  a voucher number the sequence never issued. Sanctioned RPCs run as the
  definer and are untouched; the engine's own draft-then-post shapes
  still pass.
- create_document_version refuses viewers and foreign storage paths;
  validate_version_chain needs membership and loses anon EXECUTE;
  match_documents / match_booking_templates lose anon; cron maintenance
  RPCs become service_role only; the production-only
  seed_asset_categories is dropped.

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

* build: pin tsx as an exact devDependency instead of fetching it with npx at build time

prebuild ran "npx tsx" with no lockfile entry, so every Vercel, Docker
and CI build downloaded tsx@latest and its transitive tree from the
registry with no integrity check, inside the build environment.

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

* fix(security): refuse the viewer role on API-key and MCP write paths

The v1 wrapper and the MCP company routing checked company membership
but never role, and both run as service role, so a read-only viewer
holding an API key could post vouchers and change settings through the
API. Mutating methods and non-read scopes now return 403 ROLE_READ_ONLY
for viewers on v1; MCP write tools refuse viewers the same way.

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

* fix(security): stop serving uploaded SVG, XML and HTML as executable content on the app origin

Uploads persisted the browser-declared mime type and the inline proxy
served it verbatim, sandboxing only text/html; the storage proxy
forwarded the uploader's Content-Type. Any writer, or any Peppol sender,
could plant a scripted SVG or XHTML that executed on app.gnubok.se.

- inline route: allow-list of natively safe types (PDF, raster images)
  served as before; everything else gets the opaque sandbox CSP.
- storage proxy: octet-stream + attachment + sandbox unless the DB
  mime for the key is on the allow-list.
- document-service: the stored mime is the magic-byte validated type.
- logo upload: magic-byte validation, SVG refused.

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

* fix(security): byrå brand logo upload decides the type by magic bytes and drops SVG

Same pattern as the company logo route: the logos bucket is public, so a
scripted SVG (or anything declared as an image) must never land there.
The upload pickers stop advertising SVG.

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

* fix(security): bind Enable Banking, Stripe and WooCommerce callbacks to the initiating user

The callbacks resolved the pending row by oauth_state alone, so a
victim who completed an attacker-initiated consent had their bank
account, merchant account or store attached to the attacker's company.
requireFlowInitiator() now requires the cookie session of the user who
started the flow: no session redirects to login with the callback URL
preserved, a different user is refused and nothing is exchanged.

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

* fix(security): guard tenant-controlled outbound fetches and surface the disabled rate limiter

WooCommerce and Shopify syncs fetched a member-editable store URL with
plain fetch() and redirect following under the service role, and the
invoice PDF renderer fetched company_settings.logo_url unguarded. All
three go through a new safeFetch() (public-IP validation via url-guard,
https only, redirect: 'manual', body size cap) and re-normalise the
stored host at use time. checkRateLimit() keeps failing open on hosted
but logs one error per process when Upstash is not configured and
exports isRateLimiterConfigured().

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

* fix(security): decide the API MFA gate from server-authenticated factors, not the session cookie

getAuthenticatorAssuranceLevel() without arguments derives nextLevel
from session.user.factors, which comes from the unsigned sb-*-auth-token
cookie. Deleting factors from the cookie made an enrolled account look
like it had nothing to step up to, on every /api route and in
requireAuth. Both gates now read factors from the getUser() result or
listFactors() and the level from the verified JWT claim, and fail closed
on errors. Page-branch gate hardened the same way.

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

* fix(security): bind Fortnox/Visma, Gmail and Skatteverket callbacks to the initiating user

The arcim-migration callback exchanged the provider code onto whatever
consent the one-time state named, with no check of who completed the
flow and no org-number comparison, so a phished Fortnox admin handed
their ledger to the attacker's company. provider_otc now records the
initiating user (migration 20260902100000); the callback requires that
session and, after the exchange, refuses a provider company whose org
number differs from the consent's company. The Gmail and Skatteverket
callbacks enforce the same initiator check.

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

* fix(security): BankID signup confirms the email before linking the identity

Signup created an email-confirmed, MFA-exempt account for any address
the caller typed and returned a magic link, so an attacker could
pre-register a victim's email and keep a permanent BankID login into the
account the victim later adopted. The user is now created unconfirmed,
the identity carries email_verified_at NULL (migration 20260902101000),
bankid_linked is not set until the mailed confirmation is clicked, and
BankID login of a pending identity is refused with the confirmation
re-sent.

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

* fix(security): bind MCP OAuth redirect URIs to the consenting user and cap scopes

A user-registered redirect URI was allowlisted globally, the consent page
named no client, and all scopes were pre-checked, so one phishing link
handed an attacker a full-scope key for the victim's company. Registered
URIs now resolve only for the registrant or a colleague sharing a
company; the consent page shows the client identity and redirect host;
non-built-in clients default to read-only pre-checks; scopes are capped
by the user's role (viewer: read only) at consent and at /token.

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

* fix(auth): client follow-ups for BankID confirmation, callback mismatch copy and decision log

- register client handles the new confirmation_sent response from BankID
  signup with the existing inbox screen instead of calling verifyOtp.
- BankID login surfaces the email_unconfirmed explanation.
- WooCommerce settings map woocommerce_error=wrong_user to its own copy.
- Logo help text no longer advertises SVG.
- DECISIONS.md records the audit remediation choices.

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

* fix(mcp-oauth): literal SoD columns in the api_keys insert so the phantom-column scanner resolves them

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

* test(logo): type the upload fixtures as Uint8Array<ArrayBuffer> so they are valid BlobParts

Fixes the typecheck ratchet on PR #2155 and ratchets the baseline down
by the one legacy error the change removed.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:38:30 +02:00
Jakob Wennberg 6e8d76a9cb fix(skattekonto): remove the drift email, its event and the unused drift route (#2149)
The nightly skattekonto sync emailed "Skattekontot stämmer inte med
bokföringen" whenever Skatteverket's saldo differed from BAS 1630 by more
than 1 kr, every 24 hours while it lasted. On 2026-09-02 it fired on a
35 842 kr gap that the reconciliation explained to the last krona with 14
unbooked rows, while the Hem notice and the reconciliation page (both
gated on unexplained_difference) said nothing was wrong.

The check shipped in May 2026 (#525) before any in-app skattekonto view
existed; the dashboard tile its comments promise was never built and the
drift API route had no consumer. Since 2026-08-25 the reconciliation page
and the Hem notice are the surface, with one definition of "stämmer inte".

Removed: skattekonto-drift.ts, skattekonto-drift-email.ts, their tests,
the skattekonto.drift_detected event type, the handler registration, the
cron's drift hook, GET /api/extensions/skatteverket/skattekonto/drift, and
the ROPA activity for the mail. The route is dropped from the ungated
extension route allowlist to lock the ratchet. skattekonto_drift_tolerance
stays: the Hem notice reads it. Stale skattekonto_drift_last_alert_at rows
in extension_data are inert.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:28:55 +02:00
dependabot[bot] 5b64df9c80 build(deps-dev): bump browserslist from 4.28.1 to 4.28.8 (#2151)
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.1 to 4.28.8.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.1...4.28.8)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 11:28:32 +02:00
dependabot[bot] 2b8e0f2d66 build(deps): bump dompurify from 3.4.12 to 3.4.14 (#2154)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.12 to 3.4.14.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.12...3.4.14)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 11:28:09 +02:00
dependabot[bot] 3b7d2f89f2 build(deps-dev): bump flatted from 3.3.3 to 3.4.4 (#2153)
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.4.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.4)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 11:27:46 +02:00
dependabot[bot] 73897c3d1a build(deps): bump brace-expansion (#2152)
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 1.1.12 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.18)

Updates `brace-expansion` from 2.0.2 to 2.1.4
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.18)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 2.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 11:27:22 +02:00
Mattsson 3100161e7b docs(sovereign): mark Skatteverket connector client wiring as shipped (#2156)
The bank and Skatteverket instance-side client wiring is now merged
(#2094, #2103), so the doc no longer describes SKV wiring as pending.
Keys remain not-yet-issued until a staging end-to-end run confirms the
full flow.


Claude-Session: https://claude.ai/code/session_01GZs9M1wfu7Ad3QcvwcYo13

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 11:16:16 +02:00
Mattsson 867767a22f feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129) (#2148)
* feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129)

Phase 1: every inbox row shows its document kind (Kvitto, Leverantorsfaktura, Myndighetsbrev, Ovrigt) from the existing AI documentKind, and a second menu next to the status filter narrows the list to leverantorsfakturor or underlag. Pure predicate in lib/documents/inbox-kind.ts with tests.

Phase 2: the shared inbox address accepts RFC 5233 plus-addressing. The webhook splits the local part at the first + and looks up the base, so <local>+anything@ now reaches the company instead of 404ing. +lev and +ver land in the new nullable invoice_inbox_items.kind_hint column (CHECK supplier_invoice | receipt), threaded through EmailMeta into both inbox inserts and returned by GET /items. kind_hint wins over documentKind for the badge and the filter and survives re-extraction because it is a column. The sources panel shows both tagged addresses with a one-line hint (sv + en).

Tests: filter predicate per kind and null; parser and tag mapping; webhook routes +LEV and an unknown tag; pg test pins the CHECK and NULL default.

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

* fix(inbox): honest empty state under a type filter, detail pane shares the row's kind resolution

Skeptic findings on #2148: with a type filter narrowing 'Att göra' to zero the empty state claimed 'allt är bearbetat' while the status trigger still counted pending rows; it now says no items of that type are here (sv + en). The fields rail printed the AI documentKind only, so a +lev hint could disagree with the row badge; it now uses resolveInboxKind like the list.

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

* fix(inbox): keep the type-filter empty state off purchase lists, carry kind_hint onto rejected attachment rows

CodeRabbit on #2148: the purchase lists (Saknar underlag, Hämta från portal) ignore the type menu, so a leftover kind filter must not pick their empty-state copy. A rejected attachment (unsupported MIME, too large) now keeps the sender's +lev / +ver hint on its error row like every other inbox insert; the allowlist test covers it.

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

* fix(inbox): set the +lev/+ver kind hint only when the shared address resolved the company

CodeRabbit on #2148: the hint was computed before recipient resolution, so a tag on an unknown or retired shared address could ride along onto a custom-domain match. It is now assigned inside the active shared-inbox branch only; regression test covers the multi-recipient case.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 08:50:42 +02:00
Mattsson 4c76fb10d7 feat(transactions): "Ta bort underlag" detach action on a transaction (#2132) (#2144)
* feat(transactions): "Ta bort underlag" detach action on a transaction (#2132)

Wrong receipt pinned, no way back: the DELETE
/api/transactions/[id]/attach-document route and its tests already existed,
but nothing in the UI called it. This wires it up, frontend only.

- Inbox card and history list: "Ta bort underlag" in the row menu, shown only
  for writers on unbooked rows that carry a pin (canDetachDocument helper).
- Attach dialog: a small "Ta bort underlag" link beside the already-attached
  hint, the one place the app previously admitted a doc was pinned.
- Page: handleDetachDocument confirms (useDestructiveConfirm, warning), then
  DELETEs; 200 clears document_id in local state (list, dialog snapshot, and
  the inbox card's optimistic override via a -unlinked window event) and
  toasts; 409 renders the route's Swedish BFL message verbatim; other errors
  map through get-error-message.
- Strings under tx_detach in sv.json and en.json.
- Tests: gate hidden when booked / read-only / no pin / no handler; 409
  rendered unchanged; wiring and locale assertions.

Out of scope, follow-up: MCP detach tool (new pending-op type + CHECK
migration), detaching from the inbox for non-email docs, and clearing
invoice_inbox_items.matched_transaction_id on detach so the doc is offered
again by inbox-available.

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

* fix(transactions): clear the inbox back-link when detaching underlag (#2132)

Skeptic finding on PR #2144: DELETE attach-document nulled only
transactions.document_id and left invoice_inbox_items.matched_transaction_id
pointing at the transaction. propagateUnderlagForBookedTransaction selects
on exactly that column at categorize / book / bulk-book time, so the
detached receipt would have been re-anchored onto the new verifikation as
immutable underlag (BFL 5 kap 7 §), and the doc never reappeared in
inbox-available for re-matching.

The route now clears the back-link for the detached document, scoped to
items not yet consumed by a verifikat (created_journal_entry_id null),
mirroring the invoice-inbox extension's unmatch. Best-effort like the POST
side: the pin removal is the primary effect. Three DELETE tests cover the
filters, the no-pin case, and a failing unlink. DECISIONS.md and the PR body
record the accepted bulk-booked-row limitation in the history list.

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

* fix(transactions): detach reports a failed inbox unlink instead of success (#2132)

Swedish compliance review on PR #2144: the inbox back-link cleanup was
fire-and-forget, so a failed UPDATE returned 200 while leaving exactly the
stale matched_transaction_id that re-anchors a detached document onto the
next verifikation (BFL 5 kap 6-7 §).

The unlink is now scoped by transaction only (the unique index on
matched_transaction_id means at most one item points here, and a stale item
from the replace path would re-anchor just the same), runs even when nothing
was pinned so a retry is idempotent, and a failure answers 500 with an honest
Swedish partial-failure message, mirroring the POST side's propagation
failure. Tests updated accordingly.

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

* fix(transactions): release inbox back-link before a compare-and-set pin clear (#2132)

Review findings on PR #2144, one pass:

- CodeRabbit (major): DELETE cleared the pin and then released the inbox
  back-link scoped by transaction, so a POST landing in between could end up
  as "new doc pinned, its inbox item unlinked". The release now runs FIRST,
  and the pin clear is a compare-and-set on the document that was read
  (.eq document_id, or .is null when nothing was pinned). Zero rows answers
  409 "ändrades samtidigt" and keeps the newer pin. A failed release returns
  500 before anything changed, so a retry is trivially idempotent.
- Compliance swarm (A.8.15): the unlink failure log carried the raw driver
  error; it now logs errorCauseTag() only.
- CodeRabbit docstring check: JSDoc on handleDetachDocument.

Tests: order of the two writes, CAS filters for both pinned and empty
states, 409 on concurrent re-attach, coded-cause logging.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:29:22 +02:00
Mattsson 4f33184a9a fix(mcp): explain the Claude-side steps after "Anslut till Claude" and tick the checklist on a real connection (#2133) (#2147)
* fix(mcp): explain the Claude-side steps after "Anslut till Claude" and tick the checklist on a real connection (#2133)

Lazy auth is by design: Claude lists the tools before any sign-in and the
first company-scoped call answers 401, which opens the Accounted sign-in.
Nothing told the user, so a "connected" status with an unanswered first
question read as a broken connection (Axel, Discord).

- Settings -> API & MCP: one sentence of expectation under the button, and
  the step-by-step guide link moved from under two disclosures to directly
  under the button.
- Docs (connect-claude / anslut-claude): new "What happens after you click"
  section for Path A covering the connector dialog, the tools appearing
  before sign-in, the first-call login + consent screen, "ask again", and
  the "Required when the server asks" auth setting that only the manual
  path mentioned.
- Hem checklist step "Anslut till Claude": deep link now carries
  client=claude-connector like the settings button (claudeConnectorLink),
  the footnote carries the same expectation line plus the guide link, and
  the done-signal is an unrevoked api_keys row minted by the MCP OAuth
  token route (OAUTH_MCP_KEY_NAME) instead of the in-app AI-profile flag,
  which never meant "connected to Claude".
- Tests: claudeStepDone with/without a key row, deep-link snapshot.

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

* fix(mcp): correct consent-page claims, stop the completion PATCH loop, count OAuth keys past RLS (#2133)

Three skeptic refutations on PR #2147, fixed in one pass:

- Docs (EN + SV): the consent page shows the company active in the app and
  pre-selects every scope for Claude's connector (founder decision
  2026-08-26); it has no company picker and nothing to tick. Steps 3-4 of
  the new section, the "Read-only by default" paragraph above it, the
  sandbox note and the 10-minute test now describe Endast läs under
  Behörigheter instead.
- Checklist completion: users with initial_setup_path NULL (skipped the
  books question, then imported) hit the route's "Välj först hur du vill
  komma igång" 400 and, with saving as an effect dependency, retried it
  forever with a toast. completionPatchBody() records path=migration when
  none was chosen, and a rejected PATCH is not retried within the session.
- hasMcpKey: api_keys' SELECT policy is company-scoped, so the user client
  could not see companyless (NULL company_id) or archived-company keys and
  the step stayed open for the user who had just connected. The head count
  now runs through the service client with an explicit user_id filter.

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

* fix(mcp): surface a failed OAuth-key count and reserve the marker name (#2133)

CodeRabbit round on PR #2147:

- app/(dashboard)/page.tsx: a failed api_keys count answered count null,
  which claudeStepDone read as "never connected". Throw to the error
  boundary like the settings fetch does instead of guessing.
- app/api/settings/api-keys: reject a hand-minted key named
  MCP-klient (OAuth) (400 VALIDATION_ERROR): that name is the marker the
  Hem checklist reads as "connected to Claude", so a manual key with it
  would tick the step without any connection. Test added.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:04:31 +02:00
Mattsson 8b09b06e14 feat(skatteverket): ombudsregister grant verification, honest session expiry, daily ombud sync (#2130)
* feat(skatteverket): ombudsregister grant verification, honest session expiry, daily ombud sync

Users reported the Skatteverket connection "just disappearing" with no
banner, needing BankID again every time. Two causes, both fixed here:

1. SKV's per-flow refresh token lives 65 minutes. /status and the
   skv_disconnected notice called any stored refresh token "refreshable",
   so a days-dead session reported healthy and the reconnect banner never
   fired until a submission failed live. lib/skatteverket/session-lifetime
   now decides refreshability (expires_at + 5 min, refresh cap) for both
   surfaces; the settings panel states the one-hour session lifetime.

2. The durable fix is the ombud (system certificate) path, dormant since
   July behind SKATTEVERKET_SYSTEM_AUTH_MODE. Skatteverket added scope
   `obr` (Ombudshantering v2) to our application id on 2026-09-01, so grant
   verification can now ask the ombudsregister instead of classifying 403s
   from the read services:
   - lib/ombud-client.ts: GET /ombud/autentisieratOmbud, GET /roller,
     POST .../djuplank/utseombud, on the system identity, per the public
     tjanstebeskrivning v2.0 (mirrored in dev_docs/skatteverket/ombudshantering).
     Role codes are env-pinned (SKATTEVERKET_OMBUD_ROLL_LASOMBUD/_MOMS) or
     matched on rollbeskrivning text; a deep link never mints with a
     guessed code.
   - grant-probe.ts: register first, read-service probes only as fallback.
   - New daily cron /api/extensions/skatteverket/ombud/sync/cron (30 3 * * *):
     one register call discovers every company that granted us, creates or
     downgrades connection rows by org number, runs from shadow mode on,
     and never mass-revokes on an empty register.
   - POST /system-connection/deeplink + "Utse {app} som ombud" button:
     the company lands in SKV's e-service with roles pre-selected.
   - Default system scopes include `obr`; skvRequestWithAuth gains an
     `accept` option (Ombudshantering requires the Accept header).

Still inert in prod until the org certificate and avtal land; the cron and
verify routes no-op while system auth is off or unconfigured.

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

* fix(skatteverket): skeptic round on ombud sync, register 404 fallback, opt-in-only rows, mass-downgrade guard

Cron touches only existing connection rows (a tenant's own Verifiera or
deep-link opt-in; the deeplink route now records a pending row), so an
org-number twin never gets auto-verified, and rows the tenant revoked
locally stay revoked. A register 404 throws by default (spec: wrong URI)
and is empty only for the cron, which guards it. Decisions are planned
before any upsert; a run that would fully deny >= 3 rows and > 50% of the
granted ones applies no downgrade. Grants that classify as neither
behörighet are 'error', not 'denied'. Literal select in listConnections for
the phantom-column scanner. window.open without 'noopener' so the
pre-opened tab exists; opener nulled by hand. Deeplink route test added.

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

* fix(skatteverket): CodeRabbit round: exact role labels, paginate connections, deny never-listed rows, fail deeplink without opt-in row

Role descriptions match the whole label so 'Momsdeklaration,
deklarationsombud' is never read as the narrow moms role. listConnections
pages through fetchAllRows on (created_at, id). A pending row the register
never lists is written once as denied instead of staying 'Inte verifierad'.
The deeplink route returns 500 when the opt-in row cannot be stored, and the
panel navigates in-tab when the pre-opened tab was blocked.

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

* fix(skatteverket): fence ombud grants on contested org numbers; cron honours unrecognised role codes

An org number claimed by more than one live company is contested: verify
and deep link answer 409 ORG_NUMBER_CONTESTED and the nightly sync changes
nothing on it, so a tenant that typed a victim's public org number cannot
inherit the victim's grant. The sync also skips huvudmän whose register
roles classify as neither behörighet (pinning problem, never a denial),
mirroring probeViaOmbudsregister.

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

* fix(skatteverket): validate the ombud deep link host; withdraw grants on contested org numbers

The register's djuplank must be an https skatteverket.se URL before it is
returned or navigated to (the settings page follows it). The nightly sync
now withdraws a grant already recorded on an org number that more than one
live company claims, instead of only refusing new ones.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:42:23 +02:00
Mattsson d8cf78330e docs(self-hosting): own-credentials section, stale connector lines, complete .env.example (#2146)
* docs(self-hosting): own-credentials section, stale connector lines, complete .env.example (#2131)

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

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

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

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

Closes #2131

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

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

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

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:08:47 +02:00
Jakob Wennberg 97107398c0 fix(import): fit the account mapping table at 100 % zoom (#2125) (#2138)
The SIE-import mapping table was a fixed layout of 1216px, with 144px
spent on a four-digit source account and the VAT cell's min-w-72
overflowing 32px into Konfidens, so on a laptop content column it
scrolled sideways and read as cramped even after #1684 kept the confirm
button reachable.

- Column budget ~990px: Källkonto w-20, Källnamn w-40 (existing
  truncate + tooltip), arrow w-8, Målkonto w-56, VAT w-72 with the
  treatment select flex-1/min-w-0 and the rate select shrink-0,
  Konfidens w-24, Bekräfta w-28.
- 13px text and px-3 cells, matching the page-level list density.
- Bekräfta is an icon-only button (Check) with a tooltip; the header
  keeps the label and gains an InfoTooltip explaining what confirming
  does (new chart_of_accounts.vat_treatment_confirm_help, sv + en).

Closes #2125


Claude-Session: https://claude.ai/code/session_01WFhSQWzu5SyXB6kG5ActZc

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:55:23 +02:00
Jakob Wennberg 823a0f73d8 feat(transactions): select and bulk-ignore any unbooked skattekonto row (#2127) (#2140)
* feat(transactions): select and bulk-ignore any unbooked skattekonto row (#2127)

Skattekonto rows in the inbox only got a checkbox when they carried a
deterministic booking suggestion, because the only bulk action was
Bokför valda. A migration backlog on the skattekonto (rows from before
the first fiscal year, history already booked via SIE) therefore had
to be ignored one row at a time, while bank rows next to them could be
bulk-ignored.

- isSkvSelectable: every unbooked, non-ignored skattekonto row is
  selectable (checkbox, shift-range, Markera alla).
- Bulk Bokför keeps its eligibility rule: button count, confirmation
  summary and submit all read one skvBookableSelectedRows list, so a
  mixed selection books only the deterministic subset.
- Bulk Ignorera now spans bank + skattekonto selections: one
  confirmation (body names where each kind is restored), one progress
  counter, one toast, bank rows via POST /transactions/:id/ignore and
  skattekonto rows via the per-row PATCH .../ignore, 5-wide.
- The bank-only confirm/toast strings move from hardcoded Swedish to
  transactions.batch_ignore_* keys (sv + en).

Bullet 2 of the issue (unbooked skattekonto rows "not in att göra after
migration") is scoped out; see DECISIONS.md.

Closes #2127

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

* feat(transactions): warn when bulk Ignorera covers rows that look like affärshändelser

Compliance review on #2140: any unbooked skattekonto row can now be
bulk-ignored, including rows with a deterministic booking suggestion
(interest, charges) that BFL 5 kap. says should be booked. The ignore
stays allowed, a migrated backlog is exactly such rows already present
in the imported books, but the confirmation now says how many of the
selected rows carry a suggestion and no duplicate hint, and what
Ignorera is for.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:44:52 +02:00
Jakob Wennberg ea45e9dc2f fix(invoices): say what payment detail is missing, per currency (#2126) (#2139)
"Fakturan saknar ett betalningskonto för vald valuta" read as a
foreign-currency account when the invoice was in SEK and the gap was
simply the company's bankgiro; the remediation line also asked for an
IBAN, which SEK does not need. A Visma-migrated user marking invoices
as sent hit this and went looking for a valutakonto.

- describeMissingInvoicePaymentAccount(currency) in
  lib/invoices/payment-accounts.ts: SEK names bankgiro, plusgiro, Swish
  or bank account; other currencies ask for an IBAN account in that
  currency (USD/GBP also offer routing number / sort code + BIC). Both
  point at Inställningar → Fakturering.
- getErrorMessage branches on INVOICE_SEND_PAYMENT_ACCOUNT_MISSING +
  details.currency (every dashboard route already sends it), before the
  English registry shortcut so both locales get the specific text.
- Registry entry rewritten currency-neutral for consumers without
  details (API, MCP): bankgiro/plusgiro/Swish/bankkonto for SEK, IBAN
  otherwise; remediation no longer says IBAN for everything.
- Staged-operation commit path uses the helper directly.

Tests: helper per currency, client mapping sv/en and the no-details
fallback.

Closes #2126


Claude-Session: https://claude.ai/code/session_01WFhSQWzu5SyXB6kG5ActZc

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:38:01 +02:00
Jakob Wennberg 4e1f739913 docs(decisions): rescue nine unrecorded entries stranded in a working tree since 2026-08-21 (#2136)
Found uncommitted in the main checkout alongside the behandlingshistorik
PR3 draft (#2097 rescued that part). These document decisions already
made and in some cases already executed on prod: the Peppol
personnummer refusal, the invoice@arcim.io tombstone unblock, the
record-and-compile research with its prod-validated numbers, and the
BrandMark/logo design calls. The BrandMark CODE is preserved separately
on wip/brand-mark-rescue; nothing here merges UI.


Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:18:35 +02:00
Jakob Wennberg dd84d6c1bb fix(mcp): tag unmapped tool failures with their cause vocabulary (#2051) (#2135)
Closes #2051. errorCauseTag() shipped in #2027 written and tested but
wired to nothing. This connects it: the two execution catch paths (sync
call and task) now pass errorCause into mcp.tool_called, carrying the
SQLSTATE or coded-error code, else the error's class name, capped at 64
chars.

The rows this exists for are the UNKNOWN_ERROR residue, whose
errorMessage is the constant "Något gick fel. Försök igen." and whose
errorDetail is the English constant: 465 such rows in the last 30 days
(create_voucher 58 of its 60 failures, query_journal 122) with nothing
to cluster on. A five-character SQLSTATE is protocol vocabulary; a raw
driver message can quote row values from a constraint violation and
belongs in the server log, never in event_log, so the raw message is
deliberately not captured.

A plain `new Error(...)` tags null rather than 'Error': tagging
everything is the same as tagging nothing. Pre-execution denials (scope,
capability, validation, unknown tool) pass nothing because their
errorCode already is the cause.

Self-tested by unwiring one call site and watching the new test name it.


Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:07:46 +02:00
Jakob Wennberg 191fb2cfce fix(audit): alias learning is not a rule change, stop logging it (BFNAR noise) (#2134)
Production falsified 20260901103000's exclusion list within 30 minutes
of deploy: 15 of the first 16 UPDATE audit rows on
categorization_templates changed only the learning columns plus
counterparty_aliases, because the learning path
(lib/bookkeeping/counterparty-templates.ts) merges new aliases in the
same write that bumps occurrence_count. Projected ~800 noise rows/day
against ~50/day of real rule changes, each one rendered into the
legally-facing behandlingshistorik as "Konteringsmall aendrad: Alias".

counterparty_aliases joins the trigger's strip list. The trade-off is
explicit: a human editing ONLY aliases is no longer logged. Accepted
because alias growth is overwhelmingly automatic, and a change that also
touches accounts, VAT, pattern or the active flag still logs: the first
real such row (2026-09-01 19:02:17Z, debit/credit/vat accounts changed
by the learning loop, BFN's automatkontering case exactly) was captured
correctly and stays captured under the new WHEN clause.

The pre-fix noise rows stay in audit_log (append-only). The read model
stops labelling the column, so alias-only diffs, historical ones
included, render as no-ops rather than rule changes; a diff that also
carries a real change shows only the real change.

pg-test extended: alias+learning update writes no audit row,
alias+account update still does. Read-model test pins the pre-fix noise
row shape to null.


Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:58:02 +02:00
Jakob Wennberg 53c9c0c4f9 feat(mcp): second examples batch: five more tools, seven examples, 80 tokens (#2137)
Continues #2100 under the margin the envelope trim (#2123) created:
58 999 to 59 079 against the 60 000 ceiling, spending under half the
room and leaving ~920.

Picked by evidence, not traffic alone: gnubok_search_tools was sent a
nonexistent `offset` in prod today, so its examples show the actual
levers (query, detail, limit) and the description of the mistake; the
create/complete document-upload pair's examples ARE the two-step flow,
same upload_id and file_name on both sides;
list_uncategorized_transactions is the highest-traffic read (15 122
calls/30d) and gets the pagination shape; link_document_to_voucher gets
the minimal linking call.

All seven pass the input-examples validation suite (same unknown-key
guard the server runs, plus required/type/enum/pattern and the
placeholder-id check), and the pinned tool list is updated.


Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:45:31 +02:00
Jakob Wennberg 8d9bf383d1 perf(mcp): trim the one schema 58 catalogued tools share, reclaiming 2 552 tokens (#2123)
The catalog had 116 tokens of headroom and server.ts took 70 commits in
the preceding 14 days, so the next ordinary tool addition would have
failed CI.

Measured before cutting, which is what made this findable: outputSchema
is 38% of the whole catalog (23 290 tokens), and STAGED_OPERATION_SCHEMA
alone accounts for 14 736 of it, the same envelope transmitted 58 times.
Descriptions, which the three previous rounds trimmed, are 10%.

Three edits to one constant, no tool demoted and no field removed:
period_status stops declaring its three sub-properties as JSON Schema and
carries them in one sentence (actor, approve and preview were already
bare objects, so this makes the envelope internally consistent), the next
hint drops args' redundant additionalProperties: true, and operation_id's
description loses six words.

Every change is in the looser direction on purpose. The server emits
structuredContent for every tool and the documented failure mode is a
declaration too TIGHT making a strict client reject a successful call; a
looser one cannot do that. next keeps additionalProperties: false because
staging.test.ts pins it as a closed shape, and a guard whose reason is
not in front of you is not a guard to loosen for 420 tokens.

Ceiling ratcheted 61 600 to 60 000, leaving ~1 070 tokens of deliberate
working margin rather than the ~300 the previous rounds left. The bench
log records the cycle that margin causes: ratchet tight, block the next
feature, bump, demote. If more is needed the lever is priced: the
envelope still costs ~11 800 tokens across those 58 tools, and the fix is
to stop repeating it, not to trim it further.


Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:30:37 +02:00
Mattsson b56da5d6c5 feat(api): expose bank-connection freshness in MCP and v1 REST (#2124)
* feat(api): expose bank-connection freshness in MCP and v1 REST

gnubok_connect_bank now returns last_synced_at, consent_expires and
error_message per connection, and its instructions tell the agent to
flag stale or expiring connections. New read-only endpoint
GET /api/v1/companies/{companyId}/bank-connections exposes the same
fields to API-key integrations (scope companies:read).

Background: a user's PSD2 feed died silently in July; bookkeeping
looked complete while three weeks stale, and nothing on the API/MCP
surface could reveal it. Sync stays cron-driven; an agent-triggerable
sync was considered and deferred (see DECISIONS.md).

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

* fix(api): address skeptic findings on bank-connection freshness

- Map the bank-connections group into skills/accounted-api (apiskill:check
  crashed on the unmapped group; regenerated skill files included).
- Gate the v1 route on the bank_sync capability, mirroring the MCP twin:
  a lapsed entitlement now answers with a capability error instead of
  status=active with a frozen last_synced_at.
- Reword MCP instructions + v1 pitfalls: null last_synced_at right after
  connecting is normal, staleness threshold aligned to the UI's 36 hours,
  and re-authorisation is only advised for expired/error/consent-out, not
  for stale-but-active connections (lapsed subscription or deselected
  accounts are the usual causes there).

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

* fix(mcp): keep gnubok_connect_bank schema under the tools/list token ceiling

The enriched outputSchema plus the worked examples that landed on main
(#2100) pushed the projected tools/list payload 20 tokens over the
61.6K context-budget ceiling. Drop the per-property descriptions from
the new freshness fields; the instructions string (runtime output, not
catalog payload) already explains them.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:54:55 +02:00
Jakob Wennberg a08bf51ced feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16) (#2097)
* feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16)

Part 3 of the behandlingshistorik series (#1787 report, #1790 PDF). BFNAR
2013:2 punkt 9.16 second paragraph requires the behandlingshistorik to record
"forandringar i bokforingssystemet som paverkar bokforingsposternas behandling
samt nar dessa forandringar infordes", and BFN's commentary names
behandlingsregler (automatkonteringar, fasta procentsatser) and new program
versions as the examples. Until now both changed without a trace.

Audit triggers on the behandlingsregler tables and the import logs:
mapping_rules, booking_template_library, categorization_templates,
salary_payroll_config, sie_imports, bank_file_imports. categorization_templates
learns on every booking (occurrence_count, confidence, last_seen_date), so
those telemetry-only updates are excluded by a WHEN clause the same way the
api_keys request counters are (20260721115701): only real rule changes are
logged. Measured against prod that is roughly 3 800 new audit rows a month
against an audit_log already taking 371 688, so about +1 %.

app_releases is an append-only log of program versions seen in production,
written by the runtime the first time a build answers a request. Vercel exposes
no build hook we can trust to write the row, so /api/version records it inside
after(): the handler returns synchronously and a floating promise could be
frozen before the insert lands, which is how a version log ends up silently
empty. The service client is constructed lazily so the constantly polled public
probe pays nothing once the module guard is set.

Program versions are rolled up per Swedish calendar day in the report. main
takes ~570 merges a month, so one event per version would be on the order of
7 000 a fiscal year: enough to trip the PDF's own 4 000-event guard and bury the
~400 events a real company's year contains. The statutory unit is the date, and
the same sentence qualifies the requirement to changes that affect processing,
which a deploy list cannot distinguish anyway. app_releases keeps the
per-version truth for anyone who needs to go deeper.

AuditLogEntry.user_id becomes string | null. The column is nullable and
write_audit_log() falls back to auth.uid(), which is NULL for a service-role or
global write; the company-less salary_payroll_config rows are the first that
routinely hit it, and the read model already coded for it.

Also restores the point citations the 2026-07-27 pass removed while the chapter
was unverified: it is kapitel 9, not kapitel 8 (which is arkivering), verified
against BFN's consolidated text.

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

* test(pg): fix two fixture bugs in the behandlingshistorik trigger tests

pg-real caught both, and neither is in the migration: the inserts fail
before the trigger is reached.

mapping_rules.rule_type is constrained to mcc_code / merchant_name /
description_pattern / amount_threshold / combined; the test used
'merchant'.

booking_template_library's btl_insert policy requires
current_user_can_write() and company_id = current_active_company_id(),
so the authenticated insert needs a company_members row and a
user_preferences.active_company_id, the same setup
booking-template-hidden.pg.test.ts uses.

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

* test(pg): assert the booking-template audit row inside the user transaction

withUserContext always rolls back, so the audit row the trigger writes
is gone before an outside connection can see it. The trigger fires in
the same transaction as the write, so the assertion belongs there too.
The other cases in this file write on the pool (autocommit) and are
unaffected.

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

* fix(reports): name every build id in the per-day program-version entry

Raised by the compliance review on #2097: the roll-up listed five ids
and a count, which leaves an auditor unable to reconstruct which
versions ran that day. app_releases keeps the full record, but the
report is the surface anyone actually reads. A day is bounded by the
deploy rate (~19), so the full list stays one readable cell.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:31:10 +02:00
Jakob Wennberg 169e7eaf4e feat(mcp): worked examples on five high-traffic tools, and in the error that rejects a call (#2100)
#2066 asked for input_examples on the top ~20 tools by call volume. The
binding constraint turned out to be budget, not writing: after #2089
reclaimed 3 763 tokens, its own policy required ratcheting the tools/list
ceiling down with it, so the real headroom was 317 tokens. Ten examples
across five tools cost 199, leaving ~118. The ceiling is not raised.

Tools picked from 30 days of mcp.tool_called crossed with the
combinations the descriptions already warn about and callers still get
wrong: account_override without an explicit vat_treatment (books gross,
no moms line), representation without deltagare and syfte, confirmed on
a high-risk approval, a balanced voucher where the moms leg is its own
line, and get_kpi_report, where one caller sent `metric` 604 times over
seven days to a tool whose only parameter is period_id.

Examples are also surfaced in the unknown-parameter error. That costs
nothing in tools/list, because it only ships on the response to a call
that already failed, and it reaches the caller that most needs it: a key
list told DueCue's agent which parameter was wrong but not what a
correct call looks like, and the same rejected call repeated for a week.

Every example is validated against its own schema by the same
findUnknownArgKeys guard the server runs, plus required/type/enum/pattern
checks. An example our own boundary would reject is worse than none: it
teaches the exact mistake the guard then punishes. That test caught three
invented enum values in this change's own first draft.


Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:18:19 +02:00
Jakob Wennberg e5fba9471e feat(bookkeeping): verifikationsserie dropdown, wider Ny verifikat modal, balancing amount on focus (#2120)
Three fixes to the Ny verifikation modal.

1. Verifikationsserie is a closed dropdown, not a one-letter free-text
   field. Ships Fortnox's table verbatim from their Systemdokumentation
   (A Redovisning, B Kundfakturor, C Inbetalningar från kunder,
   D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa,
   G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön,
   L Kontantfaktura, M Momsrapport). A is deliberately Redovisning, not
   Kundfakturor: it is the general series manual entries land in, and
   migration 20260526120700 ships every source_type defaulting to 'A', so
   every existing company's A series already holds everything. Any letter
   the company already configured is appended so no existing value can
   fall out of the picker.

2. The modal goes from sm:max-w-3xl to sm:max-w-5xl lg:max-w-6xl.

3. Tabbing or clicking into an untouched amount proposes the outstanding
   difference, pre-selected so typing replaces it. Only fires when the row
   has an account, both amounts are empty, and the difference belongs on
   that side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnoyZCbfm2geRbwtUpi6XN
2026-09-01 20:05:36 +02:00
Mattsson 0406e628e1 fix(settings): scope cross-field VAT validations to saves that touch them (#2121)
* fix(settings): scope cross-field VAT validations to saves that touch them

The settings PUT validated the whole effective record on every partial
update, so companies stored as vat_registered without a vat_number were
blocked from saving anything through the endpoint, including the invoice
bank-details dialog, which has no VAT fields (reported by a user stuck on
"Momsregistreringsnummer kravs...").

Each cross-field check (VAT completeness, 40m-monthly, periodisk
sammanstallning) now runs only when the request body touches a field in
its group, so the invariant still holds whenever VAT config is edited.
Explicit null now counts as clearing a value during validation instead of
falling back to the stored one, closing a latent hole where
{ vat_number: null } passed validation but wrote null.

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

* fix(invoices): gate issuance on the seller VAT number (skeptic finding)

The settings scoping in the previous commit removed what was accidentally
the only enforcement of "momsregistrerad implies momsregnr on file": with
bank details saveable again, a registered company without a stored VAT
number could issue a faktura charging moms with no seller VAT number in
the footer (mandatory element, ML (2023:200) 17 kap. 24 §).

Issuance is now gated the same way the payment account is, at all four
independent issuance points (issueAndBookInvoice, dashboard send, v1 send,
v1 mark-sent), with a structured error pointing at Installningar -> Skatt.
Credit notes, proformas, and delivery notes are exempt like the payment
gate exempts them.

Also, per the Swedish review and the secondary skeptic finding:
- PS/EU-trade edits join the VAT-completeness touch group, so enabling
  periodisk sammanstallning on an incomplete registration keeps failing.
- The stale ML 11 kap. 8 citation is updated to ML 17 kap. 24.

The makeCompanySettings fixture now models a coherent registered company
(vat_number set); the missing-number tests override it explicitly.

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

* fix(invoices): extend the seller-VAT-number gate to the headless issuance paths

Skeptic round 2 found three more issuance points beside the four gated in
the previous commit: the recurring auto-send service (cron, no human in
the loop), and the MCP staged-operation executors send_invoice and
mark_invoice_sent. Each carried the payment-account gate but not the VAT
gate; mark_invoice_sent additionally had a narrow settings select that
would have made a naive gate silently pass, now widened.

Recurring auto-send fails soft, matching its other guards: the invoice
stays a numbered draft with the standard schedule warning. The executors
return the structured Swedish message. Peppol send was verified
self-gating (BIS preflight requires the supplier VAT number).

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

* test(email): refresh brand-mail snapshots for the coherent VAT fixture

The makeCompanySettings fixture now carries a VAT number, so the invoice
and reminder mail footers correctly render the VAT line; the snapshots
predate that. Also cites ML 17 kap. 22-23 (andringsfaktura content list)
in the seller-vat-number docstring per the Swedish review suggestion,
documenting why credit notes are exempt. No behavior change.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:30:25 +02:00
Jakob Wennberg b5da51ea0a feat(settings): API & MCP tab: correct connector URL namespace, plugin path, Swedish guide (#2105)
* feat(settings): API & MCP tab: correct connector URL namespace, plugin path, Swedish guide

The in-product MCP URLs omitted `tool_namespace=accounted`, and
resolveMcpToolNamespace() falls back to the legacy `gnubok_` prefix when the
param is absent. Every connection made from Settings therefore got `gnubok_*`
tool names while the docs, the accounted-api skill, and
claude-plugin/.mcp.json all reference `accounted_*`.

- Add `tool_namespace=accounted` to the Claude.ai, Claude Code, and
  Claude Desktop snippets.
- Move the Claude Desktop bridge from `npx gnubok-mcp` / `GNUBOK_API_KEY` to
  `npx -y accounted-mcp` / `ACCOUNTED_API_KEY`, and emit `ACCOUNTED_URL` so
  self-hosted and white-label instances get a config pointing at their own
  host. The `gnubok_sk_` key prefix is unchanged: it is wire format.
- Surface the Claude Code plugin, the only path that configures the
  connection and the seven workflow commands in one step.
- Rename the settings tab "API" to "API & MCP" and rewrite its intro: the
  MCP connection is what most users come here for, not API keys.
- Link the step-by-step guide from the panel, locale-aware.

Docs: add a Swedish /docs/api/anslut-claude alongside the English page (the
docs site has no locale routing, so each language is its own URL), give both
the Claude Code plugin path, and teach the export and freshness scripts about
the new page so cross-repo drift is caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

* fix(settings,docs): Cursor is not Claude Code; use the accounted_ tool name

Review follow-up on #2105.

`claude mcp add` is a Claude Code command. Cursor does not read it, so the
"Claude Code / Cursor" row and the docs sentence pointing Cursor users at that
command were both wrong (the row predates this PR; the docs sentence did not).
Cursor now gets its own row and its own `~/.cursor/mcp.json` snippet with the
`url` field, in the panel and in both docs pages.

Also `vat_close_check` -> `accounted_vat_close_check` in the reviewer test on
both pages, matching the identifier used in the prompts section above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

* feat(settings,docs): one-click Connect to Claude, and cut the panel to one action

Anthropic documents an install link for custom connectors:
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=NAME&connectorUrl=ENCODED
(claude.com/docs/connectors/building/directory-vs-custom). It opens claude.ai
with the connector name and URL prefilled; the user still reviews and confirms,
and it grants nothing on its own. We were telling people to copy a URL and go
paste it somewhere else instead.

Settings panel, rendered and reviewed:
- "Connect to Claude" button is now the only thing above the fold. Everything
  that needs a config file or a terminal (claude.ai manual paste, Claude Code,
  the plugin, Cursor) moved into one "Other clients" disclosure, and the
  API-key methods keep theirs. Four code blocks -> one button, 1057px -> 719px.
- The connect group renders above the API-keys group. Connecting is why users
  open this tab; the tab's own intro says so.
- Each entry inside the disclosures shows its instruction as visible text.
  They were `?` HelpPopovers, so the panel read as opaque code blobs with no
  instructions on screen.
- Prose interpolates the brand's real casing, not the lowercased config key.

Docs, both languages: Path A leads with the install link and drops from five
manual steps to a link plus three short paragraphs, with the manual paste kept
under a subheading. No raw HTML: the docs renderer has no rehype-raw, so
<details> would have been silently dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

* fix(settings,docs): correct claude mcp add syntax and the SSR install link

Review follow-up. Both findings verified before acting on them.

`claude mcp add --help` gives `claude mcp add [options] <name> <commandOrUrl>`:
the URL is positional and there is no `--url` flag, so the API-key snippet
would have failed on a missing argument. Both commands now put
`--transport http` before the name and pass the URL positionally, in the panel
and in both docs pages.

The panel is server-rendered before it hydrates and window.location has no
server equivalent, so the install link was built from a relative mcpBase in the
first paint. A click in that window would hand claude.ai a connectorUrl it
cannot resolve. The origin now resolves after mount and the anchor carries no
href until it is known, which also makes it unclickable rather than wrong.
Verified: the SSR HTML contains no claude.ai href and no relative connectorUrl,
post-hydration the href is absolute, and there are no hydration warnings.

DECISIONS.md: code-span the `gnubok_*`/`accounted_*` wildcards so they stop
rendering as emphasis, and drop the "no one-click deeplink" claim from the
earlier entry rather than leave a false statement standing two lines above its
own correction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:56:19 +02:00
Mattsson cd40127f0e feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API (#2118)
* feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API

The PSD2 sync has fetched the bank's reported balance for years but the
data was stranded (F7): the Bank-page source picker read a cash_accounts
column no sync ever updated (frozen at connect time), reconciliation
hard-coded external_balance to null for bank accounts, and neither MCP
nor the v1 API exposed any balance at all, so the only path to a current
bank balance was logging into the bank.

- getAccountBalance now returns booked + available from the same
  quota-limited BALANCES response (previously all but one type discarded)
- every sync (manual + cron) mirrors balance, available_balance and
  balance_updated_at into cash_accounts, fixing the stale picker
- new cash_accounts.available_balance column (additive migration)
- reconciliation bank kind: external_balance = bank-reported balance,
  plus bank_reported_* fields and fetch timestamp in the bank block;
  difference math stays movement-based and untouched
- reconciliation view shows "Saldo enligt banken ... hamtat {date}"
- MCP gnubok_list_cash_accounts returns the three balance fields; the
  cash_today prompt now reports the bank's figure instead of teaching
  agents to answer with the bookkept 19xx balance
- new GET /api/v1/companies/{companyId}/cash-accounts endpoint

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

* fix(bank): keep external_balance null for bank sign-offs; never fabricate a zero balance; guard the mirror against stale writers

Post-review fixes from the skeptic pass + CodeRabbit on PR #2118:

- external_balance stays null for the bank reconciliation kind: sign-off
  persists it into account_reconciliations and bokslutsbilagor computes
  closing - external from that row, so a today-balance stored on a
  balansdag sign-off printed a phantom warning-red differens in the
  year-end appendix. The bank-reported figure lives only in the
  timestamped bank_reported_* pair in the bank block, and only when its
  fetch timestamp exists (a balance of unknown age is suppressed).
- AccountOverview no longer falls back to today's date when the balance
  timestamp is missing; the line is omitted instead.
- getAccountBalance returns null on an empty BALANCES response instead
  of fabricating amount 0 with a fresh timestamp; sync keeps the
  previous stored value.
- updateBalancesFromSync only writes over an older-or-missing
  balance_updated_at, so an older sync run finishing later cannot move
  the mirrored balance backwards.
- The inline initial backfill (picker save) now mirrors fetched
  balances into cash_accounts too (accounts_data is deliberately not
  re-written there).
- cash_today MCP prompt mentions the gnubok_call_tool bridge for hosts
  that only see the default catalog.

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

* fix(bank): express the stale-writer guard as two literal predicates for the schema guard

The .or() with a template literal pushed the no-phantom-columns
unresolvable-expression count over its ceiling. Same semantics, two
updates: one for rows with an older timestamp, one for rows with none.

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

* fix(bank): rank interimBooked (ITBD) as a booked balance type before the generic fallback

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 16:16:29 +02:00
Mattsson a57a8d968b fix(webshop-orders): stop syncing failed WooCommerce orders, remove stale unpaid rows (#2119)
* fix(webshop-orders): stop importing failed WooCommerce orders, remove stale rows on failed transition

Failed checkouts carry no money event but imported as permanently
unbookable 'Ej betald' rows (user report). orderImports() now excludes
'failed' alongside 'trash', and a re-polled order that transitioned to
failed deletes its existing row via removeWebshopOrders(), which enforces
the freeze boundary app-side: frozen rows are never deleted, and a parent
with a frozen refund child is spared because parent_order_id cascades.
Removal failures hold the sync cursor like upsert failures do.

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

* fix(webshop-orders): make failed-order removal race-safe per skeptic findings

Repeat every guard on the DELETE statement itself, not only the candidate
select: a row booked/marked/invoiced between the two round trips must
survive (TOCTOU refutation). Never remove paid rows (orderRemoves gated on
!orderIsPaid plus is_paid=false on both statements): money moved at some
point, and paid parents are the only rows that can carry refund children,
which also closes the cascade race without a DB trigger. Spare
cross-marked rows (legacy_transaction_id): the order may be booked via the
retired transactions feed without any freeze column set.

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

* fix(webshop-orders): audit-log successful failed-order removals

Compliance swarm finding (ISO A.8.10): the hard delete logged only its
failure path. Every successful removal batch now logs companyId, deleted
row ids and the requested external_ids, the only deletion record for
pre-bokforing rows that carry no behandlingshistorik.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:55:36 +02:00
Mattsson fa69174aa0 fix(bank): never pre-check or mirror another company's accounts in the EB callback (#2116)
* fix(bank): never pre-check or mirror another company's accounts in the EB callback

At one-session banks (SEB) the PSU's single consent can cover accounts a
sibling company books. The OAuth callback stored whatever the session
returned into the active company: all pre-enabled, mirrored into its
cash_accounts, ledgers allocated from its chart: one 'Spara val' away
from booking another aktiebolag's transactions (user report F1,
2026-09-01).

The deliberate reuse path (findReusableSessions) already guards claimed
IBANs; the callback now runs the same check via
fetchCrossCompanyAccountContext:

- accounts claimed by another of the user's companies are stored
  disabled + flagged (claimed_by_company_*), skipped by the
  cash_accounts mirror, and the picker names the claiming company
- a 'Synkas ej' deselection made on any other connection row is carried
  onto fresh rows (the recurring came-back-pre-checked complaint, C2)
- lookup failure fails closed: new accounts stored deselected
- accounts the row itself already carried keep their own state, so a
  renewal can never switch a working feed off

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

* fix(bank): close the skeptic-found holes in the cross-company claim guard

Consolidated fixes from the three-skeptic review of PR #2116 (all three
refuted the first cut):

- Active-company standing state (enabled cash_accounts + enabled
  accounts on its live-ish connection rows) now outranks sibling claims
  company-wide, not row-wide: a bank-list renewal arrives on a FRESH row
  with no priors, and the old row-local check would have let a sibling
  claim switch a working feed off while supersede demoted its cash row.
- pending_selection rows no longer claim accounts or feed deselection
  memory: their flags are unconfirmed callback output (including this
  guard's own fail-closed writes), so an abandoned picker or a transient
  lookup error can no longer poison later connects.
- Guard-disabled accounts are never mirrored from the callback:
  upsertFromPsd2 with enabled:false for a new-to-row account could
  promote the seeded primary 1930 manual row and flip it to disabled
  under a foreign identity.
- The selection save skips ledger allocation and the cash_accounts
  mirror for disabled never-mirrored accounts, so 'no cash row, no 19xx
  slot burned' holds past the mandatory Spara val, and strips the
  claimed_by_*/deselected flags when the user deliberately enables an
  account.
- Deselection carry is no longer silent: deselected_elsewhere flag +
  picker note 'Tidigare bortvald'.
- Claim lookups paginate via fetchAllRows: the bare select's silent
  1000-row PostgREST cap failed open for exactly the multi-company
  consultants the guard exists for.

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

* fix(bank): claim-guard round 2: pending_selection claims asymmetrically, paged reads ordered

Skeptic re-verification of 25a339810 found two holes:

- Excluding pending_selection rows from claims reopened the
  attach-to-picker window: an attach-created row holds deliberately
  offered enabled accounts with no cash_accounts rows until its picker
  is saved, and a full-OAuth connect in another company inside that
  window could take the same physical account. Enabled accounts on
  pending_selection rows claim again; their disabled flags still stay
  out of the deselection memory (unconfirmed callback output, including
  the guard's own fail-closed writes).
- Both fetchAllRows claim queries now order('id'): unordered .range()
  pagination can silently skip rows at page boundaries, and a skipped
  row is a missed claim, failing open at exactly the 1000+-row scale
  the pagination was added for.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:24:10 +02:00
Jakob Wennberg b1f7116231 fix(observability): clear the dead session on the first bounce, stop filing client disconnects as exceptions, scope the Hem probe (#2114)
Three small defects found underneath error clusters that are themselves benign.

bounceToAuth returned a bare NextResponse.redirect and never copied the cookies
off supabaseResponse, so the Set-Cookie headers that clear a dead session were
thrown away on the first bounce and the browser replayed the dead refresh token
once more on /login. That doubled both the GoTrue 400s and the log volume. The
AuthApiError itself is left alone: it is correct session-expiry handling that
auth-js logs from inside node_modules, and getUser() returns it as a value.

"The destination stream closed early." is a client disconnecting mid-stream,
produced inside React's Flight server. Next's own isAbortError filter does not
recognise React's cancel error, so instrumentation.ts reported it to PostHog
Error Tracking as a real exception against real users' session replays and paid
an awaited flush on an otherwise-healthy request. A narrow predicate now
early-returns before PostHog is touched. This cannot remove the line from
Vercel's runtime-error table, which is fed by Next's stderr.

other-account-hint.ts issued an unfiltered journal_entries probe on the
blocking Hem render path, inside a render Promise.all: roughly 1 in 20 Hem
loads waited an extra 2.5 s for an advisory nudge. An unfiltered probe on a
multi-tenant table is also a correctness smell. It is now company-scoped and
off the blocking path.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:03:23 +02:00
Jakob Wennberg f1d76deaba fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura (#2113)
* fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura

Two independent defects in the provider migration, both customer-visible.

A per-resource 403 was classified as a dead grant. classifyProviderError mapped
any 401 or 403 to PROVIDER_AUTH_EXPIRED, which is fatal, so a Fortnox account
without leverantorsregister permission aborted the whole migration at the
suppliers step with "Anslutningen har gatt ut. Ateranslut" even though the same
token had just succeeded on the previous step. Reconnecting can never fix that,
and steps 4 and later never ran. The provider's own reason ("Saknar behorighet
for leverantorsregister.") never reached the user. A 403 is now non-fatal once
the same token has already succeeded in the run, the migration continues, and
the provider's reason is surfaced. A 401, or a 403 on the first call, keeps the
auth-expired path.

fetchCompanyInfoDirect swallowed every error and returned null, which made the
existing PROVIDER_API_MODULE_INACTIVE remediation unreachable: a Visma customer
whose api_standard module is off got a silent 200 with an empty company card
instead of the precise Swedish explanation that was already written.

Kreditfakturor were dropped entirely. entity-mapper wrote document_type
'credit_note', but invoices_document_type_check allows only invoice, proforma
and delivery_note, and credit notes are modelled by credited_invoice_id. Every
migrated kreditfaktura was rejected and counted as skipped. One customer
imported 255 sales invoices and 0 credit notes on 2026-08-31; AR and revenue
are overstated by the credited amounts, and kreditfakturor are
rakenskapsinformation. They now import as invoice rows with reversed amounts
and status 'credited', following the in-app credit convention. They import
unlinked: no provider DTO carries a reference to the invoice being credited, so
there is nothing to match on and guessing would corrupt the AR ledger. The
wizard says so instead of burying them in skipped.

Also makes the OAuth callback non-replayable from browser history (no-store
plus history replacement), which is what the "state rejected" events were: a
replay of a callback that had already succeeded seconds earlier. No
already-connected page, so consumed-vs-unknown state stays unobservable to an
unauthenticated caller. Expected PSD2 session expiry drops from error to warn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(arcim): entity line needs the failed flag

The unlinked-credit-note row omitted `failed`, which the entityLines element
type requires. Caught by the zero-extensions build, not by vitest: the unit
suite does not typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(arcim): write the missing-reference disclosure onto the credit note itself

Review finding (swedish-compliance-review-bot): ML 17 kap 22-23 § wants a
kreditfaktura to reference the invoice it credits, and BFL 5 kap 6-7 § wants a
verifikation to reference its underlag. No provider DTO carries that reference,
so the pairing cannot be resolved at import and guessing it would corrupt the
AR ledger. Reporting the count in the migration wizard is not enough: a result
screen is not rakenskapsinformation, and the gap has to be legible on the
record itself years later.

The disclosure now goes into invoices.notes and supplier_invoices.notes,
preserving whatever note the provider sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:57:48 +02:00
Jakob Wennberg 1c04262e06 fix(byra): stop passing a lucide icon across the server boundary on the KPI empty state (#2110)
app/(dashboard)/byra/kpi/page.tsx is a Server Component that passed the lucide
icon TrendingUp as icon={TrendingUp} into EmptyState, a Client Component.
lucide builds every icon with forwardRef, so the value cannot be serialized
across the RSC boundary: the render throws and the route 500s into the
dashboard error boundary. The branch runs only when the byra team has zero
non-archived clients, which is every brand-new byra on day one, and retrying
never helps.

Adds an EmptyByraClients preset beside the existing ones. A reference to a
client component is serializable where the icon is not, so the icon, the copy
and the design treatment are unchanged.

The preset reads from the byra namespace under its own translator name rather
than t, because i18n/__tests__/message-keys.test.ts maps one variable name to
one namespace per file: reusing t would silently re-point every other preset's
key in this file at byra.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:51:03 +02:00
Jakob Wennberg 4ec2ff4b4d fix(documents): name the journal_entries/fiscal_periods relationship so supplier-invoice underlag can anchor (#2109)
Prod has three foreign keys between journal_entries and fiscal_periods, so
PostgREST answers PGRST201 to any embed of that pair that does not name the
relationship. pickAnchorEntry() destructured only data, so the error was
dropped and the helper returned null on every call since it shipped on
2026-07-27: supplier-invoice underlag has never once anchored in production.
Users see "Underlag saknas" on a verifikat that plainly shows the invoice PDF.

Names the constraint, matching the already-merged sibling fix in
lib/transactions/inbox-underlag.ts (6a40b3c0e), and handles the error instead
of dropping it.

Adds scripts/checks/ambiguous-embed.mjs to the ratchet guard, because neither
test layer can see this class: a mocked Supabase client never resolves a
relationship, and pg-real bypasses PostgREST entirely. The check derives the
ambiguous table pairs by parsing supabase/migrations, so a migration adding a
second foreign key between two tables arms the guard on the same commit; the
derived list reproduces prod's pg_constraint output exactly. It accepts both
PostgREST hint forms (constraint name and FK column name, both in use here) and
parses aliased embeds, which is the shape the real bug took.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:44:54 +02:00
Mattsson 43341aa55c feat(ui): shift-click range selection on list row checkboxes (#2117)
* feat(ui): shift-click range selection on list row checkboxes

Click one checkbox, shift-click another, and every row between them
takes the clicked row's new state, the way mail clients work. Turns a
20-row bulk selection into two clicks.

New useRangeSelect hook (lib/hooks/use-range-select.ts) keeps the anchor
and applies the range over the rows as currently rendered, so it follows
filtering, sorting and paging rather than the underlying data order. A
shift-click with no valid anchor (first click, or the anchor filtered
away) degrades to a plain toggle. Select-all and clear reset the anchor.

Wired into the 8 selection surfaces: transaction inbox and skattekonto
inbox (separate ranges, since the two row types book through different
endpoints), journal entry list, invoices, supplier invoices, orders,
pending operations, invoice inbox workspace.

Radix' onCheckedChange carries no mouse event, so each row records
shiftKey from the click that precedes it; the checkbox cells get
select-none so shift-clicking does not smear a text selection.

The pure range rule is unit tested (10 cases: both directions, range
unselect, anchor invalidation, rendered-order independence).

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

* fix(ui): void the range anchor on an empty selection, keep placeholders out

Review findings from CodeRabbit and the skeptic pass, all in the new
range-selection feature:

- Clearing a selection left the anchor behind, so the next shift-click
  extended from a row the user could no longer see selected (click a row,
  press "Rensa markering", shift-click 30 rows down, get 30 rows). The
  explicit resetAnchor() calls only covered the clear paths that were
  wired by hand; several others (period change, filter change, post-bulk
  success, "Avmarkera") were not. An empty selection now counts as having
  no anchor, which covers every clear path including ones added later.
- The invoice inbox passed optimistic upload placeholders into visibleIds
  even though they render no checkbox. Safe today only because
  placeholders are always prepended; filtering them out makes the
  invariant local instead of depending on insert order elsewhere.
- pending: "Godkänn alla" pre-selects a non-empty set, so it resets the
  anchor explicitly.

Two existing tests used a fixture the UI cannot reach (an anchor with an
empty selection); they now start from the state a real anchor implies.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:37:25 +02:00
Jakob Wennberg 237bdd0366 perf(db): index the foreign keys that actually carry delete fan-out, and event_log by company (#2115)
Two of 311 performance advisories have a real mechanism. event_log (265k rows)
has no index containing company_id, so a company-scoped read falls back to a
primary-key scan with a filter. And journal_entries has 37 inbound foreign keys
with roughly 16 children lacking a usable index, so every deleted entry fires a
sequential scan per child: stripe_payouts has 0 rows and 77 445 seq scans,
supplier_invoices 2 153 rows and 725 816.

This is index hygiene, not a user-facing bug. In the measured 24 hours there
were zero 5xx across 250 838 gateway requests at p95 63 ms, and GET /api/events
had no traffic at all.

Roughly 14 indexes, not 173. Child tables under about 500 rows are skipped: a
one-page sequential scan beats an index probe and the planner ignores the index
anyway. Every candidate was checked against prod first, and any already covered
by an existing index whose partial predicate is implied was dropped, so this
adds no duplicate.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:29:47 +02:00
Jakob Wennberg 77becf3d65 fix(documents): record archive integrity checks in their own ledger so the nightly control advances again (#2108)
The 03:00 WORM verification cron stamped last_integrity_check_at on
document_attachments. enforce_period_lock_documents() fires on any UPDATE of a
row whose journal entry sits in a closed or locked period, without checking
whether the entry link actually changed, so a read-only integrity stamp was
rejected. The queue orders last_integrity_check_at ASC NULLS FIRST, so the
rejected rows re-sorted to the head every night and the batch became
permanently 200/200 blocked. Both call sites discarded the update error, so
nothing logged and nothing alerted.

Prod state: 34 557 current-version documents, 24 083 never checked, last
successful stamp 2026-08-31 03:00, nightly successes already decayed to
single digits.

Migration 017's enforcement triggers are legally required and never-touch, so
this does not narrow the trigger. The verification outcome moves to its own
document_integrity_checks table and the cron stops writing document_attachments
altogether, which takes the trigger off the write path. The legacy column stays
in place. Failures are now counted, logged and reported in the route's summary:
the silence is why this went unnoticed for weeks.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:23:05 +02:00
Jakob Wennberg 2c46d69d21 fix(sandbox): complete the teardown for the four FK and guard blockers added since August (#2112)
cleanup_sandbox_user deletes supplier_invoices and journal_entries without
first clearing four blockers added or missed since it was last fixed:
supplier_payment_batch_items (RESTRICT FK, that table was created 2026-08-10),
fiscal_periods.opening_balance_entry_id (NO ACTION FK whose NULLing is itself
blocked by enforce_opening_balance_immutability), terminal webhook_deliveries
rows, and payment_match_log rows whose company_id IS NULL, which fail the
shared audit bypass because it requires a non-null company.

Prod: 9 stale sandbox users, oldest 2026-07-22, retried nightly and failing
forever, so anonymous demo tenants accumulate indefinitely.

The bypass stays scoped to sandbox teardown. The audit-log immutability and the
webhook terminal-delete guard are not weakened for normal tenants, and the pg
test asserts they still bite for a non-sandbox tenant.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:14:45 +02:00
Jakob Wennberg 088754d61a fix(processing-history): register the missing event types, and strip the PII two of them carry (#2111)
* fix(processing-history): register the missing event types, and strip the PII two of them carry

Ten event types are emitted by code but absent from processing_event_types, so
every append fails the foreign key. Appends are best-effort try/catch, so no
user request fails, but the internal audit trail is empty for ten kinds of
legally motivated act, including the BFL 5 kap 5 § rattelse record when a user
swaps a transaction's underlag (TransactionDocumentReplaced) and the SOC 2
revocation record (OAuthClientRevoked).

Order matters and is deliberate. Two invoice-inbox events, RateLimitedDropped
and AttachmentsTruncated, put the raw sender address and mail subject in their
payload. Registering those types first would start persisting that PII into an
append-only table whose UPDATE is trigger-blocked and which the archive's
erasure path excludes. The strip therefore ships in this same commit, ahead of
the migration.

Only the invoice-inbox emitter was edited. whatsapp-inbox shares the
RateLimitedDropped type name with a payload that carries no phone number.

Closes the class rather than the two logged instances: a TypeScript union makes
an unregistered literal a compile error, and the pg test asserts the database
catalog is a superset of the code's list, generated from the union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(processing-history): strip the inbound-mail PII in the database, not by deploy ordering

Review finding (superagent-security, P2): shipping the emitter fix and the
catalog migration in one commit is not the same as one instant. Migrations
apply on merge while the replacement build takes minutes, so an old instance
can still write a sender address and mail subject in that window, and such a
row is permanent: processing_history takes no UPDATE and no DELETE, and the
archive export excludes it from the erasure path.

Adds a BEFORE INSERT trigger stripping `from` and `subject` from the
RateLimitedDropped and AttachmentsTruncated payloads, and keeps it afterwards
so the invariant belongs to the table rather than to one emitter's good
behaviour. The jsonb object check is load bearing: `payload - 'key'` raises on
a jsonb array and payload's shape is not constrained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:14:09 +02:00
Jakob Wennberg 21e6e2d314 fix(proxy): assert the production Supabase project for customer hosts instead of allowlisting them (#2107)
The white-label guard only fired for hostnames hand-listed in
CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS. improveone.accounted.se was never added,
so when the .accounted.se domains were pinned to a feature-branch preview the
guard let it through: a customer-branded login page served from a build that
inlines the staging Supabase project, on the open internet, with no alert. The
26 August willem.accounted.se 503s were the same misrouting caught correctly,
because willem was on the list.

Inverts the model. Any customer-facing production hostname (not a *.vercel.app
preview, not localhost) must be served by the production Supabase project or
the guard trips. Adding a new white-label host no longer requires editing a
list in order to be protected.

Also closes two fail-open holes found alongside it. parseBackendHostname
returned null for an undefined NEXT_PUBLIC_SUPABASE_URL, so a missing project
read as "not staging" and fell through; it now trips the guard. And proxy.ts
gains an explicit env guard: today lib/supabase/middleware.ts asserts the URL
and key non-null and @supabase/ssr throws synchronously as the first statement
of updateSessionInner, which takes down every path including /login and
/robots.txt with an opaque crash rather than a deliberate 503.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:07:34 +02:00
Jakob Wennberg 57d757192e fix(security): revoke anon EXECUTE on SECURITY DEFINER write RPCs (#2106)
* fix(security): revoke anon EXECUTE on SECURITY DEFINER write RPCs

Supabase's bootstrap ALTER DEFAULT PRIVILEGES grants EXECUTE on every
public-schema function to PUBLIC, anon, authenticated and service_role, and
PostgREST publishes each one at /rest/v1/rpc/<name>. Seven SECURITY DEFINER
functions were therefore unauthenticated cross-tenant primitives that bypass
RLS for anyone holding the public anon key:

  sync_team_to_company          INSERTs into company_members, the table
                                user_company_ids() and every RLS policy read
  claim_due_webhook_deliveries  returns every tenant's webhook payloads
  generate_delivery_note_number UPDATEs company_settings, needs only a company id
  generate_article_number       UPDATEs company_settings and articles
  generate_invoice_number       UPDATEs company_settings and invoices
  peek_next_invoice_number      leaks another tenant's prefix and next number
  get_next_arrival_number       leaks another tenant's ankomstnummer series

The last three carried a guard, and it did not hold. Its shape is
"IF auth.uid() IS NOT NULL AND NOT EXISTS (membership) THEN RAISE", with a
comment explaining that a NULL auth.uid() means service role or cron and is
trusted. The anon key's JWT carries no sub claim, so auth.uid() is NULL for
role anon as well, and the guard short-circuits straight into the trusted
branch.

Revokes EXECUTE from PUBLIC and anon on all seven, and from authenticated on
the two with no user-session caller. PUBLIC is mandatory: proacl carries
"=X/postgres", so revoking from anon alone leaves has_function_privilege true.
The five numbering RPCs the app calls on the user's session client are
re-granted to authenticated only after their guard was replaced with a
fail-closed one. A sweep then revokes PUBLIC and anon from every remaining
definer writer in public; all 20 carry explicit authenticated and service_role
grants, verified against prod, so no signed-in or service path changes.

tests/pg/definer-function-grants.pg.test.ts generates its assertion from that
same sweep rather than a hand list, because hand-listing is exactly how the
three guarded numbering RPCs came to be declared safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(security): keep procedures out of the definer-writer sweep

Review finding (CodeRabbit, Major): the sweep predicates excluded only trigger
return types, so a SECURITY DEFINER PROCEDURE would match and the migration
would then run REVOKE ... ON FUNCTION against it, which Postgres rejects,
aborting the whole migration. The pg test's offender query had the same gap and
would have reported a procedure the migration could not fix.

public holds no procedures today (verified against prod 2026-09-01), so this is
a guard against the first one anyone adds rather than a live bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:00:36 +02:00
Mattsson 08b1119c7d feat(connect): wire the SKV extension through the connector broker + data proxy (PR6b-2) (#2103)
* feat(connect): wire the SKV extension through the connector broker + data proxy (PR6b-2)

In connector mode (GNUBOK_CONNECTOR_KEY set, no own SKV credentials) the
Skatteverket extension now routes through the hosted connector stack
(#1757) instead of calling Skatteverket directly:

- skvRequestWithAuth routes to the data proxy: base URL maps to a service
  segment (moms/skattekonto/agd-inlamning/agd-period), the user's SKV
  Bearer moves to X-Connector-Upstream-Authorization, the connector key
  authenticates the proxy, and the gateway Client_Id/Client_Secret are
  omitted (the proxy adds Arcim's). Connector-layer 4xx bodies (code
  CONNECTOR_*) are classified before the SKV-shaped 401/403 sniffing so a
  broker refusal surfaces operator guidance (check GNUBOK_CONNECTOR_KEY),
  never APIGW/BankID guidance for knobs the instance does not have.
- OAuth: /authorize starts the consent via the broker's authorize-url
  (persisting its redirect_uri + connector_state), the hosted SKV callback
  bounces the code back to the instance, and exchangeCodeForTokens /
  refreshAccessToken exchange through the broker's /oauth/token,
  unwrapping its { data } envelope. Tokens still rest encrypted on the
  instance; client_id/client_secret never exist there.
- Broker refresh 404 CONNECTOR_NOT_OWNED maps to SESSION_EXPIRED
  (terminal; reconnect fixes); broker 502 stays a raw error so a transient
  SKV outage never re-arms the reconnect banner (#1155).
- getSkatteverketEnvironment() reports 'prod' in connector mode: the
  upstream env is hosted's, and the instance's unset defaults would show a
  false Testmiljo badge on real filings.
- System (CCG/ombud) auth is deliberately not brokered: hosted-only,
  stays direct.

Hosted and own-credentials self-hosts are byte-identical: every branch
gates on skatteverketConnectorMode(), which is null whenever own SKV
credentials exist or no connector key is set. Direct-path tests pin that.

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

* fix(connect): classify SKV dead-refresh-token dialects broker-side; forward diagnostic headers; connector-aware gateway guidance

Skeptic refutation on PR #2103 (found independently by the correctness and
compliance skeptics): the broker's /oauth/token catch-all collapsed SKV's
terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant,
"Refresh Token status is expired": the dominant refresh outcome, per-flow
tokens live 65 minutes) into the generic 502 CONNECTOR_SKV_TOKEN_FAILED, so
a connector instance could never classify ordinary session expiry: raw
English 500s instead of the reconnect flow, staged filing operations
consumed as non-recoverable, crons retrying raw forever.

- Broker /oauth/token: re-codes those dialects as 401
  CONNECTOR_SKV_REFRESH_DEAD, refresh grant only (invalid_grant on the code
  exchange means an expired one-shot code and keeps the generic 502). The
  classifier (isSkvDeadRefreshTokenError) uses the same regex set the
  extension's direct path classifies with.
- Instance dead-token classifier maps CONNECTOR_SKV_REFRESH_DEAD to
  SESSION_EXPIRED alongside 404 CONNECTOR_NOT_OWNED; the generic 502 stays
  a raw error so a transient SKV outage never re-arms the reconnect banner.
- Data proxy: forwards WWW-Authenticate and x-skv-*/x-amzn-*/x-api-*
  response headers (the instance's MISSING_SCOPE classification reads them;
  body-less gateway rejections carry no other signal).
- Instance gateway-refusal guidance is connector-aware: a self-host has no
  SKATTEVERKET_APIGW_CLIENT_ID and no Utvecklarportalen access, so connector
  mode points at /api/connector/status and support instead.

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

* fix(connect): reject redirects on the instance's broker OAuth requests

CodeRabbit inline finding (CWE-200): the connector-mode authorize-url and
token requests followed redirects by default, so a 307/308 would resend the
connector key (and code/refresh token) to the redirect target. redirect
'error', matching the broker's own postToken rule; the token response must
only ever come from the broker endpoint itself.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 12:11:34 +02:00
Mattsson ee22c9c7b7 feat(connect): connector status + Synka nu row in Settings -> Abonnemang (PR6b-3) (#2104)
* feat(connect): connector status + Synka nu row in Settings -> Abonnemang (PR6b-3)

Self-host only: shows per-upstream connector mode, key prefix, and the
active company's granted capabilities from GET /api/connector/status,
plus a manual run of the entitlement sync via the new authed
POST /api/connector/sync (requireWrite, 60s cooldown) instead of
waiting for the hourly cron. Hidden on hosted.

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

* fix(connect): handle sync fetch rejection, count capabilities not rows, not_configured toast

Skeptic findings on the Synka nu flow: a rejected fetch (instance
restarting) was a silent no-op with an unhandled rejection; the success
toast printed grant rows (companies x scopes) as capabilities; a
not_configured outcome claimed the hosted service was unreachable.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:44:33 +02:00
Mattsson aabddb592f feat(billing): multi-user paywall: multi_user capability, 20-day grace, owner-only dormancy (#2099)
* feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy

Multiple people in one company becomes a paid capability (multi_user, the
eighth PAID key). Derived at access time from capability_grants, no status
column, no enforcement cron:

- entitled: active grant (trial/stripe/team/manual/comp), everyone works
- grace: newest grant expired < 20 days ago; countdown banner for everyone
  in companies with > 1 user; invites still allowed
- frozen: only role=owner resolves; other memberships go dormant (rows
  untouched, paying reactivates instantly); invites 403 with paid-plan upsell

Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin
untouched: they also run on self-hosts, where the gate never bites), gated
query fallback for service-role/API-key paths, setActiveCompany guard, MCP
company-access check, invite route. Middleware routes all-frozen users to a
new /paused page; the switcher greys locked companies.

Migration 20260901081417 (applied to staging): trial trigger seeds
multi_user, backfills for mid-trial companies, active Stripe subs, team
agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20
days) for existing unpaid multi-member companies. Daily cron mails owners at
grace start and last day. Strings in sv+en; pg-real + unit tests included.

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

* fix(billing): multi-user seat gate hardening from skeptic review

- Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting
  it: the 20-day grace window hangs on an expired row, so a deleted one
  froze churned payers' staff instantly with no banner and no mail. Other
  stripe grants keep the freeze-and-retain delete.
- New SECURITY DEFINER company_multi_user_state() RPC (migration
  20260901083726, applied to staging) and RPC-first getMultiUserState:
  capability_grants RLS hides team-scoped rows from non-team users, so
  user-client reads misread byra-covered companies as frozen (switch
  refusal, wrong switcher locks).
- Byra-kind teams get a standing team-scoped multi_user grant (backfill +
  teams trigger): byra client companies have no company-scoped trial by
  design, so a grantless byra team would freeze every consultant and
  client user.
- Comped/manual companies with active PAID-key grants extend to multi_user
  (a comped company must not read as paying while locking out user two).
- /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403).
- PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user
  rows; the gated fallback would have frozen every non-owner mid-deploy).
- Grace cron: covers team-scoped lapses (byra agreement ending) and skips
  the start mail for the hand-mailed grandfather cohort.
- Tests updated/added across all touched surfaces; pg tests for the new
  RPC and byra trigger; trial-suppression pg test extended to 8 keys.

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

* fix(billing): decouple seat-gate env check and fail open on gate read throws

CI round 1 on #2099:
- isMultiUserEnforced no longer imports has-capability: several route test
  suites partially mock that module and the vitest mock guard threw from
  inside the v1 seat gate, turning expected 4xx responses into 500s.
  multi_user is never a connector capability, so the bypass reduces to the
  same env reads, now inlined.
- getMultiUserState wraps its resolution in a fail-open try/catch: a client
  without .rpc or a thrown network error must never lock users out.
- no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or()
  scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's
  timestamp .or(); all columns in both strings are literals.

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

* fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3)

company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and
were granted to authenticated with a caller-supplied company UUID: any
logged-in user could probe an arbitrary company's billing state and grace
deadline across tenants. Migration 20260901091752 (applied to staging)
requires an auth.uid() membership in the target company when a JWT is
present, keeps service-role/definer contexts unrestricted, and clamps the
grace window to [0, 20] days. pg tests: stranger gets false/NULL, member
reads normally, oversized p_grace_days cannot widen the probe.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:29:12 +02:00
Mattsson ca12b1855e fix(connect): PR #1758 CodeRabbit follow-up: connector status hardening, i18n strings, doc alignment (#2098)
* fix(connect): PR #1758 CodeRabbit follow-up: harden connector status, i18n the connector-mode strings, align docs

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:16:37 +02:00
Mattsson b74b5e3c0d fix(company): revoke store connections when archiving a company (#2096)
* fix(company): revoke store connections when archiving a company

Archiving a company left its WooCommerce/Shopify/Stripe connection rows
at status 'active'. The store-uniqueness partial indexes (one active
company per store) then blocked reconnecting the same store from any new
company with 'Butiken är redan ansluten till ett företag', and since
user_company_ids() hides archived companies there was no user-reachable
disconnect. The archive flow now flips pending/active connections to
'revoked' and nulls their secrets, mirroring the manual disconnect paths.

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

* fix(company): audit connection revocations and cover error path on archive

Review findings (compliance swarm A.8.15/A.8.29 + skeptic pass):
- write audit_log rows for each revoked store connection (the tables
  have no auto-audit trigger)
- revoke via .neq('status','revoked') so 'error'-state rows, which can
  also carry credentials, are cleared too
- test that the archive still succeeds when a connection revoke fails

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

* fix(company): literal payloads for connection revokes (phantom-column ceiling)

The spread/mapped payloads registered as unresolvable expressions in
tests/schema/no-phantom-columns.test.ts (392 > ceiling 391). Inline each
table's update as an object literal and insert audit rows one per row.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:12:12 +02:00
Mattsson 5f81c0638c fix(ui): make row selection checkboxes always visible at muted opacity (#2093)
* fix(ui): make row selection checkboxes always visible at muted opacity

User feedback: bulk-select checkboxes hidden until hover are "sjukt
pilligt": an invisible 16px target forces a precise hover-then-aim per
row, and bulk selection is a primary workflow on list pages.

New CHECKBOX_REVEAL_CLASS in dry-table.tsx: rest at opacity-50, solid on
row hover, focus, coarse pointers, and checked state. Applied to the 8
selection-checkbox sites (transaction inbox, skattekonto inbox, journal
list, invoices, supplier invoices, orders, pending, invoice inbox
workspace). Also fixes a touch bug: TransactionInboxCard and
SkattekontoInboxCard lacked pointer-coarse fallback, leaving unselected
checkboxes permanently invisible on touch devices.

Row action controls (chevrons, quiet links) keep HOVER_REVEAL_CLASS.

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

* fix(ui): meet WCAG 3:1 on resting selection checkboxes via border-foreground

Skeptic finding: the checkbox primitive's border-input is ~1.4:1 against
the page, so the new opacity-50 resting state composited to ~1.2:1,
under the 3:1 non-text contrast minimum design.md commits to. Putting
border-foreground unconditionally on the 8 reveal checkboxes lands the
resting border at >=3.4:1 in every theme (light 3.41:1, dark 4.24:1,
white-label palettes similar) and the hover/solid state at ~17:1.

The border class lives at call sites, not in CHECKBOX_REVEAL_CLASS,
because the constant is sometimes applied to a borderless wrapper
(InvoiceInboxWorkspace); documented in dry-table.tsx.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:39:34 +02:00
Mattsson 05dce83a2b feat(connect): Enable Banking client routes through the hosted proxy in connector mode (PR6b-1) (#2094)
* feat(connect): route the Enable Banking client through the hosted proxy in connector mode

PR6b-1 of the instance-side client wiring. Until now bankConnectorMode()
had no consumer but the status label; this makes a self-host with a
connector key and no own EB credentials actually reach Enable Banking
through the hosted bank proxy.

- api-client authenticatedFetch: in connector mode swap the base URL to
  the proxy and send the connector key as a Bearer token. The EB JWT
  signer (getAuthorizationHeader) is never called: the instance holds no
  private key. On hosted and on own-credentials self-hosts the direct
  path is byte-identical.
- startAuthorization forwards X-Connector-Company so the proxy can meter
  the per-company connection quota; index.ts passes companyId at both
  connect sites.
- createSession forwards the signed connector_state so the proxy binds
  the /sessions exchange to the pending ledger row (single-use, race-safe).
- callback route reads connector_state from the query (echoed by the
  hosted callback) and threads it through finalizeConnection.

Tests: connector-mode base/auth/company-header/connector_state assertions
in api-client, direct-path and own-credentials byte-identity, and the
callback threading both connector and direct paths.

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

* fix(connect): gate X-Connector-Company on connector mode, not on companyId

Skeptic regression finding: index.ts passes companyId to startAuthorization
unconditionally, and the header was attached whenever companyId was truthy.
On hosted and on own-credentials self-hosts companyId is always set, so every
direct POST /auth to the real Enable Banking API carried the tenant's internal
company UUID: a needless behavior change on the production path and an
identifier leak to a third-party PSD2 processor (the "byte-identical direct
path" claim was false).

Gate the header on bankConnectorMode() so it is sent only when the request
actually goes to the hosted proxy. Adds a direct-path test asserting the header
is absent even when companyId is passed.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:26:40 +02:00