Enables PostHog Support through the Direct API (posthog.conversations),
restoring the second channel Recapt used to provide, but as a real ticket
linked to the person and their session replay instead of a black hole.
The in-app WIDGET stays off on purpose. It is a third-party floating chat
bubble, which is exactly what Recapt was: it would sit next to the
Assistenten FAB (which already has a hide_assistant_fab preference
because users wanted it gone), cannot follow the locked design system,
and its copy is not ours to keep Swedish. The conversations API gives the
same tickets from components/ui/support-link.tsx, which is already
on-design, Swedish and reachable from 8 surfaces.
A ticket is explicitly NOT treated as delivery. submitFeedback returns ok
only when the Resend email actually went out, even if the ticket opened.
Recapt's precise failure mode was reporting success on its own channel
while /api/support/contact was dead, and nobody is watching PostHog at
02:00. Tests pin that: ticket-only is ok:false.
Identity verification uses posthog.setIdentity(distinctId, hash) at
runtime rather than the identity_distinct_id/identity_hash init options
PostHog's settings page documents. init runs from
instrumentation-client.ts app-wide, before the user is known and
including logged-out pages, and PostHog fixes init values for the
session. setIdentity is a real method on the SDK (verified typed in
posthog-js 1.407.3), so the hash applies from AnalyticsIdentify once the
dashboard layout knows who the user is. Without the key it is skipped and
tickets fall back to browser-scoped with email recovery, which is the
normal state off hosted.
POSTHOG_SECRET_API_KEY is server-only, no NEXT_PUBLIC_ prefix: it signs
identity hashes AND authenticates external API requests, so unlike the
phc_ project token it is a real credential. Only the derived per-user
HMAC crosses to the browser.
Compliance: support free text is declared as its own data category
(user.content.support) in .compliance/ropa.yaml and named on the privacy
page. Analytics events still carry no message body (the breadcrumb sends
only the subject); a ticket carries what the user wrote, because that is
the point. Keeping the purposes separate is what stops the privacy page
drifting the way the Recapt row did.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recapt shuts down in four days, taking product analytics and session
replay with it. This adds PostHog Cloud EU alongside it; the Recapt
removal follows separately so events can be confirmed landing first.
Wiring choices that are not the tutorial defaults:
- Same-origin reverse proxy (/rl -> eu.i.posthog.com) instead of adding
PostHog hosts to the CSP. connect-src 'self' and script-src 'self'
already cover it, tracking blockers have no third-party host to match,
and the Recapt allowlist entries in next.config.ts get replaced by
nothing at all when they go. Needs skipTrailingSlashRedirect, since
PostHog sends trailing-slash API requests; verified that trailing-slash
URLs on normal routes still resolve 200 rather than 404.
- /rl is excluded from the proxy.ts matcher. Middleware runs BEFORE
next.config rewrites, so without this updateSession() treats an
ingestion POST as an unknown protected path and 307s it to /login.
Verified with a control: /zz/flags/ -> 307 /login, /rl/flags/ -> 200
from PostHog. This fails silently otherwise, because asset loads keep
working through the rewrite while no events arrive.
- persistence: 'memory' so nothing is written to the device and no
cookie-consent banner is required. Everything post-login is unaffected:
AnalyticsIdentify re-identifies on each dashboard load.
- session_recording.maskTextSelector: '*'. PostHog masks inputs but not
text by default, and this app renders org numbers (which for an
enskild firma ARE the owner's personnummer), customer names and
balances as ordinary text. Replays show where a user gets stuck, never
what their books say. buildGroupProperties() also refuses to send
org_number at all, with a test pinning it.
- Error tracking registers through the existing lib/observability sink
rather than bypassing it, so every error-level createLogger() line is
captured already redacted. instrumentation.ts onRequestError covers
what escapes uncaught.
Analytics is hosted-only: isAnalyticsEnabled() short-circuits on
NEXT_PUBLIC_SELF_HOSTED and no Docker sentinel is added, so self-hosted
runs with zero third-party runtime code. Recapt got that outcome only by
accident, via a missing sentinel; here it is explicit and tested.
vitest.config.ts aliases 'server-only' to a stub: it is a build-time
guard whose real entry point always throws, which broke 48 test files the
moment a server-only module entered the graph. request-context.ts was
already carrying the same latent trap.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>