docs(self-host): sovereign Sverige guide, backup/restore scripts, Speed Insights gate (#1744)
docs/SOVEREIGN.md (run Accounted on Swedish infrastructure: providers, self-hosted Supabase gotchas, backup/restore runbook, honest dependency list), scripts/self-host/backup.sh + restore.sh (pg_dump custom format, storage tar, SHA-256 manifest, S3-compatible upload; ACLs are preserved through the restore and re-verified against an acl-manifest including sequences; the resume hook always runs after a failed quiesce and the hooks must be configured as a pair), Vercel Speed Insights gated off for self-hosted, and stale self-host docs corrected (assistant Q&A and categorization run on BYO OpenAI-compatible models; SMTP via EMAIL_PROVIDER=smtp after #1746; connector subscription described as proposed only).
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -1140,6 +1140,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-20] Reconciliation opens on the FULL year and keeps its own range-preset memory, separate from the shared report-family key. Inheriting a "Denna manad" preset last used on Resultatrapport would show an alarming difference for a window the user never chose on this page, and a part-year reconciliation window answers a question nobody asked.
|
||||
[2026-08-20] The matcher now runs automatically once per window+account when there is unmatched work, instead of waiting for a button many users never found. It is a dry run: nothing is written, and Tillampa still requires an explicit click. ?autorun=1 keeps a distinct meaning (run even on a clean window) so the transactions-inbox deep link still produces a result rather than silence.
|
||||
[2026-08-20] Unmatched bank rows that no voucher on the account could settle (direction-compatible and equal to the ore) get "Bokfor" linking to /transactions?highlight=<id> instead of a match picker. They are unbooked affarshandelser, not reconciliation work, and the picker held nothing for them. The rule is deliberately strict: a false negative offers booking on a pairable row (a legitimate outcome), a false positive sends the user into an empty picker.
|
||||
[2026-08-20] 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] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
|
||||
[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring.
|
||||
[2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card.
|
||||
[2026-08-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] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+6
-1
@@ -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({
|
||||
</ThemeProvider>
|
||||
</BrandProvider>
|
||||
</NextIntlClientProvider>
|
||||
<SpeedInsights />
|
||||
{/* 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() && <SpeedInsights />}
|
||||
<Script src="/sw-register.js" strategy="afterInteractive" />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+14
-14
@@ -54,7 +54,7 @@ The migrations automatically enable these extensions:
|
||||
| Extension | Migration | Purpose |
|
||||
|-----------|-----------|---------|
|
||||
| `uuid-ossp` | 001 | UUID generation |
|
||||
| `vector` (pgvector) | 033 | AI embedding storage (for AI extensions) |
|
||||
| `vector` (pgvector) | 033 | Created by an early migration; no current code path stores embeddings, the extension only needs to exist for the migration to apply |
|
||||
| `btree_gist` | 042 | Fiscal period overlap prevention |
|
||||
| `pg_cron` | 048 | In-database scheduled jobs |
|
||||
|
||||
@@ -186,14 +186,14 @@ To bring in more users, invite them from **Settings > Company > Members** (compa
|
||||
|
||||
## Scheduled Jobs
|
||||
|
||||
The cron sidecar runs these jobs automatically:
|
||||
The cron sidecar runs the schedule in [`docker/crontab.self-hosted`](../docker/crontab.self-hosted). That file is generated from the `crons` array in `vercel.json` (the single source of truth, shared with the hosted service) by `npm run crontabs:generate`, and a test fails CI if it drifts, so this guide does not repeat the table: open the file for the exact jobs and times. They fall into these groups:
|
||||
|
||||
| Schedule (UTC) | Endpoint | Purpose |
|
||||
|----------------|----------|---------|
|
||||
| Daily 06:00 | `/api/deadlines/status/cron` | Update deadline statuses |
|
||||
| Daily 08:00 | `/api/invoices/reminders/cron` | Send overdue invoice reminders |
|
||||
| Yearly Jan 2 | `/api/tax-deadlines/cron` | Generate tax deadlines for the new year |
|
||||
| Sundays 03:00 | `/api/documents/verify/cron` | SHA-256 integrity check on document archive |
|
||||
- **Every minute / every few minutes**: webhook dispatch, WhatsApp and invoice-inbox sweeps (crash recovery for staged uploads).
|
||||
- **Hourly**: recurring invoices, cloud-backup auto-sync, idempotency-key cleanup.
|
||||
- **Nightly (UTC)**: deadline statuses, tax deadlines, document-archive SHA-256 verification, event and pending-operation cleanup, sandbox cleanup, booking-template sync, bank sync, skattekonto sync, accrual posting, receipt hunt, WhatsApp retention.
|
||||
- **Skatteverket receipts**: AGI every 15 minutes, VAT every two hours.
|
||||
|
||||
Extension endpoints are listed unconditionally: one whose extension is not enabled answers a cheap no-op, so enabling it later needs no crontab change.
|
||||
|
||||
All cron endpoints are authenticated with `Authorization: Bearer <CRON_SECRET>`. The cron container calls the app over the internal Docker network (`http://app:3000`), so these endpoints are not exposed publicly.
|
||||
|
||||
@@ -213,7 +213,7 @@ The stock self-hosted image includes both extraction extensions, so these creden
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
**Option 2: AWS Bedrock.** Requires an AWS account with Bedrock model access to Claude. This is what the hosted service runs, because it keeps inference inside eu-north-1: choose it if you need the AI calls to stay in the EU, which the direct API does not guarantee.
|
||||
**Option 2: AWS Bedrock.** Requires an AWS account with Bedrock model access to Claude. This is what the hosted service runs, because it keeps inference inside the EU (the `eu.` cross-region inference profile; `AWS_REGION` is the API endpoint, not a pin to one region): choose it if you need the AI calls to stay in the EU, which the direct API does not guarantee.
|
||||
|
||||
```bash
|
||||
AWS_ACCESS_KEY_ID=...
|
||||
@@ -267,7 +267,7 @@ AI_PROVIDER=bedrock|anthropic|openai-compatible # force the backend (see below
|
||||
|
||||
The pre-existing names `BEDROCK_MODEL_ID`, `BEDROCK_OPUS_MODEL_ID`, `BEDROCK_SONNET_MODEL_ID` and `BEDROCK_MAX_TOKENS` keep working as the same overrides (extraction, heavy, standard, extraction cap) on every backend; the `AI_*` names take precedence when both are set. Claude deployments default every tier to `claude-sonnet-5`.
|
||||
|
||||
When several credential sets are present, Bedrock wins, then the direct Anthropic API, then the OpenAI-compatible endpoint, so that adding a key for an experiment cannot silently move production inference out of eu-north-1. Set `AI_PROVIDER` to say which you mean. A model id written without a provider prefix is adapted to whichever backend is active; an id that already carries one (`eu.anthropic.…`) is used as-is.
|
||||
When several credential sets are present, Bedrock wins, then the direct Anthropic API, then the OpenAI-compatible endpoint, so that adding a key for an experiment cannot silently move production inference out of the EU. Set `AI_PROVIDER` to say which you mean. A model id written without a provider prefix is adapted to whichever backend is active; an id that already carries one (`eu.anthropic.…`) is used as-is.
|
||||
|
||||
Without working credentials the rest of the app runs normally: uploads are stored but not auto-interpreted (the upload UI sees that immediately rather than waiting for a timeout), and the AI assistant answers `503 ai_unconfigured`.
|
||||
|
||||
@@ -327,7 +327,7 @@ SMTP_FROM_EMAIL=faktura@your-domain.se
|
||||
|
||||
Without either, invoices can still be generated as PDFs but cannot be emailed.
|
||||
|
||||
**Invitations do not require Resend.** When no mail provider is configured, the invite is still created and the accept link is returned to the inviter in the app (a copy button under the pending invitations list, plus a warn-level log record whose msg is `email service not configured: invite email skipped`; the Docker image logs JSON, so grep for the message text, not a `WARN` prefix; the token is never logged). Share the link manually; it is valid until the invitation expires. Resend (or plain SMTP once [#1746](https://github.com/erp-mafia/accounted/pull/1746) lands) is only needed if you want the invitation mailed automatically. There is no re-send for company invitations: revoke and invite again for a new link.
|
||||
**Invitations do not require Resend.** When no mail provider is configured, the invite is still created and the accept link is returned to the inviter in the app (a copy button under the pending invitations list, plus a warn-level log record whose msg is `email service not configured: invite email skipped`; the Docker image logs JSON, so grep for the message text, not a `WARN` prefix; the token is never logged). Share the link manually; it is valid until the invitation expires. A mail provider (Resend, or your own relay with `EMAIL_PROVIDER=smtp`, both above) is only needed if you want the invitation mailed automatically. There is no re-send for company invitations: revoke and invite again for a new link.
|
||||
|
||||
Note the two separate mail paths: this variable drives the app's own mail (invoices, invitations, reminders); account mail from GoTrue (signup confirmation, password reset, and the account-provisioning invite when `AUTH_SIGNUPS_DISABLED=true`) goes through the Supabase **Authentication > SMTP Settings** described in [Configure Authentication](#2-configure-supabase-auth) and [Troubleshooting](#troubleshooting).
|
||||
|
||||
@@ -395,7 +395,7 @@ The Next.js app is stateless: all data lives in Supabase. The Docker entrypoint
|
||||
|
||||
## Fully Self-Hosted (No Supabase Cloud)
|
||||
|
||||
The setup above relies on a Supabase project at supabase.com. If you also want to host the database, auth, and storage yourself (to keep all data on-premises, avoid the SaaS dependency, or run air-gapped) you can pair Accounted with [Supabase's official Docker self-hosting stack](https://supabase.com/docs/guides/self-hosting/docker) instead.
|
||||
The setup above relies on a Supabase project at supabase.com. If you also want to host the database, auth, and storage yourself (to keep all data on-premises, avoid the SaaS dependency, or run air-gapped) you can pair Accounted with [Supabase's official Docker self-hosting stack](https://supabase.com/docs/guides/self-hosting/docker) instead. For the fully Swedish variant of this (Swedish hosting, Swedish object storage with retention locks for the 7-year archive, AI on Swedish GPUs, backup/restore runbook) see [SOVEREIGN.md](SOVEREIGN.md); the mechanics below apply there too.
|
||||
|
||||
This is a more involved path. You take responsibility for backups, TLS certificates, image upgrades, and Postgres operations. It is intended for operators already running Docker services who are comfortable with PostgreSQL.
|
||||
|
||||
@@ -436,7 +436,7 @@ flowchart LR
|
||||
|
||||
### Setup outline
|
||||
|
||||
1. **Bring up Supabase** following [supabase.com/docs/guides/self-hosting/docker](https://supabase.com/docs/guides/self-hosting/docker). Generate your own `JWT_SECRET`, `ANON_KEY`, and `SERVICE_ROLE_KEY` (Supabase ships `sh utils/generate-keys.sh`). Pick a hostname for the API gateway (e.g. `supabase.example.com`) and point `SUPABASE_PUBLIC_URL` / `API_EXTERNAL_URL` at it.
|
||||
1. **Bring up Supabase** following [supabase.com/docs/guides/self-hosting/docker](https://supabase.com/docs/guides/self-hosting/docker). Generate your own `JWT_SECRET`, `ANON_KEY`, and `SERVICE_ROLE_KEY` (Supabase ships `sh utils/generate-keys.sh`). Pick a hostname for the API gateway (e.g. `supabase.example.com`) and point `SUPABASE_PUBLIC_URL` at it and `API_EXTERNAL_URL` at it **including the `/auth/v1` path** (upstream docker 0.7.0, July 2026, changed this). The API gateway depends on the upstream release you check out: `self-hosted/v0.7.x` runs Kong by default and offers Envoy through the `docker-compose.envoy.yml` overlay; `self-hosted/v0.8.0` and later run Envoy by default and keep Kong available through `docker-compose.kong.yml`. The diagram above says `kong`; the role is the same, the container name follows your release and overlays.
|
||||
|
||||
2. **Apply the Accounted migrations** directly via `psql`: the Supabase CLI (`db push`) assumes a cloud project, so run the SQL files against the self-hosted database container:
|
||||
|
||||
@@ -574,7 +574,7 @@ portable base file alone.
|
||||
|
||||
### What you give up vs. cloud Supabase
|
||||
|
||||
- **Backups** are entirely your responsibility: set up `pg_dump` (or a tool like restic) to off-host storage. As a portable, vendor-neutral *logical* backup on top of the raw dump, you can also export each fiscal period as a standard **SIE4** file via the API and archive it: any Swedish bookkeeping system can re-import it:
|
||||
- **Backups** are entirely your responsibility. The repo ships `scripts/self-host/backup.sh` / `restore.sh` (`pg_dump` custom format with ACLs kept, ACL manifest, storage tar, optional db-config volume, to any S3-compatible bucket with Object Lock; see [SOVEREIGN.md, section 5](SOVEREIGN.md#5-backup-and-restore-ship-it-do-not-improvise-it)); scheduling and monitoring them is still on you. As a portable, vendor-neutral *logical* backup on top of the raw dump, you can also export each fiscal period as a standard **SIE4** file via the API and archive it: any Swedish bookkeeping system can re-import it:
|
||||
|
||||
```bash
|
||||
curl -fsS -H "Authorization: Bearer <reports:read API key>" \
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Kör Accounted helt svenskt: the sovereign self-host guide
|
||||
|
||||
This guide is for operators who want Accounted on Swedish (or strictly EU) infrastructure end to end: the ledger and its documents on servers in Sweden, AI on GPUs in Sweden, and source code you can audit. It builds on [SELF-HOSTING.md](SELF-HOSTING.md) (the general Docker guide) and its "Fully Self-Hosted" section; read those first, this guide only adds what the sovereign variant changes.
|
||||
|
||||
Two honest framings up front:
|
||||
|
||||
- **What you get is regulatory-risk elimination, not a legal verdict.** Hosted Accounted runs on Supabase and Vercel in AWS eu-north-1 (Stockholm) with AI inference on AWS Bedrock inside the EU; each of those providers operates under its own GDPR transfer mechanisms and contract terms (Data Privacy Framework participation and/or standard contractual clauses, documented in their DPAs), and whether that combination satisfies your policy is your assessment to make, not a conclusion this guide draws. What a self-host on Swedish providers removes is the *exposure*: no provider in the chain is subject to US extraterritorial law (the CLOUD Act), which is exactly the risk Sweden's national cloud policy of May 2026 names. That holds only for the chain you actually run: a sovereign deployment that keeps a US-dependent service such as Resend for outbound email has that one touchpoint left (section 6 lists them). The policy is principles for the public sector, not a mandate; it is still the document a procurement officer can point at.
|
||||
- **Not every Swedish accounting vendor runs on US clouds**, so do not buy this guide as a claim that "everyone else does". Buy it because you want to be able to prove, provider by provider, where your books are.
|
||||
|
||||
Everything here is free to run under the AGPL. Services that only Accounted can operate (bank sync through our PSD2 licence, Skatteverket API submission, company lookup, provider migration) are hosted-only today; a connector subscription for self-hosted instances is planned but **not yet available** (see "What is and is not covered" below). Manual filing of VAT and AGI declarations (file generation, you upload at Skatteverket) is always free and works on a self-host.
|
||||
|
||||
## 1. What a sovereign deployment looks like
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
user((User / byrå))
|
||||
agent((Your own AI agent<br/>Claude, Codex, local model))
|
||||
subgraph se["Swedish infrastructure (your account)"]
|
||||
proxy["Reverse proxy + TLS"]
|
||||
app["Accounted app + cron<br/>(this repo's Docker image)"]
|
||||
supa["Self-hosted Supabase stack<br/>Postgres, Auth, Storage"]
|
||||
s3[("S3-compatible bucket<br/>backups, Object Lock")]
|
||||
ai["Swedish inference API<br/>(OpenAI-compatible)"]
|
||||
end
|
||||
user -- HTTPS --> proxy --> app --> supa
|
||||
agent -- MCP (API key) --> app
|
||||
app -- receipts, invoices --> ai
|
||||
app -. nightly backup.sh .-> s3
|
||||
```
|
||||
|
||||
Three things carry the sovereign claim, in order of how much they matter:
|
||||
|
||||
1. **Storage and database**: the self-hosted Supabase stack (Postgres + Auth + Storage) on a Swedish provider, with backups on Swedish S3 under retention locks. This is where the räkenskapsinformation lives.
|
||||
2. **The agent surface needs no AI provider at all.** Most automation against Accounted runs through the MCP server (100+ tools, scoped API keys, staged approvals) driven by *your* agent: Claude Code, Codex, an OpenClaw setup, or a local model. The MCP server makes zero model calls itself. A deployment with no AI credentials configured is fully usable that way.
|
||||
3. **In-app AI** (reading receipts and invoices, the assistant) is optional and bring-your-own: point `AI_BASE_URL` at a Swedish OpenAI-compatible endpoint. Document extraction, the assistant's question-and-answer (`/chat` and the docked assistant sheet, via `/api/agent/ask`) and one-tap transaction categorization run on any OpenAI-compatible backend; only the specialized Anthropic-only conversational flows (VAT review, KPI explanation, settings help, the bokslut step-through, the operation-staging inbox flows) answer `503` there, see [SELF-HOSTING.md, What runs on any model](SELF-HOSTING.md#what-runs-on-any-model). The MCP path needs no model at all.
|
||||
|
||||
## 2. What is and is not covered
|
||||
|
||||
| Free and local on a self-host (AGPL) | Hosted-only or backend-restricted today |
|
||||
|---|---|
|
||||
| Double-entry bookkeeping, invoicing, supplier invoices, reports, SIE import/export | Bank sync via Enable Banking (runs on Accounted's PSD2/AISP credentials) |
|
||||
| VAT and AGI file generation for manual filing at Skatteverket | Skatteverket API submission and skattekonto sync (Accounted's API client registration) |
|
||||
| Document archive with SHA-256 integrity and WORM bucket | Company lookup (TIC), migration from Fortnox/Visma/Bokio/Björn Lundén via the Arcim gateway |
|
||||
| MCP server, API keys, staged approvals | Receipt hunt in a connected mailbox (Accounted's Google OAuth app), WhatsApp intake (Accounted's Meta credentials), Stripe billing |
|
||||
| AI document extraction, assistant Q&A and one-tap categorization on a BYO endpoint; HTML mail invoices | Specialized conversational flows (VAT review, KPI explanation, settings help, bokslut helpers): Anthropic-family backend only (Bedrock or the direct API), not a BYO OpenAI-compatible endpoint ([#1800](https://github.com/erp-mafia/accounted/issues/1800)) |
|
||||
| Push notifications (your VAPID keys), invoice email via your own SMTP relay (`EMAIL_PROVIDER=smtp`) or Resend (section 6) | |
|
||||
|
||||
The hosted-only rows (everything in the right column except the AI row, which is a backend restriction a connector key would not change) are what a connector subscription for self-hosted instances would unlock (priced at parity with hosted, per active company). That connector-key registry is proposed (PRs #1747, #1748, #1751, #1757 and #1758, none merged) and **not yet available**: today there is no instance-side variable to set and nothing to subscribe to, and the extensions' settings screens will tell you those services are unconfigured.
|
||||
|
||||
## 3. Choosing Swedish infrastructure
|
||||
|
||||
Facts below were checked on the providers' own pages in August 2026; verify before you sign, these change.
|
||||
|
||||
### Elastx (Stockholm; recommended primary)
|
||||
|
||||
- Swedish-owned (Elastx AB), data in Sweden, one region `se-sto` with three availability zones (`sto1`, `sto2`, `sto3`), each a separate data center up to 20 km apart. ISO/IEC 27001:2022, 27017, 27018, ISO 14001. Publishes a DPA with an annual audit right. Trust Center: https://elastx.se/en/trust-center
|
||||
- What fits this stack: **Kubernetes CaaS** (managed, three-AZ nodes, managed ingress and cert-manager) or plain OpenStack VMs; **DBaaS PostgreSQL** 14 to 17 with optional HA and PITR (one-week default retention); S3-compatible object storage via OpenStack Swift (`swift.elastx.cloud`, SigV4 region must be `us-east-1`). Pricing and SLA: https://elastx.se/en/pricing, https://elastx.se/en/availability-sla
|
||||
- Fit: the most "managed" Swedish option. Run the Accounted app + cron containers on CaaS or a VM, and either run the full Supabase stack yourself or point the stack's Postgres at DBaaS (self-hosted Supabase expects its own `supabase/postgres` image with extensions; using an external managed Postgres is possible but you take on the extension and role setup yourself, so the VM route with the stock stack is simpler).
|
||||
|
||||
### GleSYS (Falkenberg and Stockholm; budget VPS path)
|
||||
|
||||
- Own data centers in Falkenberg and Stockholm (plus Finland), ISO/IEC 27001:2022, 9001, 14001. EU jurisdiction; note the company is Swedish-headquartered but majority-owned by a Luxembourg infrastructure fund since 2023, which some buyers' sovereignty criteria distinguish from Swedish-owned. Public DPA (no processing outside EU/EEA). https://glesys.com/compliance-security, https://glesys.com/terms-policies/data-processing-agreement
|
||||
- What fits: **KVM VPS** for the whole stack on one or two hosts, S3-compatible **Object storage** in Stockholm/Falkenberg, managed PostgreSQL (not needed if you run the stock Supabase stack). No managed Kubernetes. https://glesys.com/products/
|
||||
- Fit: the cheapest credible path for a single company or a small byrå that is comfortable operating Docker Compose on a VPS.
|
||||
|
||||
### Safespring (Stockholm, Oslo; the backup bucket)
|
||||
|
||||
- Safespring Storage is Ceph-based, fully S3-compatible, **supports S3 Object Lock in both COMPLIANCE and GOVERNANCE modes plus legal hold and bucket default retention**, and versioning. Sites `sto1`, `sto2` (Stockholm) and `osl2` (Oslo); the cheaper Archive tier is `sto2` only. Endpoints `s3.sto1.safedc.net`, `s3.sto2.safedc.net`. No egress charges. https://docs.safespring.com/storage/object-locking/, https://www.safespring.com/en/price/
|
||||
- Fit: the place for the backup sets from `scripts/self-host/backup.sh`. Object Lock must be enabled when the bucket is created, it cannot be switched on later. COMPLIANCE mode is the right setting for the yearly archive copy: nobody, including you, can delete it before the retention date, which is what makes it a credible BFL 7 kap archive.
|
||||
|
||||
### Swedish AI inference (for in-app extraction)
|
||||
|
||||
- **Berget AI** (default in this guide): OpenAI-compatible API at `https://api.berget.ai/v1`; public model list at `/v1/models`. Vision-capable models suitable for receipts as of August 2026 include `google/gemma-4-31B-it` and `mistralai/Mistral-Medium-3.5-128B`; text-only models such as `zai-org/GLM-5.2` work for the assistant's question-and-answer but cannot read images. Markets Swedish data centers; the DPA wording is "within the EEA", so ask for the specific site in writing if your policy needs "Sweden". **The SLA excludes serverless inference**: raise that in procurement. https://docs.berget.ai/models/overview, https://berget.ai/en/dpa, https://berget.ai/en/sla
|
||||
- **evroc**: OpenAI-compatible "Think Models" API at `https://models.think.evroc.com/v1` with vision models (Gemma 4, Qwen3-VL, Kimi); EU residency, flagship Stockholm data center expected in H2 2026, so confirm where inference runs today. DPA with no sub-processors. https://docs.evroc.com/products/think/think.html, https://evroc.com/legal/data-processing-addendum/
|
||||
|
||||
Configure either through the standard variables (details in [SELF-HOSTING.md, AI Features, Option 3](SELF-HOSTING.md#ai-features)):
|
||||
|
||||
```bash
|
||||
AI_BASE_URL=https://api.berget.ai/v1
|
||||
AI_API_KEY=...
|
||||
AI_MODEL=google/gemma-4-31B-it # vision-capable: reads receipts and PDFs
|
||||
# AI_EXTRACTION_MODEL=... # if you want a different model for documents
|
||||
# AI_VISION=false # only if you chose a text-only model
|
||||
# AI_PDF_MODE=rasterize # default on these endpoints; the image ships pdftoppm
|
||||
```
|
||||
|
||||
Then prove it end to end before telling users: `npx tsx scripts/smoke-ai-provider.ts ./some-receipt.pdf` from a checkout next to your `.env`.
|
||||
|
||||
### Coolify as the deployment tool
|
||||
|
||||
If you would rather not hand-write systemd units, [Coolify](https://coolify.io) (Apache-2.0, self-hosted) deploys Docker Compose projects onto any VPS you own over SSH, so it works on Elastx VMs and GleSYS KVM alike. Accounted's `docker-compose.yml` deploys as a compose resource; use Coolify's proxy for TLS instead of the Caddy overlay. Two documented gotchas: a `ports:` mapping in your compose exposes the port on the host *outside* Coolify's proxy (the Accounted file binds to loopback, keep it that way), and Coolify's one-click Supabase template lagged upstream (Postgres 15 while upstream defaults to 17, and a known bug that exposed the database port publicly), so for the sovereign stack run upstream Supabase's own compose rather than the template. https://coolify.io/docs/knowledge-base/docker/compose, https://coolify.io/docs/services/supabase
|
||||
|
||||
Managed catalogs that host open-source apps for you (PikaPods, Elestio) are EU-located but not Swedish; they are the middle option, not the headline.
|
||||
|
||||
## 4. Self-hosted Supabase: gotchas as of August 2026
|
||||
|
||||
These are the things that cost people an afternoon. Source: the upstream Docker self-hosting docs and changelog (https://supabase.com/docs/guides/self-hosting/docker, https://github.com/supabase/supabase/blob/master/docker/CHANGELOG.md).
|
||||
|
||||
- **`API_EXTERNAL_URL` now includes the `/auth/v1` path** (docker 0.7.0, July 2026): `API_EXTERNAL_URL=https://supabase.example.com/auth/v1`. Older guides show it without the path; GoTrue then builds wrong links.
|
||||
- **Postgres 17 is the default image** since docker 0.6.0 (June 2026). Never start the 17 image on a 15 data directory: use upstream's `utils/upgrade-pg17.sh` (needs roughly twice the database size free, and back up the pgsodium root key from the `db-config` volume first). https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17
|
||||
- **The gateway depends on your release**: `self-hosted/v0.7.x` runs Kong by default (Envoy via the `docker-compose.envoy.yml` overlay), `self-hosted/v0.8.0` and later run Envoy by default (Kong via `docker-compose.kong.yml`). The diagrams in SELF-HOSTING.md say `kong`; the role is the same.
|
||||
- **Studio is single-project** in self-hosted mode (`STUDIO_DEFAULT_ORGANIZATION` / `STUDIO_DEFAULT_PROJECT`); a byrå hosting many client companies still runs one Supabase project, since Accounted's multi-tenancy is inside the database.
|
||||
- **No managed backups, no PITR.** Upstream says so plainly. This is why the next section exists.
|
||||
- **Storage backend**: by default storage-api writes files to `./volumes/storage` (`STORAGE_BACKEND=file`). To put documents straight onto Swedish S3, set `STORAGE_BACKEND=s3` with `STORAGE_S3_BUCKET`, `STORAGE_S3_ENDPOINT`, `STORAGE_S3_REGION`, `STORAGE_S3_FORCE_PATH_STYLE=true` and the bucket's `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` in the storage service. Accounted's `documents` bucket is WORM at the application level either way (migration 024); Object Lock on the S3 side adds the provider-level guarantee.
|
||||
|
||||
## 5. Backup and restore (ship it, do not improvise it)
|
||||
|
||||
Swedish bookkeeping law requires the ledger and its underlag to be kept for seven years after the end of the fiscal year, and self-hosted Supabase gives you no backups. Two scripts in this repo cover the minimum:
|
||||
|
||||
- `scripts/self-host/backup.sh`: logical `pg_dump` (custom format, `--no-owner`, ACLs kept) of the Supabase database, an ACL manifest of the `public` schema (`scripts/self-host/acl-manifest.sql`: what anon, authenticated and service_role may do with every function, relation and sequence), a tar of the storage volume (the documents), optionally the `db-config` Docker volume (the pgsodium root key; without it Vault-encrypted columns are unreadable after a restore), SHA-256 manifest, uploaded to an S3-compatible bucket with optional **COMPLIANCE-mode Object Lock**.
|
||||
- `scripts/self-host/restore.sh <name> --yes`: downloads a set, verifies checksums, unpacks db-config if asked, restores the database (`pg_restore --clean --if-exists --no-owner`), then re-runs the ACL manifest against the restored database and **stops on any difference**, then unpacks storage.
|
||||
|
||||
Why the ACL step exists: the migrations lock SECURITY DEFINER RPCs away from `anon`/`authenticated` with role-specific `REVOKE`s, but `pg_dump` writes ACLs as a diff against PostgreSQL's built-in defaults, so the dump only says "REVOKE FROM PUBLIC; GRANT TO service_role" and never "REVOKE FROM anon". A Supabase stack's `ALTER DEFAULT PRIVILEGES` would then hand `anon` EXECUTE back to every restored function. `restore.sh` resets the restoring role's default privileges to PostgreSQL's built-in ones right before `pg_restore` (the dump's own last section re-creates the source's defaults once every object exists, so later migrations still get the grants PostgREST needs) and the manifest diff proves the result. Reproduced on `supabase/postgres` 15 and `postgres` 17 (without the step, hardened RPCs and tables come back anon-reachable) and drilled with the real scripts against a migrated database: 576 `public` objects identical after the restore, a tampered manifest stops it.
|
||||
|
||||
`pg_restore` reports errors on a Supabase target even when the restore is fine, because the restoring `postgres` role is not a superuser and the stack already owns and grants its own objects. Expected classes: "already exists"; "does not exist" from `DROP ... IF EXISTS` of policies and triggers on a fresh target; "must be member of role supabase_*", "must be owner of ...", "permission denied ..." and "grant options cannot be granted back" on `GRANT`/`REVOKE`/`ALTER DEFAULT PRIVILEGES` for objects in `auth`, `storage`, `realtime`, `extensions`, `cron`, `vault`, `graphql*`, `pgbouncer`. Any error on a `public.*` object is **not** expected: investigate it. On errors the script stops before the ACL check and storage (the db-config volume, if requested, is already unpacked at that point); re-run with `RESTORE_TOLERATE_ERRORS=1` once you have read the log. An ACL mismatch has no override: fix the named objects by hand from the printed diff, or restore into a fresh stack.
|
||||
|
||||
Requirements on the host running them: `pg_dump`/`pg_restore`/`psql` matching the server major, `tar`, `gzip`, AWS CLI v2 (talks to any S3-compatible endpoint via `--endpoint-url`), and `docker` only if you back up the db-config volume. Uploads go through `aws s3api put-object`, which caps a single object at 5 GB and has no multipart fallback in the script: a dump or storage tar past that size needs splitting or another uploader before the run succeeds.
|
||||
|
||||
### Nightly plus yearly
|
||||
|
||||
Create the bucket with Object Lock enabled (it cannot be enabled afterwards). Then, on the host that can reach the database (a `.env.backup` you keep out of git):
|
||||
|
||||
```bash
|
||||
export BACKUP_DATABASE_URL='postgresql://postgres:<password>@127.0.0.1:5432/postgres'
|
||||
export BACKUP_S3_ENDPOINT='https://s3.sto2.safedc.net'
|
||||
export BACKUP_S3_BUCKET='accounted-backups'
|
||||
export AWS_ACCESS_KEY_ID='...'; export AWS_SECRET_ACCESS_KEY='...'
|
||||
export BACKUP_STORAGE_DIR='/opt/supabase/docker/volumes/storage'
|
||||
export BACKUP_DB_CONFIG_VOLUME='supabase_db-config'
|
||||
```
|
||||
|
||||
```cron
|
||||
# nightly set, 35 days immutable (covers mistakes, keeps storage bounded)
|
||||
0 2 * * * . /root/.env.backup && BACKUP_OBJECT_LOCK_DAYS=35 /opt/accounted/scripts/self-host/backup.sh
|
||||
# yearly archive copy after bokslut, seven years plus margin, COMPLIANCE mode,
|
||||
# app stopped for the window so database and documents are one consistent set
|
||||
0 3 15 1 * . /root/.env.backup && BACKUP_LABEL=yearly BACKUP_OBJECT_LOCK_DAYS=2700 BACKUP_QUIESCE_CMD='docker compose -f /opt/accounted/docker-compose.yml stop app cron' BACKUP_RESUME_CMD='docker compose -f /opt/accounted/docker-compose.yml start app cron' /opt/accounted/scripts/self-host/backup.sh
|
||||
```
|
||||
|
||||
Alert on a non-zero exit: the script prints a few progress lines on success and fails loudly; if your cron mails stdout, add `>/dev/null` to the entry and keep stderr. The database dump and the storage tar are taken one after the other, so an upload landing in that window gives a set with a document row but no file (or the reverse); the nightly run accepts that (the next night covers it), and for the yearly archive run set `BACKUP_QUIESCE_CMD` / `BACKUP_RESUME_CMD` to stop and start the app containers around the run so the set is consistent. Storage grows by one dump plus one storage tar per run; the nightly lock expires and a lifecycle rule on the bucket can expire old nightly objects, the yearly ones cannot be deleted before their date by anyone.
|
||||
|
||||
### Restore drill (do this once before you need it)
|
||||
|
||||
1. Bring up a fresh Supabase stack on a scratch VM with the **same** `JWT_SECRET`, `ANON_KEY` and `SERVICE_ROLE_KEY` as production (or re-issue keys into Accounted's `.env` afterwards).
|
||||
2. Two passes, because the db-config volume can only be replaced while the database container is stopped and `pg_restore` needs it running:
|
||||
- Stop the database container, then `RESTORE_DB_CONFIG_VOLUME=<volume> RESTORE_SKIP_DATABASE=1 scripts/self-host/restore.sh <name> --yes` (unpacks the pgsodium root key, touches nothing else). Start the database container again.
|
||||
- `RESTORE_DATABASE_URL=postgresql://postgres:...@<scratch-db>:5432/postgres RESTORE_STORAGE_DIR=<supabase-dir>/volumes/storage scripts/self-host/restore.sh <name> --yes` (database, ACL check, storage). Expect the first attempt to stop on the Supabase error classes above; read the log, re-run with `RESTORE_TOLERATE_ERRORS=1`. Then restart storage-api.
|
||||
3. Point a scratch Accounted at it, log in, open a verifikat and its underlag, run the document-archive verification cron once (`/api/documents/verify/cron`): it recomputes SHA-256 over the archive and reports mismatches. The ACL check already ran inside `restore.sh` ("ACL manifest verified"); if you want to see it with your own eyes, `select has_function_privilege('anon', 'public.get_dashboard_nav_flags(uuid)', 'EXECUTE')` must be `false`.
|
||||
4. Write down how long it took. That number is your recovery time.
|
||||
|
||||
## 6. Honest dependency list (what still touches a non-Swedish party)
|
||||
|
||||
A sovereign deployment still has these touchpoints. None carries accounting data; list them for your own risk register rather than pretending they are gone.
|
||||
|
||||
- **Image distribution**: the app image is pulled from GitHub Container Registry (`ghcr.io/erp-mafia/gnubok`), and the cron sidecar downloads `supercronic` from GitHub Releases at build time. Mirror both into your own registry for an air-gapped setup (build from source: `docker compose -f docker-compose.yml -f docker-compose.build.yml up --build`).
|
||||
- **Fonts**: `next/font/google` downloads Geist and Hedvig Letters Serif **at build time** and self-hosts them; browsers never call Google. The GitHub-built image therefore has no runtime font dependency; a source build fetches them once during `next build`.
|
||||
- **Invoice email**: the email extension sends through Resend (US) or, with `EMAIL_PROVIDER=smtp`, through your own relay: a Swedish mail provider, an M365/Workspace relay, Postfix on the host (variables in SELF-HOSTING.md, Email section; TLS is required unless you set `SMTP_REQUIRE_TLS=false` for a plaintext relay on a trusted LAN). Pick SMTP for a sovereign deployment, or leave invoice email unconfigured (invoices download as PDF). Resend, if you choose it, carries invoice PDFs to your customers but no ledger data.
|
||||
- **Telemetry**: none. Analytics (PostHog) and Vercel Speed Insights are hosted-only and switched off by `NEXT_PUBLIC_SELF_HOSTED=true`; there is no error-tracking integration at all (SELF-HOSTING.md, Error Tracking: errors go to the container logs); there is no call-home licence check, by design.
|
||||
- **Upstream services you opt into**: Enable Banking, Skatteverket, TIC, the migration gateway, Google OAuth for receipt hunt, Meta for WhatsApp are hosted-only today (section 2) and simply stay unconfigured.
|
||||
|
||||
## 7. Checklist
|
||||
|
||||
- [ ] Provider chosen for compute (Elastx CaaS/VM or GleSYS VPS) and a DPA on file.
|
||||
- [ ] Supabase stack up with `API_EXTERNAL_URL` including `/auth/v1`, Postgres 17 image on a fresh data dir, `ADDITIONAL_REDIRECT_URLS` for the app's callbacks.
|
||||
- [ ] Accounted migrations applied via `psql` (SELF-HOSTING.md, Fully Self-Hosted step 2).
|
||||
- [ ] `NEXT_PUBLIC_SELF_HOSTED=true`, `CRON_SECRET` set, cron sidecar healthy (`docker compose ps`), `/api/health` green.
|
||||
- [ ] Backup bucket created **with Object Lock**, `backup.sh` scheduled nightly + yearly, one restore drill completed and timed.
|
||||
- [ ] AI: either none (MCP-only deployment) or `AI_BASE_URL`/`AI_API_KEY`/`AI_MODEL` set and `npx tsx scripts/smoke-ai-provider.ts receipt.pdf` green.
|
||||
- [ ] Decide on invoice email (your own SMTP relay with `EMAIL_PROVIDER=smtp`, Resend, or none) and record it in your register.
|
||||
- [ ] Read the national cloud policy yourself before quoting it to a buyer: it is principles, not mandates. https://www.regeringen.se/informationsmaterial/2026/05/en-molnpolicy-for-sverige--for-okad-sakerhet-effektivitet-och-innovation-i-den-offentliga-forvaltningen/
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { execFileSync, type ExecFileSyncOptions } from 'node:child_process'
|
||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// The self-host backup/restore scripts are shipped product (docs/SOVEREIGN.md
|
||||
// tells operators to run them on a schedule), so at least their syntax and
|
||||
// their refusal paths are checked in CI. Behaviour against a real bucket and
|
||||
// database is exercised by the operator's first dry run per the runbook.
|
||||
const DIR = join(__dirname, '..')
|
||||
const SCRIPTS = ['backup.sh', 'restore.sh']
|
||||
// A clean environment: none of the BACKUP_* / RESTORE_* variables, so the
|
||||
// scripts' own guards are what runs. Typed as ProcessEnv (the repo's
|
||||
// augmentation makes NODE_ENV a required key) so it can be spread into the
|
||||
// per-test environments below without a cast.
|
||||
const BARE_PROCESS_ENV: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? '', NODE_ENV: 'test' }
|
||||
const BARE_ENV: ExecFileSyncOptions = { env: BARE_PROCESS_ENV, stdio: 'pipe' }
|
||||
// Enough environment for backup.sh to get past its required-variable guards;
|
||||
// nothing here is ever contacted (the tests below stop before any tool runs).
|
||||
const BACKUP_REQUIRED_ENV: NodeJS.ProcessEnv = {
|
||||
...BARE_PROCESS_ENV,
|
||||
BACKUP_DATABASE_URL: 'postgresql://postgres:x@127.0.0.1:1/postgres',
|
||||
BACKUP_S3_ENDPOINT: 'https://s3.invalid',
|
||||
BACKUP_S3_BUCKET: 'bucket',
|
||||
AWS_ACCESS_KEY_ID: 'key',
|
||||
AWS_SECRET_ACCESS_KEY: 'secret',
|
||||
}
|
||||
|
||||
function runBash(args: string[], env: NodeJS.ProcessEnv): { status: number; stderr: string } {
|
||||
try {
|
||||
execFileSync('bash', args, { env, stdio: 'pipe' })
|
||||
return { status: 0, stderr: '' }
|
||||
} catch (err) {
|
||||
return {
|
||||
status: (err as { status?: number }).status ?? -1,
|
||||
stderr: String((err as { stderr?: Buffer }).stderr ?? ''),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('self-host shell scripts', () => {
|
||||
for (const name of SCRIPTS) {
|
||||
it(`${name} parses under bash -n`, () => {
|
||||
expect(() => execFileSync('bash', ['-n', join(DIR, name)])).not.toThrow()
|
||||
})
|
||||
|
||||
it(`${name} sets strict mode and a private umask`, () => {
|
||||
const src = readFileSync(join(DIR, name), 'utf8')
|
||||
expect(src).toContain('set -euo pipefail')
|
||||
expect(src).toContain('umask 077')
|
||||
})
|
||||
|
||||
it(`${name} keeps ACLs in the dump (no --no-privileges), drops ownership (--no-owner), uses the ACL manifest`, () => {
|
||||
// The migrations REVOKE hardened SECURITY DEFINER RPCs from anon and
|
||||
// authenticated (REVOKE ... FROM PUBLIC, anon; GRANT ... TO service_role).
|
||||
// --no-privileges would strip that from the dump and a restored self-host
|
||||
// would re-expose those RPCs over PostgREST; --no-owner is what lets the
|
||||
// dump land in a stack whose roles were created by that stack.
|
||||
const src = readFileSync(join(DIR, name), 'utf8')
|
||||
// The invocation lines, not the comments that explain them.
|
||||
const commands = src.split('\n').filter((line) => /^\s*pg_(dump|restore) /.test(line))
|
||||
expect(commands.length).toBeGreaterThan(0)
|
||||
for (const command of commands) {
|
||||
expect(command).toContain('--no-owner')
|
||||
expect(command).not.toContain('--no-privileges')
|
||||
}
|
||||
expect(src).toContain('acl-manifest.sql')
|
||||
})
|
||||
}
|
||||
|
||||
it('restore.sh neutralizes the restoring role default privileges before pg_restore and diffs the ACL manifest after it', () => {
|
||||
// pg_dump writes ACLs as a diff against acldefault(), so the dump never
|
||||
// says "REVOKE FROM anon"; a Supabase target's ALTER DEFAULT PRIVILEGES
|
||||
// would hand anon EXECUTE back to every restored function unless those
|
||||
// defaults are removed first. The manifest diff is what makes a restore
|
||||
// that lost the hardening fail instead of pass silently.
|
||||
const src = readFileSync(join(DIR, 'restore.sh'), 'utf8')
|
||||
const neutralize = src.indexOf('ALTER DEFAULT PRIVILEGES')
|
||||
const restore = src.indexOf('pg_restore --clean')
|
||||
const check = src.lastIndexOf('acl-manifest.sql')
|
||||
expect(neutralize).toBeGreaterThan(-1)
|
||||
expect(restore).toBeGreaterThan(neutralize)
|
||||
expect(check).toBeGreaterThan(restore)
|
||||
// Exactly the built-in default, not "no grants at all": a global entry
|
||||
// that took EXECUTE on functions away from PUBLIC gets it back, otherwise
|
||||
// a function the source never touched (NULL ACL, PUBLIC-executable)
|
||||
// restores as non-executable.
|
||||
expect(src).toContain('TO PUBLIC')
|
||||
expect(src).toContain('ACL MISMATCH')
|
||||
})
|
||||
|
||||
it('acl-manifest.sql states every PostgREST role for functions, relations and sequences in public', () => {
|
||||
const sql = readFileSync(join(DIR, 'acl-manifest.sql'), 'utf8')
|
||||
for (const role of ['anon', 'authenticated', 'service_role']) {
|
||||
expect(sql).toContain(`has_function_privilege('${role}'`)
|
||||
expect(sql).toContain(`has_table_privilege('${role}'`)
|
||||
// Sequences have their own privilege set (USAGE, SELECT, UPDATE) that
|
||||
// has_table_privilege does not see; a restore that hands anon nextval
|
||||
// on an id sequence must show up in the diff like any other object.
|
||||
for (const privilege of ['USAGE', 'SELECT', 'UPDATE']) {
|
||||
expect(sql).toContain(`has_sequence_privilege('${role}', c.oid, '${privilege}')`)
|
||||
}
|
||||
}
|
||||
expect(sql).toMatch(/relkind = 'S'/)
|
||||
expect(sql).toContain("'public'::regnamespace")
|
||||
// Byte-identical output on both sides regardless of database collation.
|
||||
expect(sql).toContain('collate "C"')
|
||||
// restore.sh shows the mismatching lines by object kind; a sequence line
|
||||
// must not be filtered out of that excerpt.
|
||||
const restore = readFileSync(join(DIR, 'restore.sh'), 'utf8')
|
||||
expect(restore).toContain('(function|relation|sequence) ')
|
||||
})
|
||||
|
||||
it('restore.sh does not require RESTORE_DATABASE_URL for the db-config pass (RESTORE_SKIP_DATABASE=1)', () => {
|
||||
let status = 0
|
||||
let stderr = ''
|
||||
try {
|
||||
execFileSync('bash', [join(DIR, 'restore.sh'), 'nightly-20260101T000000Z', '--yes'], {
|
||||
...BARE_ENV,
|
||||
env: { ...BARE_PROCESS_ENV, RESTORE_SKIP_DATABASE: '1' },
|
||||
})
|
||||
} catch (err) {
|
||||
status = (err as { status?: number }).status ?? -1
|
||||
stderr = String((err as { stderr?: Buffer }).stderr ?? '')
|
||||
}
|
||||
expect(status).not.toBe(0)
|
||||
expect(stderr).not.toContain('RESTORE_DATABASE_URL')
|
||||
expect(stderr).toContain('BACKUP_S3_ENDPOINT is required')
|
||||
})
|
||||
|
||||
it('backup.sh refuses to run without the required environment', () => {
|
||||
let status = 0
|
||||
try {
|
||||
execFileSync('bash', [join(DIR, 'backup.sh')], BARE_ENV)
|
||||
} catch (err) {
|
||||
status = (err as { status?: number }).status ?? -1
|
||||
}
|
||||
expect(status).not.toBe(0)
|
||||
})
|
||||
|
||||
it('backup.sh refuses a quiesce hook without its resume hook, and the reverse, before running anything', () => {
|
||||
// A quiesce command with no resume command would leave the operator's app
|
||||
// stopped after every run; refusing up front is the only safe answer.
|
||||
const quiesceOnly = runBash([join(DIR, 'backup.sh')], { ...BACKUP_REQUIRED_ENV, BACKUP_QUIESCE_CMD: 'true' })
|
||||
expect(quiesceOnly.status).toBe(2)
|
||||
expect(quiesceOnly.stderr).toContain('BACKUP_QUIESCE_CMD is set but BACKUP_RESUME_CMD is not')
|
||||
const resumeOnly = runBash([join(DIR, 'backup.sh')], { ...BACKUP_REQUIRED_ENV, BACKUP_RESUME_CMD: 'true' })
|
||||
expect(resumeOnly.status).toBe(2)
|
||||
expect(resumeOnly.stderr).toContain('BACKUP_RESUME_CMD is set but BACKUP_QUIESCE_CMD is not')
|
||||
})
|
||||
|
||||
it('backup.sh runs the resume hook when the quiesce hook fails part-way', () => {
|
||||
// `docker compose stop app cron` can stop `app` and then fail on `cron`;
|
||||
// set -e ends the script right there, and the EXIT trap must still run
|
||||
// the resume hook or the operator's app stays down after a failed backup.
|
||||
// The required tools are stubbed on PATH so the script reaches the hook;
|
||||
// none of them is ever executed because the hook fails first.
|
||||
const stubs = mkdtempSync(join(tmpdir(), 'accounted-backup-stubs-'))
|
||||
try {
|
||||
for (const tool of ['pg_dump', 'psql', 'tar', 'gzip', 'aws']) {
|
||||
const stub = join(stubs, tool)
|
||||
writeFileSync(stub, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(stub, 0o755)
|
||||
}
|
||||
const marker = join(stubs, 'resumed')
|
||||
const result = runBash([join(DIR, 'backup.sh')], {
|
||||
...BACKUP_REQUIRED_ENV,
|
||||
PATH: `${stubs}:${process.env.PATH ?? ''}`,
|
||||
BACKUP_QUIESCE_CMD: 'exit 3',
|
||||
BACKUP_RESUME_CMD: `touch "${marker}"`,
|
||||
})
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
} finally {
|
||||
rmSync(stubs, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('backup.sh marks the quiesce attempt before running the hook, not after it succeeds', () => {
|
||||
const src = readFileSync(join(DIR, 'backup.sh'), 'utf8')
|
||||
const attempted = src.indexOf('QUIESCE_ATTEMPTED=1')
|
||||
const hook = src.indexOf('bash -c "$BACKUP_QUIESCE_CMD"')
|
||||
expect(attempted).toBeGreaterThan(-1)
|
||||
expect(hook).toBeGreaterThan(attempted)
|
||||
})
|
||||
|
||||
it('restore.sh rejects a backup name with shell metacharacters before doing anything', () => {
|
||||
let status = 0
|
||||
let stderr = ''
|
||||
try {
|
||||
execFileSync('bash', [join(DIR, 'restore.sh'), 'nightly-2026; rm -rf /', '--yes'], BARE_ENV)
|
||||
} catch (err) {
|
||||
status = (err as { status?: number }).status ?? -1
|
||||
stderr = String((err as { stderr?: Buffer }).stderr ?? '')
|
||||
}
|
||||
expect(status).toBe(2)
|
||||
expect(stderr).toContain('invalid backup name')
|
||||
})
|
||||
|
||||
it('neither script runs a shell inside the docker helper container', () => {
|
||||
// `docker run ... sh -c "<string with ${NAME}>"` would let a crafted
|
||||
// backup name execute inside the container; tar must get the path as a
|
||||
// direct argument. (The operator-supplied quiesce/resume hooks in
|
||||
// backup.sh run through bash on the host by design; they are config.)
|
||||
const dockerShell = /docker run[^\n]*(\\\n[^\n]*)*?\bsh -c/
|
||||
expect(readFileSync(join(DIR, 'restore.sh'), 'utf8')).not.toMatch(dockerShell)
|
||||
expect(readFileSync(join(DIR, 'backup.sh'), 'utf8')).not.toMatch(dockerShell)
|
||||
})
|
||||
|
||||
it('restore.sh refuses to run without --yes', () => {
|
||||
let status = 0
|
||||
let stderr = ''
|
||||
try {
|
||||
execFileSync('bash', [join(DIR, 'restore.sh'), 'nightly-20260101T000000Z'], BARE_ENV)
|
||||
} catch (err) {
|
||||
status = (err as { status?: number }).status ?? -1
|
||||
stderr = String((err as { stderr?: Buffer }).stderr ?? '')
|
||||
}
|
||||
expect(status).toBe(2)
|
||||
expect(stderr).toContain('--yes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
-- ACL manifest of the application schema: one line per function, relation
|
||||
-- and sequence in `public`, stating what each PostgREST role (anon,
|
||||
-- authenticated, service_role) may do with it. backup.sh writes this next to the dump;
|
||||
-- restore.sh runs it again after pg_restore and diffs the two. Any difference
|
||||
-- means the restored database exposes an object differently than the source
|
||||
-- did, which is exactly the failure a restore drill must surface (the
|
||||
-- migrations lock SECURITY DEFINER RPCs away from anon/authenticated, and a
|
||||
-- restore that loses those REVOKEs is silent otherwise).
|
||||
--
|
||||
-- Deliberately owner-independent: --no-owner changes who owns the objects,
|
||||
-- never what these three roles may do. Run with psql -X -A -t -q. Ordered
|
||||
-- under the C collation so a source and a target with different database
|
||||
-- collations still produce byte-identical files.
|
||||
set search_path = public, pg_catalog;
|
||||
|
||||
select line from (
|
||||
select 'function ' || p.oid::regprocedure::text
|
||||
|| ' anon=' || has_function_privilege('anon', p.oid, 'EXECUTE')::int
|
||||
|| ' authenticated=' || has_function_privilege('authenticated', p.oid, 'EXECUTE')::int
|
||||
|| ' service_role=' || has_function_privilege('service_role', p.oid, 'EXECUTE')::int as line
|
||||
from pg_proc p
|
||||
where p.pronamespace = 'public'::regnamespace
|
||||
union all
|
||||
-- Four digits per role: SELECT INSERT UPDATE DELETE.
|
||||
select 'relation ' || c.oid::regclass::text
|
||||
|| ' anon='
|
||||
|| has_table_privilege('anon', c.oid, 'SELECT')::int
|
||||
|| has_table_privilege('anon', c.oid, 'INSERT')::int
|
||||
|| has_table_privilege('anon', c.oid, 'UPDATE')::int
|
||||
|| has_table_privilege('anon', c.oid, 'DELETE')::int
|
||||
|| ' authenticated='
|
||||
|| has_table_privilege('authenticated', c.oid, 'SELECT')::int
|
||||
|| has_table_privilege('authenticated', c.oid, 'INSERT')::int
|
||||
|| has_table_privilege('authenticated', c.oid, 'UPDATE')::int
|
||||
|| has_table_privilege('authenticated', c.oid, 'DELETE')::int
|
||||
|| ' service_role='
|
||||
|| has_table_privilege('service_role', c.oid, 'SELECT')::int
|
||||
|| has_table_privilege('service_role', c.oid, 'INSERT')::int
|
||||
|| has_table_privilege('service_role', c.oid, 'UPDATE')::int
|
||||
|| has_table_privilege('service_role', c.oid, 'DELETE')::int
|
||||
from pg_class c
|
||||
where c.relnamespace = 'public'::regnamespace
|
||||
and c.relkind in ('r', 'p', 'v', 'm')
|
||||
union all
|
||||
-- Sequences carry their own privilege set (has_table_privilege does not
|
||||
-- see them), and the Supabase defaults grant them to every PostgREST role:
|
||||
-- a restore that changes them (nextval on an id sequence for anon, say)
|
||||
-- must fail the diff like any other object. Three digits per role:
|
||||
-- USAGE SELECT UPDATE.
|
||||
select 'sequence ' || c.oid::regclass::text
|
||||
|| ' anon='
|
||||
|| has_sequence_privilege('anon', c.oid, 'USAGE')::int
|
||||
|| has_sequence_privilege('anon', c.oid, 'SELECT')::int
|
||||
|| has_sequence_privilege('anon', c.oid, 'UPDATE')::int
|
||||
|| ' authenticated='
|
||||
|| has_sequence_privilege('authenticated', c.oid, 'USAGE')::int
|
||||
|| has_sequence_privilege('authenticated', c.oid, 'SELECT')::int
|
||||
|| has_sequence_privilege('authenticated', c.oid, 'UPDATE')::int
|
||||
|| ' service_role='
|
||||
|| has_sequence_privilege('service_role', c.oid, 'USAGE')::int
|
||||
|| has_sequence_privilege('service_role', c.oid, 'SELECT')::int
|
||||
|| has_sequence_privilege('service_role', c.oid, 'UPDATE')::int
|
||||
from pg_class c
|
||||
where c.relnamespace = 'public'::regnamespace
|
||||
and c.relkind = 'S'
|
||||
) t
|
||||
order by line collate "C";
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env bash
|
||||
# Accounted self-host backup: logical database dump + document storage, shipped
|
||||
# to an S3-compatible bucket, optionally under S3 Object Lock (WORM).
|
||||
#
|
||||
# Self-hosted Supabase has no managed backups or PITR, and Swedish bookkeeping
|
||||
# law (BFL 7 kap) requires the ledger and its underlag (receipts, invoices) to
|
||||
# be kept for seven years after the end of the fiscal year. This script is the
|
||||
# minimum that satisfies both: one restorable dump per run, and the documents
|
||||
# bucket alongside it, on storage the operator controls. See
|
||||
# docs/SOVEREIGN.md ("Backup and restore") for the runbook and the cron line.
|
||||
#
|
||||
# Requirements on the host running it: bash, pg_dump and psql (matching the
|
||||
# server's major version), tar, gzip, sha256sum (or shasum), AWS CLI v2 (works
|
||||
# against any S3-compatible endpoint: Safespring, GleSYS, Elastx via
|
||||
# --endpoint-url).
|
||||
#
|
||||
# Environment (required unless marked optional):
|
||||
# BACKUP_DATABASE_URL postgresql://postgres:<password>@<host>:<port>/postgres
|
||||
# Use the Supabase session-mode pooler port or the
|
||||
# db container's port; never a public address.
|
||||
# BACKUP_S3_ENDPOINT e.g. https://s3.sto2.safedc.net
|
||||
# BACKUP_S3_BUCKET bucket name (create it with Object Lock enabled:
|
||||
# Object Lock can only be turned on at creation)
|
||||
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY credentials for that bucket
|
||||
# BACKUP_S3_REGION optional, default us-east-1 (what most S3-compatible
|
||||
# endpoints expect for SigV4; Elastx requires it)
|
||||
# BACKUP_S3_PREFIX optional, default "accounted"
|
||||
# BACKUP_STORAGE_DIR optional: path of the Supabase storage volume
|
||||
# (STORAGE_BACKEND=file: <supabase-dir>/volumes/storage).
|
||||
# Omit when storage-api writes straight to S3; then
|
||||
# back that bucket up bucket-to-bucket instead.
|
||||
# BACKUP_DB_CONFIG_VOLUME optional: name of the Supabase `db-config` Docker
|
||||
# volume (e.g. supabase_db-config). It holds the
|
||||
# pgsodium root key; a dump restored without it
|
||||
# cannot decrypt Vault secrets. Requires docker.
|
||||
# BACKUP_OBJECT_LOCK_DAYS optional: when set, every uploaded object gets
|
||||
# COMPLIANCE-mode retention for this many days
|
||||
# (immutable even for the bucket owner). Suggested:
|
||||
# a long value (>= 2600, seven years plus margin)
|
||||
# for the yearly post-bokslut run, a short one for
|
||||
# nightly runs, or unset and rely on bucket default
|
||||
# retention. COMPLIANCE retention cannot be shortened.
|
||||
# BACKUP_WORKDIR optional: scratch directory, default mktemp.
|
||||
# BACKUP_LABEL optional: name fragment, default "nightly".
|
||||
# BACKUP_QUIESCE_CMD optional: a command run BEFORE the dump and
|
||||
# BACKUP_RESUME_CMD AFTER the upload (also on failure), e.g.
|
||||
# "docker compose -f /opt/accounted/docker-compose.yml stop app cron"
|
||||
# and the matching "start". The database dump and
|
||||
# the storage tar are taken one after the other;
|
||||
# an upload landing between them leaves a document
|
||||
# row without its file (or the reverse) in that
|
||||
# set. Stopping the app for the window makes the
|
||||
# set consistent; recommended for the yearly
|
||||
# archive run, optional for nightly runs where the
|
||||
# next night's set covers the gap. Set both or
|
||||
# neither: one without the other is refused
|
||||
# before anything runs.
|
||||
#
|
||||
# Prints a short progress log on stdout; exit status is non-zero on any
|
||||
# failure, which is what a cron wrapper should alert on.
|
||||
set -euo pipefail
|
||||
# Dumps, archives and manifests are the whole ledger: never world-readable,
|
||||
# whatever the operator's default umask is.
|
||||
umask 077
|
||||
|
||||
: "${BACKUP_DATABASE_URL:?BACKUP_DATABASE_URL is required}"
|
||||
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT is required}"
|
||||
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET is required}"
|
||||
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}"
|
||||
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}"
|
||||
|
||||
# The hooks are a pair. A quiesce command without its resume command would
|
||||
# leave the operator's app stopped after every run (and a resume command
|
||||
# without a quiesce command would start containers nobody stopped), so refuse
|
||||
# before anything else happens.
|
||||
if [ -n "${BACKUP_QUIESCE_CMD:-}" ] && [ -z "${BACKUP_RESUME_CMD:-}" ]; then
|
||||
echo "backup: BACKUP_QUIESCE_CMD is set but BACKUP_RESUME_CMD is not; set both or neither" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -n "${BACKUP_RESUME_CMD:-}" ] && [ -z "${BACKUP_QUIESCE_CMD:-}" ]; then
|
||||
echo "backup: BACKUP_RESUME_CMD is set but BACKUP_QUIESCE_CMD is not; set both or neither" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
export AWS_DEFAULT_REGION="${BACKUP_S3_REGION:-us-east-1}"
|
||||
PREFIX="${BACKUP_S3_PREFIX:-accounted}"
|
||||
LABEL="${BACKUP_LABEL:-nightly}"
|
||||
# The label becomes S3 keys, file names and container arguments: keep it to
|
||||
# the same character set restore.sh accepts.
|
||||
if ! [[ "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "backup: invalid BACKUP_LABEL \"$LABEL\" (letters, digits, . _ - only)" >&2
|
||||
exit 2
|
||||
fi
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
NAME="${LABEL}-${STAMP}"
|
||||
|
||||
for bin in pg_dump psql tar gzip aws; do
|
||||
command -v "$bin" >/dev/null 2>&1 || { echo "backup: missing required tool: $bin" >&2; exit 2; }
|
||||
done
|
||||
if command -v sha256sum >/dev/null 2>&1; then SHA="sha256sum"; else SHA="shasum -a 256"; fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
WORK="${BACKUP_WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/accounted-backup.XXXXXX")}"
|
||||
mkdir -p "$WORK"
|
||||
chmod 700 "$WORK"
|
||||
# Flipped to 1 BEFORE the quiesce hook runs, not after it succeeds: a hook
|
||||
# that stops one container and then fails ends the script (set -e), and the
|
||||
# EXIT trap must still run the resume hook, otherwise a failed backup leaves
|
||||
# the app stopped.
|
||||
QUIESCE_ATTEMPTED=0
|
||||
cleanup() {
|
||||
if [ "$QUIESCE_ATTEMPTED" = 1 ] && [ -n "${BACKUP_RESUME_CMD:-}" ]; then
|
||||
bash -c "$BACKUP_RESUME_CMD" || echo "backup: BACKUP_RESUME_CMD failed; check that the app is running" >&2
|
||||
fi
|
||||
[ -z "${BACKUP_WORKDIR:-}" ] && rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
upload() {
|
||||
# upload <local-file> <key>
|
||||
local file="$1" key="$2"
|
||||
local args=(s3api put-object --endpoint-url "$BACKUP_S3_ENDPOINT" --bucket "$BACKUP_S3_BUCKET" --key "$key" --body "$file")
|
||||
if [ -n "${BACKUP_OBJECT_LOCK_DAYS:-}" ]; then
|
||||
local until
|
||||
# GNU date and BSD date differ; try GNU first.
|
||||
until="$(date -u -d "+${BACKUP_OBJECT_LOCK_DAYS} days" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v+"${BACKUP_OBJECT_LOCK_DAYS}"d +%Y-%m-%dT%H:%M:%SZ)"
|
||||
args+=(--object-lock-mode COMPLIANCE --object-lock-retain-until-date "$until")
|
||||
fi
|
||||
aws "${args[@]}" >/dev/null
|
||||
echo "backup: uploaded s3://${BACKUP_S3_BUCKET}/${key}"
|
||||
}
|
||||
|
||||
echo "backup: starting ${NAME}"
|
||||
|
||||
if [ -n "${BACKUP_QUIESCE_CMD:-}" ]; then
|
||||
QUIESCE_ATTEMPTED=1
|
||||
bash -c "$BACKUP_QUIESCE_CMD"
|
||||
echo "backup: application quiesced for a consistent set"
|
||||
fi
|
||||
|
||||
# 1. Database: custom-format dump (compressed, selective restore possible).
|
||||
# --no-owner so it restores into a fresh Supabase stack whose roles were
|
||||
# created by that stack, not by us. ACLs (GRANT/REVOKE) are kept (no
|
||||
# --no-privileges): the migrations lock SECURITY DEFINER RPCs away from
|
||||
# anon/authenticated, and the dump is the only record of that. Keeping them
|
||||
# is necessary but not sufficient: pg_dump writes ACLs as a diff against
|
||||
# PostgreSQL's built-in defaults, so a role-specific "REVOKE ... FROM anon"
|
||||
# is not in the dump at all, and a Supabase target's ALTER DEFAULT
|
||||
# PRIVILEGES would hand anon/authenticated EXECUTE back to every restored
|
||||
# function. restore.sh neutralizes those default privileges before
|
||||
# pg_restore and then diffs the ACL manifest taken in step 1b against the
|
||||
# restored database, so a re-exposure fails the restore instead of hiding.
|
||||
DB_FILE="${WORK}/${NAME}.db.dump"
|
||||
pg_dump --format=custom --no-owner --file "$DB_FILE" "$BACKUP_DATABASE_URL"
|
||||
echo "backup: database dump $(du -h "$DB_FILE" | cut -f1)"
|
||||
|
||||
# 1b. ACL manifest: what anon/authenticated/service_role may do with every
|
||||
# function, relation and sequence in public
|
||||
# (scripts/self-host/acl-manifest.sql).
|
||||
# restore.sh compares the restored database against this file.
|
||||
ACL_FILE="${WORK}/${NAME}.acl.txt"
|
||||
psql -X -A -t -q -v ON_ERROR_STOP=1 -f "${SCRIPT_DIR}/acl-manifest.sql" "$BACKUP_DATABASE_URL" > "$ACL_FILE"
|
||||
echo "backup: ACL manifest $(wc -l < "$ACL_FILE" | tr -d ' ') objects"
|
||||
|
||||
# 2. Documents (the BFL underlag) when storage-api uses the file backend.
|
||||
STORAGE_FILE=""
|
||||
if [ -n "${BACKUP_STORAGE_DIR:-}" ]; then
|
||||
if [ ! -d "$BACKUP_STORAGE_DIR" ]; then
|
||||
echo "backup: BACKUP_STORAGE_DIR does not exist: $BACKUP_STORAGE_DIR" >&2
|
||||
exit 2
|
||||
fi
|
||||
STORAGE_FILE="${WORK}/${NAME}.storage.tar.gz"
|
||||
tar -C "$BACKUP_STORAGE_DIR" -czf "$STORAGE_FILE" .
|
||||
echo "backup: storage archive $(du -h "$STORAGE_FILE" | cut -f1)"
|
||||
fi
|
||||
|
||||
# 3. Supabase db-config volume (pgsodium root key), optional but strongly
|
||||
# recommended: without it Vault-encrypted columns in the dump are
|
||||
# unreadable after a restore.
|
||||
DBCONFIG_FILE=""
|
||||
if [ -n "${BACKUP_DB_CONFIG_VOLUME:-}" ]; then
|
||||
command -v docker >/dev/null 2>&1 || { echo "backup: BACKUP_DB_CONFIG_VOLUME set but docker not found" >&2; exit 2; }
|
||||
DBCONFIG_FILE="${WORK}/${NAME}.db-config.tar.gz"
|
||||
docker run --rm -v "${BACKUP_DB_CONFIG_VOLUME}:/src:ro" -v "${WORK}:/out" alpine:3 \
|
||||
tar -C /src -czf "/out/$(basename "$DBCONFIG_FILE")" .
|
||||
echo "backup: db-config archive $(du -h "$DBCONFIG_FILE" | cut -f1)"
|
||||
fi
|
||||
|
||||
# 4. Checksums, then upload everything under one prefix.
|
||||
MANIFEST="${WORK}/${NAME}.sha256"
|
||||
( cd "$WORK" && $SHA "$(basename "$DB_FILE")" "$(basename "$ACL_FILE")" \
|
||||
${STORAGE_FILE:+"$(basename "$STORAGE_FILE")"} \
|
||||
${DBCONFIG_FILE:+"$(basename "$DBCONFIG_FILE")"} > "$MANIFEST" )
|
||||
|
||||
for f in "$DB_FILE" "$ACL_FILE" ${STORAGE_FILE:+"$STORAGE_FILE"} ${DBCONFIG_FILE:+"$DBCONFIG_FILE"} "$MANIFEST"; do
|
||||
upload "$f" "${PREFIX}/${NAME}/$(basename "$f")"
|
||||
done
|
||||
|
||||
echo "backup: done ${NAME}"
|
||||
Executable
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env bash
|
||||
# Accounted self-host restore: the counterpart of backup.sh.
|
||||
#
|
||||
# Restores one backup set (database dump, ACL manifest, optional storage
|
||||
# archive, optional db-config archive) from the S3-compatible bucket into a
|
||||
# target Postgres and storage directory. Destructive by design: it drops and
|
||||
# recreates the objects in the target database (--clean). Run it against a
|
||||
# FRESH Supabase stack, or one you are prepared to overwrite, and pass --yes.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/self-host/restore.sh <backup-name> --yes
|
||||
# e.g. scripts/self-host/restore.sh nightly-20260820T020000Z --yes
|
||||
#
|
||||
# Environment:
|
||||
# RESTORE_DATABASE_URL target postgresql://... (required unless
|
||||
# RESTORE_SKIP_DATABASE=1). Connect as the stack's
|
||||
# `postgres` role, the one the migrations ran as.
|
||||
# BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, AWS_ACCESS_KEY_ID,
|
||||
# AWS_SECRET_ACCESS_KEY, BACKUP_S3_REGION, BACKUP_S3_PREFIX as in backup.sh
|
||||
# RESTORE_STORAGE_DIR optional: where to unpack the storage archive
|
||||
# (the new stack's <supabase-dir>/volumes/storage).
|
||||
# RESTORE_DB_CONFIG_VOLUME optional: Docker volume name to unpack the
|
||||
# db-config archive into. The database container
|
||||
# must be STOPPED for this and started afterwards,
|
||||
# so do it as its own pass with
|
||||
# RESTORE_SKIP_DATABASE=1 (see the runbook).
|
||||
# RESTORE_SKIP_DATABASE optional: set to 1 to skip pg_restore and the ACL
|
||||
# check (the db-config pass above).
|
||||
# RESTORE_WORKDIR optional scratch dir.
|
||||
# RESTORE_TOLERATE_ERRORS optional: set to 1 to continue when pg_restore
|
||||
# reports errors (exit status 1). Default is to
|
||||
# STOP before the ACL check and storage and show
|
||||
# the error log. On a Supabase target these
|
||||
# classes are routine (the stack already owns and
|
||||
# grants those objects; the restoring role is not a
|
||||
# superuser): "already exists"; "does not exist"
|
||||
# from DROP ... IF EXISTS of policies/triggers on
|
||||
# a fresh target; "must be member of role
|
||||
# supabase_*", "must be owner of ...", "permission
|
||||
# denied ..." and "grant options cannot be granted
|
||||
# back" on GRANT/REVOKE/ALTER for objects in
|
||||
# auth, storage, realtime, extensions, cron, vault,
|
||||
# graphql*, pgbouncer. Errors on public.* objects
|
||||
# are NOT expected: investigate before continuing.
|
||||
# A partial restore must be a decision you take
|
||||
# knowingly, not a default.
|
||||
#
|
||||
# Order that works: (1) bring up a fresh Supabase stack with the SAME
|
||||
# JWT_SECRET / ANON_KEY / SERVICE_ROLE_KEY as the old one (or re-issue keys to
|
||||
# your Accounted .env), (2) stop the database container and restore db-config
|
||||
# (RESTORE_DB_CONFIG_VOLUME + RESTORE_SKIP_DATABASE=1), (3) start it and
|
||||
# restore the database and storage (second run), (4) restart storage-api,
|
||||
# (5) run `scripts/smoke-ai-provider.ts`-style checks and log in.
|
||||
set -euo pipefail
|
||||
# Downloaded dumps are the whole ledger: never world-readable.
|
||||
umask 077
|
||||
|
||||
NAME="${1:-}"
|
||||
CONFIRM="${2:-}"
|
||||
if [ -z "$NAME" ] || [ "$NAME" = "--help" ]; then
|
||||
sed -n '2,/^set -euo pipefail/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'
|
||||
exit 2
|
||||
fi
|
||||
if [ "$CONFIRM" != "--yes" ]; then
|
||||
echo "restore: refusing to run without --yes (this overwrites the target database)" >&2
|
||||
exit 2
|
||||
fi
|
||||
# The name becomes S3 keys, local file names and container arguments: only
|
||||
# the characters backup.sh can produce are accepted, so nothing shell- or
|
||||
# path-like ever reaches those places.
|
||||
if ! [[ "$NAME" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "restore: invalid backup name \"$NAME\" (expected e.g. nightly-20260820T020000Z)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SKIP_DB="${RESTORE_SKIP_DATABASE:-0}"
|
||||
if [ "$SKIP_DB" != "1" ]; then
|
||||
: "${RESTORE_DATABASE_URL:?RESTORE_DATABASE_URL is required (or set RESTORE_SKIP_DATABASE=1)}"
|
||||
fi
|
||||
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT is required}"
|
||||
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET is required}"
|
||||
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}"
|
||||
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}"
|
||||
|
||||
export AWS_DEFAULT_REGION="${BACKUP_S3_REGION:-us-east-1}"
|
||||
PREFIX="${BACKUP_S3_PREFIX:-accounted}"
|
||||
|
||||
for bin in pg_restore psql tar gzip aws; do
|
||||
command -v "$bin" >/dev/null 2>&1 || { echo "restore: missing required tool: $bin" >&2; exit 2; }
|
||||
done
|
||||
if command -v sha256sum >/dev/null 2>&1; then SHA="sha256sum"; else SHA="shasum -a 256"; fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
WORK="${RESTORE_WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/accounted-restore.XXXXXX")}"
|
||||
mkdir -p "$WORK"
|
||||
chmod 700 "$WORK"
|
||||
cleanup() { [ -z "${RESTORE_WORKDIR:-}" ] && rm -rf "$WORK"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
fetch() {
|
||||
# fetch <file-name> -> downloads into $WORK, returns 1 when the key is absent
|
||||
aws s3api get-object --endpoint-url "$BACKUP_S3_ENDPOINT" --bucket "$BACKUP_S3_BUCKET" \
|
||||
--key "${PREFIX}/${NAME}/$1" "${WORK}/$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo "restore: fetching ${NAME} from s3://${BACKUP_S3_BUCKET}/${PREFIX}/${NAME}/"
|
||||
fetch "${NAME}.sha256" || { echo "restore: no manifest found for ${NAME}" >&2; exit 1; }
|
||||
fetch "${NAME}.db.dump" || { echo "restore: database dump missing" >&2; exit 1; }
|
||||
HAVE_ACL=0; fetch "${NAME}.acl.txt" && HAVE_ACL=1
|
||||
HAVE_STORAGE=0; fetch "${NAME}.storage.tar.gz" && HAVE_STORAGE=1
|
||||
HAVE_DBCONFIG=0; fetch "${NAME}.db-config.tar.gz" && HAVE_DBCONFIG=1
|
||||
|
||||
# Verify every file the manifest lists before touching anything.
|
||||
( cd "$WORK" && $SHA -c "${NAME}.sha256" )
|
||||
echo "restore: checksums verified"
|
||||
|
||||
if [ "$HAVE_DBCONFIG" = 1 ] && [ -n "${RESTORE_DB_CONFIG_VOLUME:-}" ]; then
|
||||
command -v docker >/dev/null 2>&1 || { echo "restore: RESTORE_DB_CONFIG_VOLUME set but docker not found" >&2; exit 2; }
|
||||
# No shell inside the container: tar receives the path as an argument.
|
||||
docker run --rm -v "${RESTORE_DB_CONFIG_VOLUME}:/dst" -v "${WORK}:/in:ro" alpine:3 \
|
||||
tar -C /dst -xzf "/in/${NAME}.db-config.tar.gz"
|
||||
echo "restore: db-config volume restored (${RESTORE_DB_CONFIG_VOLUME}); start the database container before the database pass"
|
||||
fi
|
||||
|
||||
if [ "$SKIP_DB" = "1" ]; then
|
||||
echo "restore: RESTORE_SKIP_DATABASE=1, database not touched"
|
||||
else
|
||||
# Log only what follows the last "@" (host, port, database): never the
|
||||
# credentials part of the URL.
|
||||
echo "restore: restoring database into ${RESTORE_DATABASE_URL##*@} (objects are dropped and recreated)"
|
||||
|
||||
# The dump carries ACLs, but pg_dump writes them as a diff against
|
||||
# PostgreSQL's built-in defaults (acldefault), never against the target's
|
||||
# ALTER DEFAULT PRIVILEGES. A Supabase stack grants anon, authenticated and
|
||||
# service_role on every new function/table/sequence the postgres role
|
||||
# creates, so restoring into it would hand anon/authenticated EXECUTE back
|
||||
# to every hardened RPC: the dump only says "REVOKE FROM PUBLIC; GRANT TO
|
||||
# service_role" and never "REVOKE FROM anon". Put the restoring role's
|
||||
# default privileges back to exactly PostgreSQL's built-in defaults right
|
||||
# before pg_restore (revoke every added grantee; PUBLIC back to EXECUTE on
|
||||
# functions and USAGE on types, nothing on tables and sequences), so the
|
||||
# dump's explicit GRANT/REVOKE statements apply to the base they were
|
||||
# computed against. The dump's own last section (DEFAULT ACL) re-creates
|
||||
# the stack's default privileges once every object exists, so migrations
|
||||
# applied later still get the grants PostgREST needs; the check after
|
||||
# pg_restore confirms that.
|
||||
psql -X -q -v ON_ERROR_STOP=1 "$RESTORE_DATABASE_URL" <<'SQL'
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
scope text;
|
||||
kind text;
|
||||
BEGIN
|
||||
-- 1. Every grantee the stack added to the defaults (anon, authenticated,
|
||||
-- service_role, PUBLIC on tables, ...): revoke it. The role's own
|
||||
-- privileges are left alone. IN SCHEMA entries are additions on top of
|
||||
-- the built-in default and PostgreSQL drops them once they are empty.
|
||||
FOR r IN
|
||||
SELECT DISTINCT d.defaclobjtype AS objtype, d.defaclnamespace AS nsp, a.grantee
|
||||
FROM pg_default_acl d
|
||||
CROSS JOIN LATERAL aclexplode(d.defaclacl) a
|
||||
WHERE d.defaclrole = (SELECT oid FROM pg_roles WHERE rolname = current_user)
|
||||
AND d.defaclnamespace IN (0, 'public'::regnamespace)
|
||||
AND a.grantee <> d.defaclrole
|
||||
LOOP
|
||||
scope := CASE WHEN r.nsp = 0 THEN '' ELSE 'IN SCHEMA public' END;
|
||||
kind := CASE r.objtype
|
||||
WHEN 'r' THEN 'TABLES' WHEN 'S' THEN 'SEQUENCES' WHEN 'f' THEN 'FUNCTIONS'
|
||||
WHEN 'T' THEN 'TYPES' WHEN 'n' THEN 'SCHEMAS'
|
||||
END;
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES %s REVOKE ALL ON %s FROM %s', scope, kind,
|
||||
CASE WHEN r.grantee = 0 THEN 'PUBLIC' ELSE r.grantee::regrole::text END);
|
||||
END LOOP;
|
||||
-- 2. A global entry (no schema) REPLACES the built-in default, so one that
|
||||
-- took EXECUTE on functions or USAGE on types away from PUBLIC would
|
||||
-- still be in force. Grant those back: an entry equal to the built-in
|
||||
-- default is removed by PostgreSQL itself. (The Supabase image has no
|
||||
-- global entries for postgres; an operator-hardened stack may.)
|
||||
FOR r IN
|
||||
SELECT DISTINCT d.defaclobjtype AS objtype
|
||||
FROM pg_default_acl d
|
||||
WHERE d.defaclrole = (SELECT oid FROM pg_roles WHERE rolname = current_user)
|
||||
AND d.defaclnamespace = 0
|
||||
AND d.defaclobjtype IN ('f', 'T')
|
||||
LOOP
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES GRANT %s ON %s TO PUBLIC',
|
||||
CASE WHEN r.objtype = 'f' THEN 'EXECUTE' ELSE 'USAGE' END,
|
||||
CASE WHEN r.objtype = 'f' THEN 'FUNCTIONS' ELSE 'TYPES' END);
|
||||
END LOOP;
|
||||
END $$;
|
||||
SQL
|
||||
echo "restore: default privileges of the restoring role neutralized for the restore"
|
||||
|
||||
# --clean --if-exists: drop objects before recreating them. --no-owner: the
|
||||
# fresh stack owns its roles. No --no-privileges: the ACLs are the point.
|
||||
# pg_restore exits 1 when any restore operation failed; on a Supabase
|
||||
# target the classes listed in the header are routine (stack-owned objects
|
||||
# the non-superuser postgres role may not drop or re-grant), a missing
|
||||
# table or an error on a public.* object is not, and the script cannot
|
||||
# tell which. Default: stop here, show the log, restore nothing further.
|
||||
# The operator reads the log and re-runs with RESTORE_TOLERATE_ERRORS=1 if
|
||||
# the errors are the expected kind.
|
||||
PG_LOG="${WORK}/pg_restore.log"
|
||||
set +e
|
||||
pg_restore --clean --if-exists --no-owner --dbname "$RESTORE_DATABASE_URL" \
|
||||
"${WORK}/${NAME}.db.dump" 2> "$PG_LOG"
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "restore: pg_restore exited with status ${rc}; errors reported:" >&2
|
||||
grep -E "^pg_restore: (error|warning)" "$PG_LOG" | head -40 >&2 || tail -40 "$PG_LOG" >&2
|
||||
if [ "$rc" -ne 1 ] || [ "${RESTORE_TOLERATE_ERRORS:-0}" != "1" ]; then
|
||||
echo "restore: stopping before the ACL check and storage (the db-config volume, if requested, is already unpacked). Inspect the errors above; if they are the expected kind on a Supabase target (\"already exists\", \"does not exist\" from DROP IF EXISTS, and permission/ownership/grant errors on objects in auth, storage, realtime, extensions, cron, vault, graphql*, pgbouncer, never on public.*), re-run with RESTORE_TOLERATE_ERRORS=1." >&2
|
||||
exit "$rc"
|
||||
fi
|
||||
echo "restore: continuing despite pg_restore errors (RESTORE_TOLERATE_ERRORS=1)" >&2
|
||||
fi
|
||||
echo "restore: database restored (pg_restore status ${rc})"
|
||||
|
||||
# ACL check: the manifest backup.sh took from the source must match the
|
||||
# restored database line for line. A difference means an object is now
|
||||
# reachable by a PostgREST role that could not reach it before (or the
|
||||
# reverse), and there is no override: fix the ACLs by hand from the diff
|
||||
# (GRANT/REVOKE on the named objects) or re-run into a fresh stack.
|
||||
if [ "$HAVE_ACL" = 1 ]; then
|
||||
ACL_NOW="${WORK}/${NAME}.acl.restored.txt"
|
||||
psql -X -A -t -q -v ON_ERROR_STOP=1 -f "${SCRIPT_DIR}/acl-manifest.sql" "$RESTORE_DATABASE_URL" > "$ACL_NOW"
|
||||
if ! diff -u "${WORK}/${NAME}.acl.txt" "$ACL_NOW" > "${WORK}/acl.diff"; then
|
||||
echo "restore: ACL MISMATCH between the source manifest (-) and the restored database (+):" >&2
|
||||
grep -E "^[-+](function|relation|sequence) " "${WORK}/acl.diff" | head -60 >&2
|
||||
echo "restore: stopping before storage. The restored database does not grant the PostgREST roles what the source did; see the lines above." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "restore: ACL manifest verified ($(wc -l < "$ACL_NOW" | tr -d ' ') objects, identical to the source)"
|
||||
else
|
||||
echo "restore: no ACL manifest in this set (older backup.sh); ACL verification skipped" >&2
|
||||
fi
|
||||
|
||||
# The dump's DEFAULT ACL section should have re-created the stack's default
|
||||
# privileges for the restoring role. If it did not (restored as a role the
|
||||
# source never set defaults for), later migrations would create objects the
|
||||
# PostgREST roles cannot reach: say so.
|
||||
DEFACL_ROWS="$(psql -X -A -t -q -v ON_ERROR_STOP=1 "$RESTORE_DATABASE_URL" \
|
||||
-c "select count(*) from pg_default_acl where defaclrole = (select oid from pg_roles where rolname = current_user) and defaclnamespace = 'public'::regnamespace")"
|
||||
if [ "${DEFACL_ROWS:-0}" = "0" ]; then
|
||||
echo "restore: WARNING: no default privileges for the restoring role in schema public after the restore; re-apply the stack's ALTER DEFAULT PRIVILEGES (tables, functions, sequences to anon, authenticated, service_role) before running further migrations" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$HAVE_STORAGE" = 1 ] && [ -n "${RESTORE_STORAGE_DIR:-}" ]; then
|
||||
mkdir -p "$RESTORE_STORAGE_DIR"
|
||||
tar -C "$RESTORE_STORAGE_DIR" -xzf "${WORK}/${NAME}.storage.tar.gz"
|
||||
echo "restore: storage unpacked into ${RESTORE_STORAGE_DIR} (restart storage-api)"
|
||||
elif [ "$HAVE_STORAGE" = 1 ]; then
|
||||
echo "restore: storage archive present but RESTORE_STORAGE_DIR unset; skipped"
|
||||
fi
|
||||
|
||||
echo "restore: done ${NAME}"
|
||||
Reference in New Issue
Block a user