diff --git a/.env.docker.example b/.env.docker.example index 0b347dbb..b3f4503b 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -40,6 +40,28 @@ NEXT_PUBLIC_SELF_HOSTED=true # (http:// by ws://). Set only if Realtime is served from another origin. # NEXT_PUBLIC_SUPABASE_WS_URL=wss://your-project.supabase.co +# Optional: AI features (document extraction, in-app assistant). The image +# includes the extraction extensions; set ONE backend. See +# docs/SELF-HOSTING.md "AI Features" for the full list of model overrides. +# 1. Claude via AWS Bedrock (inference stays inside the EU through the eu. +# cross-region inference profile; AWS_REGION is the API endpoint, not a pin): +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# AWS_REGION=eu-north-1 +# 2. Claude via the direct Anthropic API: +# ANTHROPIC_API_KEY= +# 3. Any OpenAI-compatible endpoint (e.g. a Swedish inference provider; see +# docs/SOVEREIGN.md). A model id is required. Extraction, the assistant's +# question-and-answer and categorization run here; only the specialized +# Anthropic-only conversational flows return 503 (see docs/SELF-HOSTING.md, +# What runs on any model). +# AI_BASE_URL=https://api.example.se/v1 +# AI_API_KEY= +# AI_MODEL= +# AI_EXTRACTION_MODEL= # a vision model, if AI_MODEL is not one +# AI_VISION=true # false for a text-only model +# AI_PDF_MODE=auto # auto | native | rasterize (the image ships pdftoppm) + # Optional: outbound email (invoices, reminders). Pick one provider. # 1. Resend (what hosted runs): # RESEND_API_KEY= diff --git a/DECISIONS.md b/DECISIONS.md index a72c8ad4..6a2c967a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1140,6 +1140,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-20] Reconciliation opens on the FULL year and keeps its own range-preset memory, separate from the shared report-family key. Inheriting a "Denna manad" preset last used on Resultatrapport would show an alarming difference for a window the user never chose on this page, and a part-year reconciliation window answers a question nobody asked. [2026-08-20] The matcher now runs automatically once per window+account when there is unmatched work, instead of waiting for a button many users never found. It is a dry run: nothing is written, and Tillampa still requires an explicit click. ?autorun=1 keeps a distinct meaning (run even on a clean window) so the transactions-inbox deep link still produces a result rather than silence. [2026-08-20] Unmatched bank rows that no voucher on the account could settle (direction-compatible and equal to the ore) get "Bokfor" linking to /transactions?highlight= instead of a match picker. They are unbooked affarshandelser, not reconciliation work, and the picker held nothing for them. The rule is deliberately strict: a false negative offers booking on a pairable row (a legitimate outcome), a false positive sends the user into an empty picker. +[2026-08-20] Sovereign package docs (plan WS2 PR1): docs/SOVEREIGN.md is written as regulatory-risk elimination with a per-provider fact sheet checked on the vendors' own pages (Elastx CaaS/DBaaS/3 Stockholm AZs/ISO 27001:2022; GleSYS VPS + S3, no managed k8s, EU-owned not Swedish-owned; Safespring S3 with Object Lock COMPLIANCE/GOVERNANCE; Berget api.berget.ai/v1 with gemma-4-31B-it vision and an SLA that excludes serverless; evroc Think Models EU-only), never as "US cloud is illegal", and it leads with the MCP server as the agent surface that needs no AI provider at all (alignment rule R5). The connector subscription is described as planned and not yet available rather than documented as if it shipped. Vercel Speed Insights is now gated behind !isSelfHosted() in app/layout.tsx (the last ungated hosted-only telemetry; read via lib/env/public-flags per the folded-flag rule). Backup/restore ship as scripts/self-host/{backup,restore}.sh (pg_dump custom format + storage volume tar + optional db-config volume for the pgsodium root key, SHA-256 manifest, AWS CLI v2 against any S3-compatible endpoint, optional COMPLIANCE-mode Object Lock) with a bash -n + refusal-path test, because self-hosted Supabase has no managed backups and BFL 7 kap needs a credible 7-year archive. Stale self-host docs fixed: the 4-of-23 cron table replaced by a pointer to the generated crontab and the pgvector line corrected (nothing stores embeddings). [2026-08-20] Vercel build heap is raised through vercel.json `buildCommand` (`NODE_OPTIONS=--max-old-space-size=6144 npm run build`), not a project env var and not `build.env`: a project-level NODE_OPTIONS also reaches function runtime (V8 sizes the heap against a limit the function does not have), and `build.env` is marked deprecated in the vercel.json schema; `buildCommand` scopes the flag to the build exactly like core-build.yml's 8192 does for CI. 6144 fits the standard 4-core/8 GB build machine next to the main next process; the type-check needs ~4.5 GB and was hanging at V8's ~4 GB default ceiling (4 production timeouts 2026-08-14..20). [2026-08-20] The production build type-checks tsconfig.build.json (tsconfig.json minus tests and mocks) via typescript.tsconfigPath; tsconfig.json stays the editor/ESLint view of the whole repo. Why: Next 16.3 runs the project-local tsc CLI by default (prerequisite for TypeScript 7's native checker, which has no JS API) and that checker checks the complete project it is given, whereas the old API checker silently dropped diagnostics from __tests__/*.test.* files. ~490 real type errors live in test files today (mostly route handlers called without the ctx argument); vitest never type-checks them, so nothing caught them. Excluding tests from the build keeps that debt where it was (invisible) instead of turning it into a red deploy; a separate tsc job for tests is the follow-up that makes it visible. Measured: tests are ~10% of the check's memory, so this is correctness, not the memory fix (that is the vercel.json heap bump). [2026-08-20] AI provider abstraction (#1406 Tier 2, Sovereign plan WS1 PR1) ships extraction-first: a job-shaped service in lib/ai (generateText / generateStructured / extractFromDocument, NO streaming members) with an Anthropic-family adapter that delegates to the existing createAiClient() factory and sends the exact request literals the call sites sent before (request-shape tests deep-equal them, so hosted Bedrock stays byte-identical), plus an OpenAI-compatible adapter for BYO Swedish endpoints. Only document extraction moved onto it in this PR; the chat loop, composer, receipt hunt and WhatsApp interpreter stay on the direct SDK behind a shrink-only allowlist in the new direct-ai-client antipattern check, because the AI surface audit (2026-08-18) recommends ripping the chat runtime and making the composer deterministic, and porting streaming + translation for code with a delete recommendation against it would be wasted work. The streaming port is gated on that founder call (plan rule R3). @@ -1341,6 +1342,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review). [2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring. [2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card. +[2026-08-30] scripts/self-host/{backup,restore}.sh keep ACLs (--no-owner, no --no-privileges) AND restore.sh resets the restoring role's ALTER DEFAULT PRIVILEGES to PostgreSQL's built-in acldefault() before pg_restore, then diffs an ACL manifest (acl-manifest.sql: what anon/authenticated/service_role may do with every public function and relation, ordered under collate "C") that backup.sh took from the source: the migrations carry ~170 REVOKE statements that lock SECURITY DEFINER RPCs (create_company_for_brand_signup, get_dashboard_nav_flags, reserve_voucher_range, ...) away from anon/authenticated, and keeping ACLs alone does not preserve them because pg_dump writes ACLs as a diff against acldefault() (never "REVOKE FROM anon") while a Supabase target's default privileges re-grant anon/authenticated on every restored function and table; reproduced on supabase/postgres 15 (get_dashboard_nav_flags came back anon-executable) and drilled with the real scripts (576 public objects identical after restore, tampered manifest caught). Reset means exactly acldefault, not just "revoke anon/authenticated/service_role": every added grantee including PUBLIC is revoked (IN SCHEMA entries are additive and vanish once empty), and a global entry that took PUBLIC's built-in EXECUTE on functions or USAGE on types away gets it back, so a function the source never touched (NULL ACL) restores PUBLIC-executable; the restoring role's defaults are whatever the stack set, and the dump's DEFAULT ACL section re-creates them once every object exists. A manifest mismatch has no override: it is the one thing a restore drill must never pass silently. Rejected: re-applying GRANT/REVOKE statements harvested from supabase/migrations after pg_restore, because it needs a parser for 700 files, drifts as soon as a function is dropped or renamed, and still would not verify the outcome. [2026-08-28] Skill, atom and API-doc texts must never claim a capability is absent when it is gated (#546): Peppol send has been live since 2026-08-21 behind a per-company access grant (app/api/invoices/[id]/peppol/send, scripts/peppol/access.ts), yet invoicing-rules, customer-onboarding, the swedish-invoice-compliance atom, docs/PEPPOL_FOUNDATION.md and the v1 :send/:mark-sent descriptions still told agents it did not exist, which sends B2G users to a competitor. Every touched text now says "gated per company, request under Inställningar > Fakturering", keeps the restrictions explicit (aktiebolag senders only, standard invoices only, no MCP or v1 send verb yet) so agents do not over-promise to enskild firma users, and keeps the external-provider + gnubok_mark_invoice_as_sent fallback for companies without access. The skills guard test was inverted from pinning "no Peppol send" to pinning the truthful claim, so a future regression in either direction fails CI. [2026-08-28] Generated agent_atom_registry seed renamed from the generator's max-plus-one version (20260828220001) to 20260829000100 (#546): the migration rule for this batch is a version newer than every existing file and dated after today's parallel worktrees, which also emit seeds with generator-picked 20260828 names; skills:check compares the content-hash manifest, not the filename, and the pg replay test globs the seed, so the rename is inert for both. [2026-08-29] PR #2021 review round (#546): the v1 :send/:mark-sent descriptions now say :mark-sent is not needed after a SUCCESSFUL dashboard Peppol send and IS the documented recovery when the network accepted the document but issuance failed (send route returns 201 with issuance.ok=false and the invoice still draft; the dashboard toast peppol_issue_failed_description points the user to Markera som skickad; ensureInvoiceNumber is idempotent so no second F-number is consumed). The unconditional "never call :mark-sent after a Peppol send" would have made an agent refuse the one action that completes the bookkeeping. The relayed restriction list in all three agent texts gained the exporter's other refusals (SEK, taxable 6/12/25 % VAT only, no ROT/RUT deductions, Er referens) and the send cap, because the ROT/RUT section sits directly above the Peppol section in invoicing-rules and a bygg company would otherwise be promised a send the exporter refuses. The atom edit was seeded as a SECOND generated migration (20260829010000, atom v8) next to the PR's 20260829000100 (v7) rather than editing that file in place: the generator is append-only by design, and the Supabase preview branch for the PR has already applied 20260829000100, so replacing or removing it would orphan the preview's migration history; both seeds are idempotent upserts with the version guard, so prod applying both is harmless. The guard test's "capability absent" patterns were re-anchored on the capability as subject (active, passive and adjective forms, each proven against the pre-#546 wording inline) so the true v1 sentence "a v1 or MCP Peppol send action is not yet available" stays legal and a passive rewording no longer evades the test. @@ -1358,6 +1360,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-29] get_vat_ruta_source_lines ACL restored in a NEW migration (20260829090500) rather than by editing 20260828172003: that file DROPped the 9-arg overload and CREATEd the 11-arg one without restating REVOKE/GRANT, and DROP FUNCTION discards the ACL, so the new signature silently fell back to EXECUTE for PUBLIC (anon included); the migration is already applied on prod, so a follow-up file is the only compliant path. Rule going forward: every DROP + CREATE of an RPC must restate its REVOKE ALL FROM PUBLIC, anon / GRANT EXECUTE TO authenticated, service_role, and tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts now pins it with has_function_privilege (anon false, authenticated and service_role true, exactly one overload). [2026-08-29] PR #1756 replacement (rebind on PSD2 remap, amends the 2026-07-09 #916 entry): when upsertFromPsd2 resolves a duplicate row for the same connection+uid, the duplicate's MOVABLE transactions (unbooked, unmatched, not anchored via transaction_voucher_links or a payment row: the #1570 single-row move gate) are rebound onto the promoted row BEFORE the duplicate is resolved, so categorize/booking proposes the ledger the user just mapped instead of the overflow slot; a duplicate that still holds booked or anchored rows is demoted to manual as before and never deleted (their vouchers carry the old 19xx line, and the #1643 orphan guards handle the released twin). The contributor's unconditional rebind-all-then-delete was narrowed for that reason. [2026-08-29] Database errors now keep their SQLSTATE: new lib/errors/db-error.ts (dbError/errorCauseTag), applied at the 54 `throw new Error(\`Database error: ${err.message}\`)` sites in the MCP server AND, far more importantly, at lib/supabase/fetch-all.ts:74 where `throw new Error(error.message)` was the single highest-traffic strip point in the codebase (31 callers; every paginated read). isTransientFailure() checks the driver code FIRST and 57014 (statement timeout) is already in TRANSIENT_SQLSTATES, so discarding it turned a retryable timeout into UNKNOWN_ERROR ("Något gick fel. Försök igen."), which an agent cannot dispatch on. Traced end to end: gnubok_query_journal -> fetchEntryLines -> fetchAllRows (code stripped here) -> the tool's own sanitizeDbError, which ALREADY had a correct TRANSIENT_ERROR branch with a "retry or narrow with date_from/date_to" hint that could never fire because getStructuredError saw an anonymous Error. Measured on prod over 60 days with bot actors excluded: 1 024 real-agent failures, 645 UNKNOWN_ERROR across 60 actors and 57 companies; query_journal failed 164 times at p50 8 110 ms while every other failing tool sat at 1-315 ms; 82 retry streaks, 462 wasted repeat calls, 53.1% of error calls inside a streak. fetch-all passes context=null so the driver message stays VERBATIM (sanitizeDbError and other callers match on the existing text; this change adds the code, it does not reword). Attaching `code` is safe because extractCode() only accepts /^[A-Z_]+$/ and every SQLSTATE/PostgREST code contains digits, so it cannot hijack the application error registry (pinned by a test). dbError also never renders the literal "undefined": a driver-level failure with no message produced "Database error: undefined", the string that made these unsearchable. errorCauseTag() returns a PII-safe SQLSTATE for telemetry; the raw driver message can quote row values in a constraint violation and belongs in the server log, never in event_log. NOT ratcheted: check:types reports 538 vs baseline 539 because main fixed an unrelated error in own-account-detector.test.ts after the baseline was set; the gate only fails on an INCREASE, so the baseline is left alone rather than adding unrelated churn to this diff. +[2026-08-30] scripts/self-host/backup.sh requires BACKUP_QUIESCE_CMD and BACKUP_RESUME_CMD as a pair (exit 2 otherwise) and marks the quiesce as attempted BEFORE running the hook: a hook such as `docker compose stop app cron` can stop app and then fail on cron, set -e ends the script there, and with the marker set only after success the EXIT trap never ran the resume hook, so a failed backup left the operator's app stopped (Superagent P2 on #1744). acl-manifest.sql now also records USAGE/SELECT/UPDATE per PostgREST role for relkind 'S' (has_table_privilege does not see sequence privileges), so a restore that changes a sequence grant fails the manifest diff instead of passing (P3 on #1744). Manifest lines from older backup sets simply lack the sequence rows and will show as a diff against a current restore; that is the correct outcome for a set taken before this change. [2026-08-30] Reminder settings disclosure (PR #2033) keeps the cron unscheduled: re-adding the vercel.json entry would fail the crontab ratchet (INTENTIONALLY_UNSCHEDULED) and log daily 503s; the full re-enable checklist incl. idempotency prerequisites lives in lib/invoices/reminders-enabled.ts. [2026-08-30] delete_draft_invoice risk tier 'high' (not 'medium' like update_invoice): both outcomes are irreversible (hard delete removes the row; makulering permanently consumes the F-series number), so the op must never be auto-committed. [2026-08-30] v1 DELETE invoices/{id} returns INVOICE_DELETE_NOT_DRAFT as 409 via the status override (registry maps it to 400 for the cookie route): a state-machine refusal is a conflict on v1, aligned with INVOICE_UPDATE_NOT_DRAFT; web behavior left unchanged. diff --git a/README.md b/README.md index 94c9ee94..1952f2aa 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ cd accounted docker compose up -d ``` -You need a Supabase project and must apply the database migrations before first use. See [docs/SELF-HOSTING.md](docs/SELF-HOSTING.md) for the full step-by-step guide, including Supabase setup, auth configuration, optional features (AI, email, push notifications), and troubleshooting. +You need a Supabase project and must apply the database migrations before first use. See [docs/SELF-HOSTING.md](docs/SELF-HOSTING.md) for the full step-by-step guide, including Supabase setup, auth configuration, optional features (AI, email, push notifications), and troubleshooting. To run everything on Swedish infrastructure (your own Supabase stack, Swedish hosting, AI on Swedish GPUs), see [docs/SOVEREIGN.md](docs/SOVEREIGN.md). ## Development Setup diff --git a/app/layout.tsx b/app/layout.tsx index 525ab786..3d0deaed 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -22,6 +22,7 @@ import { PaletteProvider } from "@/components/providers/PaletteProvider"; import { SWRProvider } from "@/components/providers/SWRProvider"; import { ScrollbarReveal } from "@/components/ScrollbarReveal"; import { ensureInitialized } from "@/lib/init"; +import { isSelfHosted } from "@/lib/env/public-flags"; import { getBranding } from "@/lib/branding/service"; import { resolveRequestBrand } from "@/lib/branding/request-brand"; import { getBrandFontPair } from "@/lib/branding/fonts"; @@ -215,7 +216,11 @@ export default async function RootLayout({ - + {/* Vercel Speed Insights is hosted-only telemetry: a self-hosted + (AGPL) instance must not report its users' page timings to our + Vercel project. Read through lib/env/public-flags, never compared + in place (the Docker build folds in-place NEXT_PUBLIC_* reads). */} + {!isSelfHosted() && }