fix: production CSP blocking PostHog/Sentry/Scalar and silent failure hardening

The production CSP had connect-src/script-src/font-src set to 'self' only,
silently blocking all analytics and error reporting in production while
working fine in dev (where CSP is not applied).

CSP fixes:
- Add PostHog ingest + assets origins to connect-src and script-src
- Add Sentry ingest origin to connect-src
- Add Scalar fonts origin to font-src for API docs pages
- Extract CSP construction into testable buildCsp() function

Silent failure hardening:
- Settings/features stores now set loadError flag and allow retry on
  subsequent fetch() calls instead of permanently caching failed state
- Analytics init no longer sets initialized=true before the try block,
  allowing retry on failure
- Settings dialog Tools section disables save button when settings
  failed to load, preventing accidental config wipe
- Branding logo storage moved from process.cwd() to FILES_STORAGE_PATH
  so logos persist across Docker container recreation

Test coverage:
- 16 CSP directive tests covering all external service domains
- Store retry-on-error behavior tests for settings and features stores
- Analytics init retry-after-failure test
This commit is contained in:
SnapOtter
2026-05-05 17:16:19 +08:00
parent fe86c5ac9c
commit e358634f8b
10 changed files with 183 additions and 25 deletions
+5 -7
View File
@@ -16,12 +16,10 @@ function scrubString(str: string): string {
export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
if (initialized || !config.enabled) return;
initialized = true;
try {
const posthogJs = (await import("posthog-js")).default;
if (!consentGranted) {
initialized = false;
return;
}
posthog =
@@ -39,15 +37,15 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
ip: false,
persistence: "localStorage",
}) ?? null;
} catch {
// SDK blocked or unavailable
initialized = true;
} catch (err) {
console.warn("[analytics] PostHog init failed:", err);
}
try {
if (config.sentryDsn) {
const Sentry = await import("@sentry/react");
if (!consentGranted) {
initialized = false;
return;
}
Sentry.init({
@@ -86,8 +84,8 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
},
});
}
} catch {
// Sentry blocked or unavailable
} catch (err) {
console.warn("[analytics] Sentry init failed:", err);
}
}