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).
SmtpEmailService (nodemailer 9.0.5, exact-pinned) behind the existing EmailService seam. Provider resolution: EMAIL_PROVIDER wins, else RESEND_API_KEY selects Resend (hosted byte-identical), else SMTP_HOST selects SMTP. From header is built exactly like the Resend service after #1956 (no 'via <app>', fromAddress honored, platform-sender retry). STARTTLS is required by default (requireTLS) with SMTP_REQUIRE_TLS=false as an explicit opt-out for a plaintext LAN relay. Docs, env examples and the generated extension registry updated.
* feat(auth): make automatic logout an opt-in per-user setting
Session timeouts (30 min idle / 12 h absolute on hosted) now apply only
to users who enable "Automatic logout" in Settings > Security. Default
is off: sessions live for the full Supabase refresh-token lifetime, the
behavior from before the 2026-07 session hardening.
- user_preferences.auto_logout (migration, default false), toggled via
the extended /api/user/preferences route
- The opt-in is snapshotted into the signed timeout cookie at mint, so
enforcement stays DB-read-free per request; the preferences route
clears the cookie on change so a toggle takes effect immediately
- Pre-toggle cookies are authentic-but-stale: re-minted preserving
their timers, never routed down the tamper path, so the rollout does
not log anyone out
- NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true enforces timeouts for
every user regardless of preference (emergency lever, also plumbed
through the Docker image); self-hosted stays disabled by default
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): resolve PR #1536 review findings
- Replace the spread upsert in /api/user/preferences with one literal
payload per field: the phantom-column schema guard cannot resolve
spread payloads (Unit tests 3/4 ceiling failure)
- Map the preferences 500 through getErrorMessage so the user-facing
text is Swedish (CodeRabbit)
- fetchAutoLogoutPreference now returns null on a FAILED read instead
of a fail-open false: callers skip minting so an unknown preference
is never persisted into the year-long signed cookie, and the next
request retries; failures log at error level, distinct from the
normal opt-out path (compliance swarm GDPR Art.32(1)(b) / ISO A.8.5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): write multi-field preference updates as one atomic upsert
A request carrying both hide_assistant_fab and auto_logout previously
issued two sequential writes, so a failure of the second returned 500
after half the request had persisted (CodeRabbit, PR #1536). One
literal upsert per accepted field combination keeps the write atomic
and stays resolvable for the phantom-column schema guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): provision invitees server-side when signups are disabled
Self-hosted installations with GoTrue disable_signup broke the invite
flow silently: invitees without an account were routed to /register,
where supabase.auth.signUp fails with "Signups not allowed for this
instance", surfaced only as a generic toast.
New server-only env flag AUTH_SIGNUPS_DISABLED (documented in
.env.example) mirrors the GoTrue setting. When true, POST
/api/company/members/invite checks check_email_exists and, for invitees
without an account, provisions one via auth.admin.inviteUserByEmail
with a redirect back to /invite/<token>, before the Resend email and
before the invitation row is written so a provisioning failure leaves
nothing half-created and the admin can retry. The response now carries
user_provisioned alongside email_sent, and a provisioning failure
returns 502 with a Swedish message mapped through getErrorMessage
instead of a silently-successful invite.
/auth/callback now routes type=invite verifications to /reset-password
(the existing set-password surface) instead of dropping the
passwordless user on the dashboard, and preserves the invite token from
next=/invite/<token> as the pre-auth invite cookie so the existing
reset-password invite handoff accepts the membership right after the
password is saved.
getErrorMessage learns two GoTrue patterns: "Signups not allowed"
(account creation closed on this installation, contact your inviter or
administrator) so the /register dead end is explained even for flows
that bypass provisioning, and "Error sending ... email" (GoTrue SMTP
not configured) so the 502 above is actionable.
Hosted is untouched: the flag is unset there and every new code path is
gated on it.
Fixes#1335
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): restore check_email_exists RPC and harden self-host invite config
Adversarial review of #1404 found that the check_email_exists function the
invite flow depends on does not exist anywhere: it shipped in PR #229 and
was lost in the #244 migration consolidation before ever reaching prod
(verified missing on the hosted production database directly). Today
app/api/team/accept destructures only { data } from the RPC call, so
alreadyHasAccount is silently null on every deployment and the invite page
routes even existing-account invitees toward /register.
- New migration 20260804140000 restores the function exactly as originally
shipped: SECURITY DEFINER over auth.users, EXECUTE revoked from PUBLIC,
anon and authenticated, granted to service_role only (prevents email
enumeration). Fixes hosted prod behavior too once applied.
- New tests/pg/check-email-exists.pg.test.ts locks in existence,
case-insensitive matching, false-for-unknown, and the role grants.
- .env.docker.example gains the AUTH_SIGNUPS_DISABLED block self-hosters
actually use; both env templates now note that the GoTrue redirect URI
allow-list must include /invite/* or the invite email redirect silently
falls back to SITE_URL.
- Invite route test for the existsError branch: RPC failure logs a warning
and provisioning proceeds anyway (GoTrue is authoritative).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): mask invitee email in provisioning-failure log (#1335)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(auth): enforce session idle and absolute timeouts
Hosted browser sessions now carry an HMAC-signed, HttpOnly cookie holding
session start, last activity and sign-in method, bound to the Supabase
session. Middleware enforces a 30 min idle and 12 h absolute limit
(reason-coded redirects to /login), a heartbeat route advances idle
activity from real user input, and a client controller warns 2 minutes
before expiry. BankID users are routed back to BankID on re-auth via a
short-lived method hint. API-key and MCP bearer surfaces are exempt;
self-hosted installs default off and can opt in via env vars.
Fixes#362
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): derive session-timeout signing key via HKDF
The HMAC key is now HKDF-derived with a purpose-bound info string, so
the SUPABASE_SERVICE_ROLE_KEY fallback never uses the privileged
credential directly as a signing key. Addresses the security review
finding on PR #1387.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): back signature bytes with a plain ArrayBuffer
crypto.subtle.verify requires a BufferSource; Uint8Array.from is typed
over ArrayBufferLike, which the Vercel TypeScript build rejects. Decode
base64url into a Uint8Array constructed over a fresh ArrayBuffer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): address session-timeout review findings
- signSessionTimeoutState returns null on signing failure instead of
throwing, so a missing secret degrades the timeout feature in line
with verifySessionTimeoutState rather than crashing authenticated
requests; middleware and heartbeat skip the cookie write when null
- heartbeat initializes a fresh signed state for a missing or
session-mismatched cookie, mirroring middleware, instead of
returning SESSION_EXPIRED during normal initialization
- sessionStateMatchesUser treats an unresolved current session id as
a mismatch for session-bound state so another session's cookie is
never accepted on the userId fallback alone
- drop aria-live from the countdown DialogDescription so screen
readers are not interrupted every second
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
connect-src listed the https Supabase origin plus wss://*.supabase.co,
but never the wss variant of a self-hosted Supabase URL. Supabase
Realtime opens wss://<host>/realtime/v1/websocket, which CSP blocked;
WebKit throws synchronously on a CSP-blocked new WebSocket(), so Safari
unmounted the dashboard into the error boundary (Chromium only logs).
- next.config.ts: add supabaseWsUrl (NEXT_PUBLIC_SUPABASE_WS_URL, or
the Supabase URL with https to wss / http to ws) to connect-src
- Dockerfile: bake a __NEXT_PUBLIC_SUPABASE_WS_URL__ sentinel, since
the CSP is fixed at build time and only sed-substituted at runtime
- docker-entrypoint.sh: derive the wss origin from
NEXT_PUBLIC_SUPABASE_URL unless overridden, substitute the sentinel
- .env.docker.example: document the optional override
Hosted is unaffected: the wss form of *.supabase.co was already
allowlisted, so the added token is redundant there.
Fixes#893
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Add .env.docker.example as a template for Docker-based local
development, and add a negation pattern to .gitignore so the
example file is tracked while real .env* files stay ignored.
Signed-off-by: Fabian Mossberg <fabian@hejfaktura.se>
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>