fix(web): the global Inbox in the browser
What was asked
On tryluke.dev/app the global Inbox showed no items, though per-task inboxes did, and the same account's desktop showed them all.
Root cause
The account inbox is followed over Neon Realtime: the host fetches sealed statements from /api/live/inbox and opens a WebSocket to the Realtime socket that answer names (packages/host/src/account-inbox-follow.ts). The web app's Content-Security-Policy (root vercel.json, on the /app routes) had connect-src 'self' plus PostHog and Sentry, and no Realtime host. So the browser refused the socket. Realtime's client then reconnects forever without reporting anything, no answer was ever published, and app state's inbox slice stayed empty.
These all read that slice, so they were all empty on the web:
- the global Inbox
- the Inbox badge
- the inbox, chat and failed-run toasts and notifications
The agent-ended toast doesn't depend on it. Per-task inboxes read their items directly by act, which is why they worked. The desktop's host runs in Electron's main process, where no page policy applies.
Things I checked and ruled out:
/api/live/inboxalready accepts the cookie throughresolveHostedUserId.linkClientNoticesand the follow are wired on the web exactly as on the desktop.- Production's Realtime branch accepts any
Origin(allowed_originsis empty; I checked with a WebSocket handshake).
Fix
- CSP:
connect-srcon both/approutes now allowswss://*.us-east-1.aws.neon.tech.- CSP only allows a wildcard as the leftmost label, so a form like
*-neonrealtimesocket.…isn't valid. - Each branch gets its own socket,
<branch>-neonrealtimesocket.compute.c-10.us-east-1.aws.neon.tech. Production and every Preview branch I checked are in the same cell. - I chose the project's region over its cell (
c-10): a Neon project never changes region, but nothing promises it keeps its cell. - The headers are static in
vercel.json, so the build can't write in the exact host fromNEON_REALTIME_URL. - A
wsssource also matches anhttpsaddress, so this works whichever scheme the URL is written with.
- CSP only allows a wildcard as the leftmost label, so a form like
- Loud failure: a follow that has published nothing 30 seconds after it began is now reported once, as a
Warn:- It goes to the host's line (
Effect.logWarning, which the Reporter receives) and to the runtime's error reporters (ErrorReporter.report, which reaches Sentry on the desktop). - It says why the follow last stopped, and never includes an address or a seal. The follow keeps retrying.
- It goes to the host's line (
- Sentry on the web: the web runtime installs no Effect error reporter yet. So the tab's Sentry SDK now sends every kind of request the page policy refuses (
securitypolicyviolation) once, as a warning, naming only the directive and the origin. The next connection the policy forgets will show up in Sentry.
Tests
apps/web/tests/vercel-services.test.ts: every/approute'sconnect-srcadmits production's and a Preview's Realtime socket, and no other socket. This fails without the fix.packages/host/src/compose-coding-agents.test.ts:- a follow whose socket never delivers is reported once at 30 s, as a warning with no address or seal, and keeps following. This fails without the fix.
- a follow that answers in time reports nothing.
apps/web/tests/web-sentry.test.ts: a refusal is sent once per directive and origin, without its path or query.
Checks
pnpm check:affectedran with the sandbox under memory pressure:- knip found an unused export, now fixed.
- The panel and web typechecks were OOM-killed. I re-ran the web typecheck alone and it is clean; I didn't touch the panel.
- Some
compose-liveandopen-on-mactests timed out under load. Run alone, all of them pass.
- lint and oxlint are clean.
- I couldn't test this in a signed-in browser here. CI and a Preview are what confirm it.
Made with Luke