diff --git a/.env.docker.example b/.env.docker.example
index e441a239..d9977072 100644
--- a/.env.docker.example
+++ b/.env.docker.example
@@ -18,6 +18,11 @@ NEXT_PUBLIC_SELF_HOSTED=true
# Optional dedicated HMAC secret; otherwise SUPABASE_SERVICE_ROLE_KEY is used.
# SESSION_TIMEOUT_SECRET=
+# Optional Cloudflare Turnstile site key for Supabase Auth bot protection.
+# The matching secret is configured in GoTrue/Supabase Auth, never here.
+# Enable provider-side enforcement only after this public key is deployed.
+# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
+
# Set to true when public signup is turned off in your GoTrue/Supabase auth
# config (GOTRUE_DISABLE_SIGNUP / "Allow new users to sign up" off). GoTrue
# offers no clean server-side read of that setting, so this flag mirrors it.
diff --git a/.env.example b/.env.example
index 437df904..fdcfd870 100644
--- a/.env.example
+++ b/.env.example
@@ -67,6 +67,13 @@ RECEIPT_HUNT_COMPANY_IDS=
# The button stays hidden until this is true.
# NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=true
+# Cloudflare Turnstile site key for Supabase Auth bot protection. This value is
+# public and is embedded in the browser bundle. Leave it unset until a widget
+# has been created for the deployment's exact hostnames. Deploy the site key
+# before enabling Turnstile with the matching SECRET in Supabase Auth, so the
+# existing login flow remains available throughout rollout.
+# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
+
# ── Optional: extension features (core runs without these) ─
# AI features (document extraction + AI assistant). Three ways to provide a
# backend; set one of them. AI_PROVIDER (bedrock|anthropic|openai-compatible)
diff --git a/DECISIONS.md b/DECISIONS.md
index d44e312d..f75642c1 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1235,5 +1235,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-08-25] Proposal line-pattern settlement leg now takes the counterparty template's learned legacy pair (credit for expense, debit for income, mirror-swapped, || 1930), passed raw from QuickReviewDialog: two skeptics refuted the 1930 default (engine books e.g. 2440 from SIE-learned patterns; preview/prefill showed 1930). Declined CodeRabbit's two suggestions on #1894 deliberately: the 3740 rounding line keeps the engine's business-side placement for BOTH diff signs (parity contract; the engine's negative-diff imbalance cannot reach the ledger, commit_journal_entry rejects it; engine-side sign fix is a separate issue) and the naiveOreRound baseline stays raised to 622 (engineRound is a documented parity exception, not drift).
[2026-08-25] The production-only _backfill_remaining_20260817 invoice repair snapshot is privilege-contained, not deleted or relocated: PR #1655 identifies it as the safety snapshot for the 337-row 2026-08-17 remaining_amount repair, but the repository establishes neither its retention classification nor approval to destroy financial evidence, so the migration enables RLS, revokes PUBLIC/anon/authenticated access, and limits service_role to read-only for an authorized follow-up review while postgres retains owner control. Neither PR #1655 nor repository history establishes the original repair's behandlingshistorik/rattelse traceability or whether any repaired invoice was linked to a posted voucher; verifying the repair's who/when/what trail and its relation to booked entries is an explicit compliance follow-up, not inferred or altered by this access fix. Default privileges stay unchanged in this scoped fix because Supabase's platform transition and the application's many existing implicit grants require a separate compatibility audit.
[2026-08-25] Risk ID RISK-2026-08-25-INVOICE-BACKFILL-SNAPSHOT treatment record (PR #1901): Risk Owner and follow-up owner Emil; classification restricted financial remediation evidence pending BFL review; treatment preserves all 337 rows and merges only the anonymous-access containment; BFL retention/rattelse review deadline 2026-09-25; residual risk after containment Low, explicitly including postgres-owner bypass until that review. Retention or deletion requires the separate reviewed follow-up, and this PR must not delete or alter snapshot rows. This entry and PR #1901 are the repository-native Risk Treatment Plan reference because the repository has no risk register.
+[2026-08-25] Supabase Auth bot protection uses dependency-free Cloudflare Turnstile with a two-step rollout: a missing public site key keeps existing Auth flows available, while a configured client fails closed until it has a token; provider enforcement is enabled only after the client deploy is verified. The generic Docker image always permits Cloudflare's script and frame origins because its optional site key is substituted at runtime, after the CSP has been built.
[2026-08-25] Plugin distribution goes through the Claude plugin directory (public GitHub link, claude plugin validate, submit from claude.ai admin-settings or Console), not an organisation marketplace: org marketplaces accept private/internal repos only and require the Claude GitHub App, so a public monorepo can never pass that dialog (the 'Repository not accessible' error is misleading). Install-time guidance is a /accounted:setup slash command, the convention Anthropic's own plugins use (commands/*-setup.md); no SETUP.md mechanism exists in the plugin spec.
[2026-08-25] The marketplace entry for the Accounted plugin is a git-subdir source (public repo URL + path claude-plugin), not the relative path ./claude-plugin: relative sources only resolve when the whole marketplace repo is cloned (Claude Code), while Claude.ai's Add-marketplace backend fetches the manifest and resolves each plugin source as a repository, which surfaced as 'Repository not accessible' on a public repo. git-subdir is also the form the plugin-directory catalog uses for monorepos.
diff --git a/Dockerfile b/Dockerfile
index da382d0b..bc9e4c63 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -42,6 +42,9 @@ ENV NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=__NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS__
ENV NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=__NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS__
ENV NEXT_PUBLIC_SESSION_WARNING_MS=__NEXT_PUBLIC_SESSION_WARNING_MS__
ENV NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=__NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL__
+# Optional Supabase Auth bot protection. The site key is public; the matching
+# secret belongs in GoTrue/Supabase Auth and must never be baked into this image.
+ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=__NEXT_PUBLIC_TURNSTILE_SITE_KEY__
# Keep the branding placeholder intact through prebuild's inject script so
# docker-entrypoint.sh can substitute the runtime value into public/sw.js.
ENV NEXT_PUBLIC_BRANDING_APP_NAME=__NEXT_PUBLIC_BRANDING_APP_NAME__
diff --git a/app/(auth)/login/login-client.tsx b/app/(auth)/login/login-client.tsx
index ccac4cf3..4fd7c858 100644
--- a/app/(auth)/login/login-client.tsx
+++ b/app/(auth)/login/login-client.tsx
@@ -35,7 +35,15 @@ import {
import { buildPasswordResetRedirectTo } from '@/lib/domains/trusted-app-origin'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
+import {
+ TurnstileChallenge,
+ type TurnstileChallengeHandle,
+} from '@/components/auth/TurnstileChallenge'
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
+import {
+ captchaTokenOptions,
+ isTurnstileSubmissionBlocked,
+} from '@/lib/auth/turnstile'
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import { persistLoginMethodHint, type LoginMethod } from '@/lib/auth/login-method'
@@ -77,8 +85,12 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
// Consecutive credential failures; from the second one on, the error line
// grows a reset-password action (extra help on repeated errors).
const [failedAttempts, setFailedAttempts] = useState(0)
+ const [passwordCaptchaToken, setPasswordCaptchaToken] = useState(null)
+ const [resetCaptchaToken, setResetCaptchaToken] = useState(null)
const passwordInputRef = useRef(null)
const emailInputRef = useRef(null)
+ const passwordTurnstileRef = useRef(null)
+ const resetTurnstileRef = useRef(null)
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
@@ -145,11 +157,13 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const openResetForm = () => {
setFormError(null)
+ setResetCaptchaToken(null)
setShowResetPassword(true)
}
const closeResetForm = () => {
setFormError(null)
+ setResetCaptchaToken(null)
setShowResetPassword(false)
}
@@ -248,6 +262,12 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault()
setFormError(null)
+
+ if (isTurnstileSubmissionBlocked(passwordCaptchaToken)) {
+ setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
+ return
+ }
+
setIsLoading(true)
const formData = new FormData(e.currentTarget)
@@ -258,6 +278,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const { error } = await supabase.auth.signInWithPassword({
email: emailValue,
password: passwordValue,
+ options: captchaTokenOptions(passwordCaptchaToken),
})
if (error) {
@@ -317,6 +338,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
+ passwordTurnstileRef.current?.reset()
setIsLoading(false)
}
}
@@ -324,6 +346,12 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault()
setFormError(null)
+
+ if (isTurnstileSubmissionBlocked(resetCaptchaToken)) {
+ setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
+ return
+ }
+
setIsLoading(true)
const formData = new FormData(e.currentTarget)
@@ -332,6 +360,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
try {
const { error } = await supabase.auth.resetPasswordForEmail(emailValue, {
redirectTo: buildPasswordResetRedirectTo(window.location.origin),
+ ...captchaTokenOptions(resetCaptchaToken),
})
if (error) {
@@ -357,6 +386,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
+ resetTurnstileRef.current?.reset()
setIsLoading(false)
}
}
@@ -465,7 +495,20 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
className="h-11"
/>
-