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 -3
View File
@@ -11,6 +11,7 @@ interface BundleProgress {
interface FeaturesState {
bundles: FeatureBundleState[];
loaded: boolean;
loadError: boolean;
installing: Record<string, BundleProgress>;
errors: Record<string, string>;
queued: string[];
@@ -149,6 +150,7 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
return {
bundles: [],
loaded: false,
loadError: false,
installing: {},
errors: {},
queued: [],
@@ -156,13 +158,13 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
startTimes: {},
fetch: async () => {
if (get().loaded) return;
if (get().loaded && !get().loadError) return;
try {
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
set({ bundles: data.bundles, loaded: true });
set({ bundles: data.bundles, loaded: true, loadError: false });
recoverActiveInstalls();
} catch {
set({ loaded: true });
set({ loaded: true, loadError: true });
}
},
+5 -2
View File
@@ -10,6 +10,7 @@ interface SettingsState {
defaultToolView: "sidebar" | "fullscreen";
defaultTheme: Theme;
loaded: boolean;
loadError: boolean;
fetch: () => Promise<void>;
}
@@ -21,9 +22,10 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
defaultToolView: "sidebar",
defaultTheme: "light",
loaded: false,
loadError: false,
fetch: async () => {
if (get().loaded) return;
if (get().loaded && !get().loadError) return;
try {
const data = await apiGet<{
settings: Record<string, string>;
@@ -39,11 +41,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
defaultToolView: data.settings.defaultToolView === "fullscreen" ? "fullscreen" : "sidebar",
defaultTheme,
loaded: true,
loadError: false,
});
useThemeStore.getState().applyServerDefault(defaultTheme);
} catch {
set({ loaded: true });
set({ loaded: true, loadError: true });
}
},
}));