6a85efb00a3bbcce0bb645b1d81789c7862b9162
1495 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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 (
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |