Commit Graph

368 Commits

Author SHA1 Message Date
bef8657d81 chore(tooling): migrate pre-commit framework to husky
Replaces the Python pre-commit framework (.pre-commit-config.yaml) with
husky 9, kept faithful to the existing checks:

- Block direct commits to main (was: no-commit-to-branch).
- git diff --cached --check covers trailing-whitespace and merge-conflict
  marker detection.
- Custom large-file check (>500KB), excluding crawler test fixtures.
- Backend Go: gofmt -l (fail on diff), go vet, golangci-lint — only when
  backend/*.go is staged.
- Backend deps: go mod tidy -diff — only when go.mod/go.sum is staged.
- Web: prettier --check, eslint, svelte-check — only when web/ is
  staged.

lint-staged was intentionally not adopted — the previous config ran
hooks tree-wide (pass_filenames: false), so per-file optimisation would
be a behaviour change.

Install: pnpm install at repo root (the prepare script wires husky into
.git/hooks via core.hooksPath=.husky/_).
2026-04-30 22:18:14 +02:00
dee4cee23c fix(auth): invalidate valkey cache on session revoke
RevokeSession, RevokeSessionsByFamilyID, DeleteUserSessions,
RevokeOtherSessions, and ConsumeRefreshToken updated revoked_at in
Postgres but did not invalidate the valkey access-token cache. The cache
serves the original Session JSON (RevokedAt: null) until its TTL expires
(JWT_ACCESS_TTL = 2h), so logout / admin-revoke / refresh-reuse-detection
took up to 2h to actually invalidate.

Fix: each revocation path now uses RETURNING access_token_hash and DELs
the cache key via new helper invalidateCachedSessions. revokeBulk handles
multi-row revocations.

Adds three router-level negative tests for the admin auth chain
(RequireAuth + RequireRole("admin")):
- TestAdminChain_UserRole_Returns403 — user role rejected with 403
- TestAdminChain_AdminRole_Passes — admin role accepted
- TestAdminChain_NoBearerToken_Returns401 — missing token rejected with
  401 (auth runs before role check)

Repository-level regression test for the cache invalidation requires
real Valkey + Postgres, currently not in test harness — flagged as TODO
in planning/18-security-threat-model.md.

Audit findings H1, E (negative tests for session validation, authz).
2026-04-30 22:12:09 +02:00
b7c88dd86a docs(planning): add security threat model and abuse-case audit
First security audit of the marktvogt backend. Covers custom auth,
sessions, OAuth, TOTP+backup codes, magic links, admin endpoints,
LLM pipeline, and AI-cost endpoints.

Findings:
- H1: revoked sessions stay valid in valkey cache (FIXED in this branch)
- H2: prompt-injection via aggregator scrapes (MITIGATED via promptguard)
- H3: no threat-model artefact existed (RESOLVED by this doc)
- M1: VPA lost in monolithic-chart migration (FIXED in this branch)
- M2: fmt.Printf used for valkey-cache failure (FIXED in this branch)
- M3: AI per-day cost cap is logged-only, not enforcing (OPEN)
- M4: replicaCount=1 + PDB disabled for backend+web (OPEN)
- M5: no body-tampering tests on admin discovery endpoints (OPEN)
- L1: panic in startup paths (acceptable, documented)
- I1: Stripe pre-implementation guards (verify-first, idempotency)

Audit finding H3.
2026-04-30 22:11:54 +02:00
6181adbba4 feat(helm): port VerticalPodAutoscaler resources into monolithic chart
VPA was added to per-service charts (backend/deploy/helm, web/deploy/helm)
on 2026-04-20 but lost when those charts were deleted in the 2026-04-28
monolithic chart migration. The orphan branch gitlab/feat/helm-vpa-off-mode
never made it into helm/marktvogt/.

Restores VPA gated under <svc>.vpa.enabled (default false), updateMode
"Off" so the recommender observes without eviction. Activate via:

  helm upgrade --reuse-values --set backend.vpa.enabled=true \
                                    --set web.vpa.enabled=true

After ~1 week of recommender data, decide: tune resources.requests
manually, or flip updateMode to "Auto" (requires PDB + replicaCount>=2).
When flipping to Auto with HPA on CPU, drop "cpu" from
controlledResources to avoid the HPA+VPA-on-same-metric anti-pattern.

Audit finding M1.
2026-04-30 22:11:43 +02:00
c2bcdf0881 feat(promptguard): redact prompt-injection patterns in LLM input
New pkg/promptguard.Sanitize strips known structural injection patterns
(role labels, override directives, chat-template tokens, llama tokens,
prompt-exfil) from third-party scraped content before it reaches Gemini.

Wired into both LLM call sites:
- discovery/enrich.ProviderLLMEnricher.EnrichMissing (per-source quellen)
- market/research.buildUserPrompt (quellePage title + text)

Defense-in-depth on top of existing structural framing (JSON envelope in
research, JSON-Schema constrained decoding in enrich_b).

Audit finding H2.
2026-04-30 22:11:20 +02:00
c1430e66b0 fix(auth): use structured logger for valkey-cache failure
Replace fmt.Printf to stderr with slog.Warn so cache-degradation events
are captured in Loki and queryable.

Audit finding M2.
2026-04-30 22:11:02 +02:00
af7b6232e0 docs(readme): correct cache backend naming and bump status date
Architecture table said Redis, but local dev uses Valkey and prod uses
Dragonfly. Both speak the Redis protocol but neither is Redis.
2026-04-29 00:44:53 +02:00
5ad8126b81 fix(web): use proper umlauts in remaining server-side error messages
All checks were successful
ci/someci/push/web Pipeline was successful
Sweep of server action error strings — eight ASCII fallbacks replaced with
ä/ö/ü/ß across three +page.server.ts files: Pruefung -> Prüfung,
bestaetige -> bestätige, waehle -> wähle, fuelle -> fülle, Loeschen ->
Löschen, Statusaenderung -> Statusänderung, Ungueltiger -> Ungültiger.

Discovery agent_status enum literals ('bestaetigt', 'unklar', etc.) are
intentionally left as ASCII — they must match the LLM schema constants on
the backend.
2026-04-28 23:07:15 +02:00
8d8d96c231 fix(web): use proper umlauts in feedback dialog text
All checks were successful
ci/someci/push/web Pipeline was successful
Replace ASCII fallbacks (vollstaendig, uebermittelt, geprueft, Schliessen,
fuer, Rueckfragen, Datenschutzerklaerung) with proper German characters.
The ASCII-only convention applies to planning docs, not user-facing UI.
2026-04-28 22:43:10 +02:00
709fb6663a fix(web): center feedback dialog (Tailwind 4 preflight resets dialog margin)
All checks were successful
ci/someci/push/web Pipeline was successful
Native <dialog>.showModal() relies on the user-agent's margin: auto to
center the modal, but Tailwind 4's preflight resets margin to 0 on every
element, leaving the dialog pinned to the top-left edge of the viewport.

Add m-auto to the dialog class to restore the intended centering. Only one
dialog in the app, so a scoped class fix is sufficient — no global override
needed.
2026-04-28 22:35:44 +02:00
a89bed4a3e fix(research): expand multi-day opening hours; reject aggregator websites
All checks were successful
ci/someci/push/backend Pipeline was successful
Two follow-ups to the apply-shape fix:

opening_hours: previous converter only kept datum_von's weekday, dropping
the rest of multi-day ranges. A Sat-Sun event ended up with only Samstag
saved. Now iterates [datum_von, datum_bis] inclusive, projects each date
to its German weekday, and dedupes by (weekday, open, close). Different
hours on the same weekday still produce separate rows so the admin sees
the conflict. Capped at 7 distinct weekdays — the form's expressive limit.

website validator: the LLM was emitting aggregator/listing URLs (e.g.
suendenfrei.tv detail pages) as the official event website because that's
where it found grounding info. The prompt already forbade this, but the
model ignores soft rules. Add a hard validator-level rejection for known
aggregator domains: suendenfrei.tv, mittelalterkalender.info,
marktkalendarium.de, festival-alarm.com, mittelaltermarkt.online. These
suggestions now land in the "rejected" bucket with a clear reason.
image_url / logo_url are unaffected (those legitimately come from any host).

Prompt: enumerate the same aggregator domains explicitly under both the
"primary source" fallback list and the "website forbidden" rule, so the
model has a concrete blacklist instead of a category description.

Existing markets with already-saved aggregator websites need manual
clearing — the validator only kicks in on new applies.

Tests: 4 new opening-hours subtests (range expansion, weekday dedupe,
cross-entry dedupe, hours-conflict-kept-separate) and 4 validator subtests
(three aggregator domains rejected as website; aggregator host as image_url
still ok).
2026-04-28 21:22:43 +02:00
e50b1526f0 fix(research): restore LLM->form shape conversion in server-side apply
All checks were successful
ci/someci/push/backend Pipeline was successful
When apply moved server-side (9b30863), the client-side conversion from
dd9a5ae was lost. opening_hours and admission_info were stored raw in the
LLM's German shape ([{datum_von,...}], [{betrag,name,waehrung}]), which the
form's reactive bindings could not parse — fields appeared empty after
Uebernehmen + reload.

applyFieldMerge now routes both fields through dedicated converters:

- admission_info: smart name-prefix mapping (Erwachsen*->adult_cents,
  Kind*/Child*->child_cents, Ermaessigt*/Schueler*/Senior*/Rentner*/Reduced
  ->reduced_cents). Unmapped ticket names append to notes so admins still
  see the extracted info. Already-form-shape input (map with adult_cents)
  passes through unchanged.

- opening_hours: each LLM entry's datum_von is parsed as ISO date and
  converted to a German weekday name. Entries with unparseable datum_von
  are dropped (better than writing rows the form's day select cannot bind).
  Already-form-shape input ([{day,...}]) passes through unchanged.

Forward-only: markets where a previously broken apply already wrote
LLM-shape JSON need a re-apply (or manual edit) to render correctly.

Tests: 8 new TestApplyFieldMerge_* subtests covering smart mapping, notes
fallback, weekday derivation, malformed dates, and pass-through.
2026-04-28 20:59:35 +02:00
6a145088bd docs: add top-level README 2026-04-28 17:40:01 +02:00
bafe0c3680 chore(helm): pin values.yaml image tags to currently-deployed SHAs
Pre-flight for ArgoCD enable: ensure chart renders match cluster state so
the first sync is a no-op rather than rolling deployments back to :latest.
CI continues to bump via --set-string on each push.
2026-04-28 17:17:00 +02:00
208f76f9cc docs(claude): refresh post-migration — somegit, Woodpecker, helm/marktvogt, Bun 2026-04-28 17:11:35 +02:00
d293dd9182 fix(ci): use --set-string for image tags (avoids float coercion of all-digit SHAs)
All checks were successful
ci/someci/push/backend Pipeline was successful
ci/someci/push/web Pipeline was successful
2026-04-28 17:04:54 +02:00
1539879098 fix(ci): retry helm upgrade on race + revert workflow depends_on
Some checks failed
ci/someci/push/backend Pipeline was successful
ci/someci/push/web Pipeline failed
depends_on: [backend] in web.yaml blocked web from triggering on
single-subtree web commits (backend's path filter excluded it from the
pipeline, so web's dependency was never satisfied; web never ran).

Switching to retry loop: 3 attempts with 30s backoff. When a cross-subtree
commit triggers both pipelines, the loser of the helm release-lock race
sleeps and retries — works whether or not the other workflow ran.
2026-04-28 17:01:40 +02:00
99a0d13ab9 chore(ci): web workflow depends_on backend to serialize helm upgrades 2026-04-28 16:58:36 +02:00
f2973dc905 fix(ci): drop --install flag (release exists; helm 4.1 errors on it)
Some checks failed
ci/someci/push/backend Pipeline was successful
ci/someci/push/web Pipeline failed
2026-04-28 16:40:52 +02:00
36db6f08ed fix(ci): use --reuse-values instead of --reset-then-reuse-values
Some checks failed
ci/someci/push/web Pipeline failed
ci/someci/push/backend Pipeline failed
helm 4.1 in alpine/helm:4.1 errored 'release: already exists' on the latter
flag despite the release being deployed. --reuse-values is the older,
universally-supported variant — preserves the other service's image tag
between pipeline runs the same way.
2026-04-28 16:39:16 +02:00
75a626b127 chore: switch CI to monolithic chart, delete old per-service charts
Some checks failed
ci/someci/push/backend Pipeline failed
ci/someci/push/web Pipeline failed
CI deploy steps now target helm/marktvogt with --reset-then-reuse-values,
preserving the other service's image tag across pipeline runs. Each pipeline
sets only its own X.image.tag.

App-level secrets (smtp/turnstile/discovery/ai/JWT/oauth) moved out of CI's
--set chain in the previous phase — now pre-created via
scripts/k8s-secrets-sync.sh from .env.helm. The chart's conditional secret
templates remain for backward-compat with the live release's stored values
but will be removed in a follow-up once those values are cleared.

Old per-service chart directories deleted; only the monolithic
helm/marktvogt/ remains.

MIGRATION.md updated with the actual procedure that worked, including the
several pitfalls hit during the live tenant-2 migration on 2026-04-28
(helm uninstall trap, SSA field-manager swap for CRDs, kyverno hostname
allowlist for new subdomains).
2026-04-28 16:33:53 +02:00
d3982c1d73 feat(helm): add monolithic marktvogt chart + secrets sync script
New unified helm chart at helm/marktvogt/ that combines backend (Go API,
Postgres, Dragonfly, migrate hook, discovery cron) and web (SvelteKit SSR)
into a single release. Replaces the per-service charts at backend/deploy/helm
and web/deploy/helm — kept in place until the live migration is verified
(see helm/marktvogt/MIGRATION.md).

Selector labels and resource names match the existing per-service charts
exactly so migration is by re-annotation rather than recreate; CNPG cluster
and Dragonfly survive the cutover with no data loss.

Adds scripts/k8s-secrets-sync.sh + .env.helm.example for reproducible
out-of-band secret creation. .env.helm itself is gitignored.
2026-04-28 15:57:30 +02:00
ae8e06c7b0 fix(ci): revert corepack on node:25-alpine (no longer bundled)
All checks were successful
ci/someci/push/web Pipeline was successful
2026-04-28 15:31:13 +02:00
08aa34141f chore(ci): split web install/check, corepack, pin platforms
Some checks failed
ci/someci/push/web Pipeline failed
ci/someci/push/backend Pipeline was successful
2026-04-28 15:25:06 +02:00
5b34252132 feat(web): add explicit /healthz endpoint for liveness/readiness probes
All checks were successful
ci/someci/push/web Pipeline was successful
2026-04-28 15:13:48 +02:00
921f329dab chore(web): switch runtime to bun (drop-in node replacement, ~50MB lighter)
All checks were successful
ci/someci/push/web Pipeline was successful
2026-04-28 14:51:17 +02:00
2758e315a8 fix(ci): prune step keeps 8-char SHAs and guards against empty history
All checks were successful
ci/someci/push/web Pipeline was successful
ci/someci/push/backend Pipeline was successful
2026-04-28 14:48:07 +02:00
2acfeed12e chore(ci): prune old SHA-tagged images, keep last 10 per pipeline
All checks were successful
ci/someci/push/web Pipeline was successful
ci/someci/push/backend Pipeline was successful
2026-04-28 14:37:13 +02:00
ec76bc2528 fix(ci): cd web once in check step (cwd persists across commands)
All checks were successful
ci/someci/push/web Pipeline was successful
2026-04-28 14:26:50 +02:00
c857544a12 chore(ci): drop environment from plugin steps to satisfy schema
Some checks failed
ci/someci/push/web Pipeline failed
ci/someci/push/backend Pipeline was successful
2026-04-28 14:21:54 +02:00
fbaa598ae7 chore(ci): switch woodpecker pipelines to plugin-docker-buildx
Some checks failed
ci/someci/push/web Pipeline failed
ci/someci/push/backend Pipeline failed
2026-04-28 14:18:58 +02:00
8fd3e53fe6 chore(ci): add woodpecker pipelines for backend and web 2026-04-28 14:04:16 +02:00
5f96daf7f3 feat(market): admin edit link and public feedback form 2026-04-28 13:43:22 +02:00
f30a963329 fix(market): marshal empty merge-plan buckets as [] not null
Nil slices in MergePlan.AutoApply/ReviewRequired/Rejected serialized to
JSON null, causing the admin research panel to crash with
"can't access property 'map', plan.review_required is null". Initialize
the buckets as empty slices so the wire contract is always an array.
Tightened the empty-buckets test to assert the JSON shape.
2026-04-28 13:09:26 +02:00
3d62ba9526 chore(web): integrate @typescript/native-preview (tsgo) for type-checking
Adds the native (Go) TypeScript compiler as a devDep and routes
svelte-check through it via --tsgo. Local pnpm run check goes from
~5s to ~3s on this codebase; pre-commit hook inherits the speed
automatically.

The linux-x64 prebuilt is a statically-linked Go binary (~25MB), so
the alpine builder in web/Dockerfile installs it cleanly even though
it never invokes svelte-check during the image build.
2026-04-28 12:56:41 +02:00
2de9bdf6c3 chore(db): backfill historical ai_usage costs after pricing fix
Re-prices every existing ai_usage row using the correct $/1M token rates
per model family. CASE clauses ordered specific-first (flash-lite before
flash) to mirror the longest-prefix-match in priceFor(). Aliases
(gemini-*-latest) resolve to the 2.5 family, the only one in production
during the affected window.

The grounding-fee component ($35/1k above 1500/day free tier) is not
recomputed: historical traffic shows zero grounded calls in the window,
so the bumper would be 0. Down is a no-op (irreversible by design — the
original miscalculated values are not preserved).
2026-04-28 12:56:32 +02:00
ba4dce1f76 fix(ai): per-model cost calc + thinking toggle and token tracking
estimateCost ignored the model name and billed every Gemini call at
hardcoded flash-lite rates ($0.10 / $0.40 per 1M), under-counting Pro
calls by ~12-25x. Switch to priceFor(model) and prefer resp.ModelVersion
so aliases like gemini-pro-latest resolve to their concrete family.

Capture ThoughtsTokenCount as a separate ThinkingTokens column on
ai_usage (migration 000030) and bill it at the output rate.

Add a global thinking on/off toggle that mirrors the grounding pattern:
provider holds an in-memory cache (read at startup from settings.Store),
handler keeps it in sync, Chat() applies ThinkingConfig.ThinkingBudget=0
only when disabled. Default true preserves SDK behavior. Grounding+
thinking get/set helpers folded into shared getBool/setBool to keep
goconst happy.

Web admin settings: new "Modell-Reasoning" toggle card; usage panel sums
include thinking tokens. Types are optional with `?? 0` defaults so a
brief web-before-backend rollout window cannot render NaN.
2026-04-28 12:56:04 +02:00
34a3da6e8b fix(auth): include legacy expires_at column in session INSERT
The original sessions table has expires_at TIMESTAMPTZ NOT NULL with no
default. Migration 000027 added the new columns but did not drop this one,
so CreateSession must still supply a value. Using AbsoluteExpiresAt.
2026-04-26 14:10:19 +02:00
bf4d8eb71d chore(settings): update stale JWT_SECRET comment to APP_SECRET 2026-04-26 13:57:19 +02:00
38401ca802 docs(app): auth migration notes for Flutter interceptor update 2026-04-26 13:39:59 +02:00
c6cdc11693 feat(auth): D5 cleanup + W3 web refresh UX
D5 — backend cleanup:
- Migration 000029 drops legacy token_hash column from sessions
- JWT_SECRET renamed to APP_SECRET (fallback + deprecation warning)

W3 — web session UX:
- AuthData type: session_token→refresh_token, remove expires_in
- cookies.ts: refresh_token cookie, non-HttpOnly access_expires_at
- client.server.ts: sends X-Refresh-Token header (not JSON body)
- hooks.server.ts: simplified two-path SSR refresh logic
- refresh.ts: single-flight client-side refresh
- client.ts: proactive refresh + 401 retry on non-auth paths
- /api/auth/refresh: SvelteKit proxy for HttpOnly cookie refresh
- OAuth callback, Datenschutz page updated to new cookie names
2026-04-26 13:25:48 +02:00
515a72e6e8 feat(auth): D4 TOTP backup codes + session management
- Backup codes: 10 × Crockford base32 (XXXXX-XXXXX), SHA-256 hashed,
  single-use; regenerate requires current TOTP code
- Login accepts BackupCode field alongside TOTPCode
- Session management: list, revoke-by-id (ownership-checked),
  revoke-all-except-current; password change revokes other sessions
- New routes: POST /auth/2fa/backup-codes/regenerate,
  GET /auth/sessions, DELETE /auth/sessions, DELETE /auth/sessions/:id
- fakeRepo extended with backup code + session management stubs
- Tests cover: code format/count, hash storage, regen invalidates old,
  login with valid/used code, session list isolation, revoke ownership,
  password change session revocation
2026-04-26 12:33:47 +02:00
492bbb350e feat(auth): D2/D3 opaque-token session model — drop JWT
Replace HS256 JWT access tokens with two opaque 32-byte random tokens
(access + refresh), both stored as SHA-256 hashes in sessions + Valkey.

Key changes:
- GenerateOpaqueToken() replaces JWT issuance; TokenService removed
- Sessions now carry access_token_hash, refresh_token_hash, family_id,
  parent_session_id, access_expires_at, absolute_expires_at, last_used_at,
  revoked_at — per migration 000027 (updated to add access_expires_at)
- Refresh rotation is atomic (UPDATE...RETURNING); reuse detection kills
  the entire token family and returns auth.refresh_reuse_detected
- RequireAuth/OptionalAuth now take SessionLookup (Valkey→Postgres) instead
  of *TokenService; sets session_id in context alongside user_id
- last_used_at is bumped on each request, throttled to writes >60s old
- AuthConfig{AccessTTL,RefreshIdleTTL,RefreshAbsoluteTTL} replaces JWT TTL env
  vars (AUTH_ACCESS_TTL=30m, AUTH_REFRESH_IDLE_TTL=168h, AUTH_REFRESH_ABSOLUTE_TTL=720h)
- JWT_SECRET kept for AI-settings key derivation (drops from auth flow)

Forced logout on deploy (D3 behaviour); pre-launch so acceptable.
2026-04-26 12:15:57 +02:00
0997d4befa feat(auth): D1 non-breaking security foundations
- CORS: rewrite middleware with Vary: Origin, regex origin patterns,
  startup validation, and prod boot-fail on empty allowlist; shared
  CORSConfig exported for CSRF reuse
- CSRF: new Origin/Referer check middleware sharing CORS allowlist;
  Bearer-token clients exempt; mounts globally after CORS
- Argon2id: new password package with PHC format, bcrypt dispatch, and
  NeedsRehash; lazy upgrade on login in auth service
- Rate limiting: add RateLimitByKey with custom key function; apply
  per-route limits to /auth/login, /refresh, /2fa/verify,
  /auth/magic-link, and /auth/password
- apierror: add CSRFMismatch and RefreshReuse error constructors
- Migrations: 000027 (session model schema columns for D2/D3),
  000028 (TOTP secret_v2 column + totp_backup_codes table)
- cmd/totp-encrypt: one-shot job to encrypt existing TOTP secrets
2026-04-26 11:54:37 +02:00
49a31bca02 chore: add .worktrees/ to .gitignore 2026-04-26 11:36:27 +02:00
24dc46eeb8 fix(merge-plan): snapshot proposal prop to avoid structuredClone proxy throw
structuredClone on a Svelte 5 reactive Proxy throws DataCloneError during
component init, causing MergeProposalPanel to silently fail to mount.
Replace with \$state.snapshot which is the documented way to deep-copy a
reactive prop into a local editable state.
2026-04-26 00:28:21 +02:00
131d8c8ff0 fix(merge-plan): extend poll timeout to 270s + guard undefined proposal
Frontend budget was 180s — equal to the backend goroutine cap — so a race
determined which side timed out first. Bumped to 270s to guarantee the frontend
outlasts the backend's 3-minute window.

Added explicit null guard on result.proposal: if the LLM ever returns a
done-status without a proposal body the UI now surfaces a clear error instead
of silently assigning undefined (which kept the panel hidden with no feedback).

Also guards field_merges ?? {} in MergeProposalPanel to avoid Object.keys(null)
if the model returns a null map.
2026-04-26 00:11:31 +02:00
643ee77600 feat(merge-plan): convert to async polling to bypass nginx 60s timeout
POST /admin/markets/:id/merge-plan now returns 202 + job_id immediately
and runs the Gemini advisor in a detached goroutine. Frontend polls
GET .../merge-plan/:job_id until done, with backoff up to 3 minutes.

Adds in-memory job registry (keyed map + RWMutex, 5-min TTL sweep) and
handler tests covering the full pending→done and error paths.
2026-04-25 23:37:03 +02:00
caaad8adf4 fix(web): SSR calls use cluster-internal backend URL to bypass nginx timeout
All serverFetch calls were going to https://api.marktvogt.de (public
gateway), creating a second nginx hop for every SSR operation. Slow LLM
calls (merge-plan, research-plan) hit the 60s proxy_read_timeout.

- Add PRIVATE_API_BASE_URL=http://marktvogt-backend to web Helm config
- serverFetch now builds SERVER_API_BASE from PRIVATE_API_BASE_URL at
  runtime (falls back to PUBLIC_API_BASE_URL when not set)
- apiFetch accepts optional baseURL param; client-side calls unchanged
2026-04-25 22:25:31 +02:00
4916b0d6af fix(infra): increase gateway timeout for admin+market routes to 120s
Merge-plan and research-plan both call Gemini which can take >60s.
The default gateway timeout was killing connections with 504.

- Web HTTPRoute: add /admin/ rule with 120s request+backendRequest timeout
- Backend HTTPRoute: add /api/v1/admin/markets/ rule with 120s timeout
- MergePlan handler: add 110s context deadline for graceful degradation
  before the gateway cuts the upstream connection
2026-04-25 22:03:20 +02:00