Skip to content

Commit e55ca5e

Browse files
committed
address PR review: explicit logout-once guard, config coercion, drop dead platformUrl
- fetch-with-refresh fires captureLogout per concurrent 401; make the single event explicit via a once-per-incident guard in sentry.ts (consumeLogoutOnce / resetLogoutGuard, reset on re-auth) instead of relying on Sentry's Dedupe. - config.ts: Caddy now emits all values as JSON strings (incl. enabled / tracesSampleRate); getConfig coerces booleans/numbers so a malformed operator value (e.g. NB_SENTRY_ENABLED=yes) degrades that one feature to off, not the whole config to invalid JS. - Drop the dead platformUrl field; rely on Sentry's default same-origin trace propagation. Correct the misleading "W3C traceparent" comment (Sentry propagates sentry-trace/baggage, not W3C). - Tests: web/test/config.test.ts (coercion + precedence) and logout-guard cases in sentry-scrub.test.ts.
1 parent 5513b11 commit e55ca5e

5 files changed

Lines changed: 191 additions & 29 deletions

File tree

web/Caddyfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,15 @@
2828
# Helm — no writable filesystem, no entrypoint. index.html loads this before
2929
# the app bundle (window.__NB_CONFIG__, read by web/src/config.ts). Public
3030
# client values ONLY — never secrets. Unset env => empty field => feature off.
31+
#
32+
# Every value is emitted as a JSON *string* (incl. enabled/tracesSampleRate),
33+
# and config.ts coerces booleans/numbers. This way a malformed operator value
34+
# (e.g. NB_SENTRY_ENABLED=yes) degrades that one feature to "off" instead of
35+
# producing invalid JS that would disable every feature at once.
3136
handle /config.js {
3237
header Content-Type "application/javascript; charset=utf-8"
3338
header Cache-Control "no-store"
34-
respond `window.__NB_CONFIG__ = {"tenantId":"{$NB_TENANT_ID:}","environment":"{$NB_SENTRY_ENV:}","release":"{$NB_RELEASE:}","sentry":{"dsn":"{$NB_SENTRY_DSN:}","enabled":{$NB_SENTRY_ENABLED:false},"tracesSampleRate":{$NB_SENTRY_TRACES_SAMPLE_RATE:0}},"turnstile":{"siteKey":"{$NB_TURNSTILE_SITE_KEY:}","enabled":{$NB_TURNSTILE_ENABLED:false}},"posthog":{"key":"{$NB_POSTHOG_KEY:}","enabled":{$NB_POSTHOG_ENABLED:false}}};` 200
39+
respond `window.__NB_CONFIG__ = {"tenantId":"{$NB_TENANT_ID:}","environment":"{$NB_SENTRY_ENV:}","release":"{$NB_RELEASE:}","sentry":{"dsn":"{$NB_SENTRY_DSN:}","enabled":"{$NB_SENTRY_ENABLED:false}","tracesSampleRate":"{$NB_SENTRY_TRACES_SAMPLE_RATE:0}"},"turnstile":{"siteKey":"{$NB_TURNSTILE_SITE_KEY:}","enabled":"{$NB_TURNSTILE_ENABLED:false}"},"posthog":{"key":"{$NB_POSTHOG_KEY:}","enabled":"{$NB_POSTHOG_ENABLED:false}"}};` 200
3540
}
3641
handle {
3742
root * /srv

web/src/config.ts

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,28 @@
77
* configured per tenant via Helm env, with no per-tenant builds and no writable
88
* filesystem.
99
*
10+
* Caddy renders every value as a JSON *string* (so a malformed env value can't
11+
* produce a syntax error that breaks the whole file); `getConfig()` coerces
12+
* booleans/numbers, so a bad value degrades that one feature to "off" rather
13+
* than silently disabling everything.
14+
*
1015
* The committed placeholder `public/config.js` (empty object) is what `bun run
11-
* dev` serves locally; in a container Caddy's handler supersedes it.
12-
* For local-dev convenience `getConfig()` falls back to Vite env when a field is
13-
* absent — in a built image `window.__NB_CONFIG__` always wins because CI builds
14-
* with no `VITE_*` values.
16+
* dev` serves locally; in a container Caddy's handler supersedes it. For local
17+
* dev `getConfig()` falls back to Vite env when a field is absent — in a built
18+
* image `window.__NB_CONFIG__` always wins because CI builds with no `VITE_*`.
1519
*
1620
* This is the single source the web client reads for Sentry, Turnstile, and
1721
* PostHog config. All values here are public client keys — never secrets.
1822
*/
23+
24+
/** Resolved config the app reads, with booleans/numbers coerced. */
1925
export interface NbRuntimeConfig {
2026
/** Deployment tenant id (from NB_TENANT_ID); the Sentry `tenant_id` tag. */
2127
tenantId?: string;
2228
/** Deployment environment label (from NB_SENTRY_ENV), e.g. "production". */
2329
environment?: string;
2430
/** Build SHA, optional. */
2531
release?: string;
26-
/** Platform API base URL (for distributed-trace propagation). */
27-
platformUrl?: string;
2832
sentry?: {
2933
dsn?: string;
3034
enabled?: boolean;
@@ -34,33 +38,60 @@ export interface NbRuntimeConfig {
3438
posthog?: { key?: string; enabled?: boolean };
3539
}
3640

41+
/**
42+
* Raw injected shape: Caddy renders every value as a JSON string, and the dev
43+
* placeholder may omit fields. Booleans/numbers arrive as strings and are
44+
* coerced by `getConfig()`.
45+
*/
46+
interface RawConfig {
47+
tenantId?: string;
48+
environment?: string;
49+
release?: string;
50+
sentry?: { dsn?: string; enabled?: string | boolean; tracesSampleRate?: string | number };
51+
turnstile?: { siteKey?: string; enabled?: string | boolean };
52+
posthog?: { key?: string; enabled?: string | boolean };
53+
}
54+
3755
declare global {
3856
interface Window {
39-
__NB_CONFIG__?: NbRuntimeConfig;
57+
__NB_CONFIG__?: RawConfig;
4058
}
4159
}
4260

61+
/** Coerce a string/boolean flag; `undefined` stays undefined (dev default). */
62+
function asBool(v: string | boolean | undefined): boolean | undefined {
63+
if (v === undefined) return undefined;
64+
return v === true || v === "true";
65+
}
66+
67+
/** Coerce a string/number to a finite number, falling back on garbage/empty. */
68+
function asNum(v: string | number | undefined, fallback: number): number {
69+
const n = typeof v === "number" ? v : Number(v);
70+
return Number.isFinite(n) ? n : fallback;
71+
}
72+
4373
/** Resolved runtime config: `window.__NB_CONFIG__` over Vite-env dev fallbacks. */
4474
export function getConfig(): NbRuntimeConfig {
45-
const w = (typeof window !== "undefined" && window.__NB_CONFIG__) || {};
75+
const w: RawConfig = (typeof window !== "undefined" && window.__NB_CONFIG__) || {};
4676
return {
47-
tenantId: w.tenantId,
48-
environment: w.environment ?? import.meta.env.MODE,
49-
release: w.release,
50-
platformUrl: w.platformUrl ?? import.meta.env.VITE_API_BASE,
77+
tenantId: w.tenantId || undefined,
78+
environment: w.environment || import.meta.env.MODE,
79+
release: w.release || undefined,
5180
sentry: {
52-
dsn: w.sentry?.dsn ?? import.meta.env.VITE_SENTRY_DSN,
53-
enabled: w.sentry?.enabled,
54-
tracesSampleRate:
55-
w.sentry?.tracesSampleRate ?? Number(import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE ?? 0),
81+
dsn: w.sentry?.dsn || import.meta.env.VITE_SENTRY_DSN,
82+
enabled: asBool(w.sentry?.enabled),
83+
tracesSampleRate: asNum(
84+
w.sentry?.tracesSampleRate ?? import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE,
85+
0,
86+
),
5687
},
5788
turnstile: {
58-
siteKey: w.turnstile?.siteKey ?? import.meta.env.VITE_TURNSTILE_SITE_KEY,
59-
enabled: w.turnstile?.enabled,
89+
siteKey: w.turnstile?.siteKey || import.meta.env.VITE_TURNSTILE_SITE_KEY,
90+
enabled: asBool(w.turnstile?.enabled),
6091
},
6192
posthog: {
62-
key: w.posthog?.key ?? import.meta.env.VITE_POSTHOG_KEY,
63-
enabled: w.posthog?.enabled,
93+
key: w.posthog?.key || import.meta.env.VITE_POSTHOG_KEY,
94+
enabled: asBool(w.posthog?.enabled),
6495
},
6596
};
6697
}

web/src/sentry.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,28 @@ import { getConfig } from "./config";
1616
*/
1717

1818
let initialized = false;
19+
// One Sentry event per involuntary-logout *incident*, not per concurrent 401.
20+
// The app fires parallel /v1 requests; a real logout resolves the single-flighted
21+
// refresh as rejected for all of them, so each awaiting caller would otherwise
22+
// call captureLogout. This guard makes singularity explicit rather than relying
23+
// on Sentry's Dedupe integration. Reset on re-auth (setSentryUser).
24+
let loggedOutCaptured = false;
25+
26+
/**
27+
* Test seam for the once-per-incident logout guard: returns `true` the first
28+
* time and `false` until {@link resetLogoutGuard} re-arms it. Exported so the
29+
* singularity can be unit-tested without initializing Sentry.
30+
*/
31+
export function consumeLogoutOnce(): boolean {
32+
if (loggedOutCaptured) return false;
33+
loggedOutCaptured = true;
34+
return true;
35+
}
36+
37+
/** Re-arm the logout guard (called on re-auth). Exported for tests. */
38+
export function resetLogoutGuard(): void {
39+
loggedOutCaptured = false;
40+
}
1941

2042
export function initSentry(): void {
2143
if (initialized) return;
@@ -35,8 +57,11 @@ export function initSentry(): void {
3557
// results, file contents) — the exact content the trust rule forbids. Same
3658
// intent as telemetry.ts disabling PostHog session recording.
3759
tracesSampleRate: s.tracesSampleRate ?? 0,
38-
// Extend the trace into the platform's OTel layer over W3C traceparent.
39-
tracePropagationTargets: [cfg.platformUrl || /^\//],
60+
// tracePropagationTargets is left at the SDK default (same-origin), which is
61+
// exactly our case — all API calls are same-origin /v1/* proxied to the
62+
// platform. Note browserTracingIntegration propagates Sentry's own
63+
// sentry-trace + baggage headers (not W3C traceparent), so this does not
64+
// auto-link into the kernel's OTLP traces; the platform ignores the headers.
4065
beforeSend,
4166
beforeBreadcrumb,
4267
// Strip query strings from transaction names so workspace/conversation ids
@@ -84,6 +109,8 @@ export function beforeBreadcrumb(crumb: Sentry.Breadcrumb): Sentry.Breadcrumb |
84109
/** Set the per-session opaque user id (never email/displayName). */
85110
export function setSentryUser(userId: string | null | undefined): void {
86111
if (!initialized) return;
112+
// A new authenticated session starts a fresh logout incident.
113+
if (userId) resetLogoutGuard();
87114
Sentry.setUser(userId ? { id: userId } : null);
88115
}
89116

@@ -107,11 +134,12 @@ export function addAuthBreadcrumb(message: string): void {
107134
}
108135

109136
/**
110-
* Emit one event at the terminal involuntary logout, carrying the preceding
111-
* auth breadcrumb trail — turns a "random logout" into a reconstructable
112-
* sequence. Not called on manual user logout.
137+
* Emit ONE event per involuntary-logout incident, carrying the preceding auth
138+
* breadcrumb trail — turns a "random logout" into a reconstructable sequence.
139+
* Idempotent across the concurrent 401s that resolve one rejected refresh; reset
140+
* by setSentryUser on re-auth. Not called on manual user logout.
113141
*/
114142
export function captureLogout(reason: string): void {
115-
if (!initialized) return;
143+
if (!initialized || !consumeLogoutOnce()) return;
116144
Sentry.captureMessage(`involuntary logout: ${reason}`, "warning");
117145
}

web/test/config.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// ---------------------------------------------------------------------------
2+
// getConfig — the runtime config seam every client feature reads through.
3+
//
4+
// Caddy injects window.__NB_CONFIG__ with all values as JSON strings; getConfig
5+
// coerces booleans/numbers so a malformed operator value degrades that one
6+
// feature to "off" instead of breaking the whole config. These tests pin the
7+
// coercion and precedence so the seam can't silently regress.
8+
// ---------------------------------------------------------------------------
9+
10+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11+
import { getConfig } from "../src/config";
12+
13+
// Mutate only window.__NB_CONFIG__ — never the shared (happy-dom) window object,
14+
// or we'd tear down the DOM for every test that runs after this file.
15+
type Win = { __NB_CONFIG__?: unknown } | undefined;
16+
const getWin = (): Win => (globalThis as unknown as { window?: Win }).window as Win;
17+
const ensureWin = (): { __NB_CONFIG__?: unknown } => {
18+
const g = globalThis as unknown as { window?: { __NB_CONFIG__?: unknown } };
19+
g.window ??= {};
20+
return g.window;
21+
};
22+
23+
function setConfig(c: unknown): void {
24+
ensureWin().__NB_CONFIG__ = c;
25+
}
26+
27+
beforeEach(() => {
28+
const w = getWin();
29+
if (w) w.__NB_CONFIG__ = undefined;
30+
});
31+
32+
afterEach(() => {
33+
const w = getWin();
34+
if (w) w.__NB_CONFIG__ = undefined;
35+
});
36+
37+
describe("getConfig", () => {
38+
test("uses injected values and coerces string flags/numbers", () => {
39+
setConfig({
40+
tenantId: "tenant-a",
41+
sentry: { dsn: "https://k@o1.ingest.sentry.io/1", enabled: "true", tracesSampleRate: "0.25" },
42+
turnstile: { siteKey: "0xAAA", enabled: "false" },
43+
posthog: { key: "phc_x", enabled: "true" },
44+
});
45+
const c = getConfig();
46+
expect(c.tenantId).toBe("tenant-a");
47+
expect(c.sentry?.dsn).toBe("https://k@o1.ingest.sentry.io/1");
48+
expect(c.sentry?.enabled).toBe(true);
49+
expect(c.sentry?.tracesSampleRate).toBe(0.25);
50+
expect(c.turnstile?.enabled).toBe(false);
51+
expect(c.posthog?.enabled).toBe(true);
52+
});
53+
54+
test("a malformed flag/number degrades to off/0, not a crash", () => {
55+
setConfig({ sentry: { dsn: "d", enabled: "yes", tracesSampleRate: "" } });
56+
const c = getConfig();
57+
expect(c.sentry?.enabled).toBe(false); // anything but "true" => off
58+
expect(c.sentry?.tracesSampleRate).toBe(0); // empty/garbage => 0
59+
});
60+
61+
test("absent enable flag stays undefined (dev: on iff key present)", () => {
62+
setConfig({ sentry: { dsn: "d" } });
63+
expect(getConfig().sentry?.enabled).toBeUndefined();
64+
});
65+
66+
test("empty config leaves every feature unconfigured", () => {
67+
setConfig({});
68+
const c = getConfig();
69+
expect(c.sentry?.dsn).toBeUndefined();
70+
expect(c.sentry?.enabled).toBeUndefined();
71+
expect(c.turnstile?.siteKey).toBeUndefined();
72+
expect(c.posthog?.key).toBeUndefined();
73+
expect(c.sentry?.tracesSampleRate).toBe(0);
74+
});
75+
});

web/test/sentry-scrub.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@
1010
// ---------------------------------------------------------------------------
1111

1212
import type { Breadcrumb, ErrorEvent } from "@sentry/react";
13-
import { describe, expect, test } from "bun:test";
14-
import { beforeBreadcrumb, beforeSend } from "../src/sentry";
13+
import { beforeEach, describe, expect, test } from "bun:test";
14+
import {
15+
beforeBreadcrumb,
16+
beforeSend,
17+
consumeLogoutOnce,
18+
resetLogoutGuard,
19+
} from "../src/sentry";
1520

1621
describe("beforeSend", () => {
1722
test("strips cookies and headers from the request", () => {
@@ -64,3 +69,21 @@ describe("beforeBreadcrumb", () => {
6469
expect(out?.data?.url).toBe("https://app.example/v1/tools");
6570
});
6671
});
72+
73+
describe("logout guard (one event per incident)", () => {
74+
beforeEach(() => resetLogoutGuard());
75+
76+
test("emits once across the concurrent 401s of a single logout", () => {
77+
// N concurrent 401s resolve one rejected refresh; captureLogout runs per
78+
// caller but must collapse to a single emit.
79+
expect(consumeLogoutOnce()).toBe(true);
80+
expect(consumeLogoutOnce()).toBe(false);
81+
expect(consumeLogoutOnce()).toBe(false);
82+
});
83+
84+
test("re-auth re-arms the guard for a later logout", () => {
85+
expect(consumeLogoutOnce()).toBe(true);
86+
resetLogoutGuard(); // setSentryUser does this on a fresh session
87+
expect(consumeLogoutOnce()).toBe(true);
88+
});
89+
});

0 commit comments

Comments
 (0)