feat(notifications): notification center (bell, banner, admin) [#1259] - #1552
Conversation
Add the bones of a GitHub-esque notification center backed by the existing central-manager SystemStatus table: - Bell + unread badge in the navbar with a preview dropdown - Dedicated /notifications center page (active / upcoming / expired notices) - Bottom banner auto-showing active, un-acknowledged notices - Admin management UI (fabric-admin) to create / edit / delete notices - Live updates over Harper's table WebSocket subscription (SSE is buffered at the edge), with a 60s polling backstop - Client-side (localStorage) acknowledgement state; time-windowed (UTC) active state; optional internal (router) / external (new-tab) deep links No central-manager change needed: SystemStatus already exposes public read, fabric-admin-gated writes, and a WebSocket subscription. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive system-wide notification feature, including a notification bell in the navigation bar, a bottom-anchored notification banner, a dedicated notifications center, and an admin management interface. It also implements a WebSocket subscription manager for real-time updates and client-side acknowledgment persistence. The review feedback highlights a critical security vulnerability where unvalidated external links could allow stored XSS via 'javascript:' or 'data:' URIs. Additionally, feedback points out a potential sorting instability caused by subtracting 'Infinity' from 'Infinity' in the notifications center, and a potential runtime crash in the 'toMs' helper if a non-string value is trimmed.
Move the banner + WebSocket subscription from the authed Dashboard layout to the cloud root (StudioCloud) so global maintenance/outage notices reach signed-out users on the sign-in page too — SystemStatus read is public and the socket connects anonymously. Gate the navbar bell to cloud, since Navbar also renders in local studio (which has no central-manager). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, a11y) - parseNotificationLink: allowlist safe external schemes (http/https/mailto) and reject javascript:/data:/vbscript:/unknown schemes — stored-XSS defense on the admin-authored deep link (gemini, high) - notification center sort: use Number.MAX_SAFE_INTEGER instead of Infinity so two open-ended notices don't compare as NaN (unstable sort) (gemini, medium) - toMs: return null for unexpected non-string/number inputs instead of throwing on .trim() (gemini, medium) - banner: role="region" + aria-live="polite" so screen readers announce active notices (cb1kenobi) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DavidCockerill
left a comment
There was a problem hiding this comment.
Really nice piece of work — this is a lot of surface area and the shape is right throughout. Non-blocking overall; the subscription pair below is the one thing I'd want fixed before it ships, and the two halves need fixing together.
Two things I checked hard and found sound, worth stating since they're the scary parts of a feature like this:
- The authorization isn't presentational.
AdminShell.tsx:14's<Navigate to="/" />is UI-only, but the server enforces independently atcentral-manager/src/resources/SystemStatus.js:9-19(isFabricAdmin(context)on create/update/delete) and atroles.yaml:96(insert/update/delete: falsefor the standard role).isFabricAdminthrows on a missing user rather than returning falsy, so it fails closed. A normal user POSTing directly gets rejected. Your "no central-manager change needed" claim is accurate. - The
datetime-local↔ UTC round trip is correct — this is the bug I most expected to find.toInputValuereads with local getters;fromInputValuecallsDate.parseon an offset-less date-time string, which ES2015+ specifies as local, then.toISOString(). Same zone both directions, lossless.
Significant
1 & 2 — the subscription has two opposite failure modes sharing one root cause (NotificationsSubscriptionManager.tsx:30 and :44). failures is reset by a successful handshake, not a healthy session.
Silent permanent give-up. Backoff sums to 2+4+8+16+32 = 62s, then if (failures > 5) return; — and because StudioCloud never unmounts, the effect never re-runs. The socket is dead for the rest of the session, with no log line.
Plainly: one wifi hiccup and the live feed is dead in that tab until reload. Nothing tells the user, nothing tells us — it silently degrades to the 60s poll. When someone reports "the maintenance banner took a minute to appear," there's no signal anywhere pointing at the cause.
Unbounded reconnect loop — the mirror image. If the server accepts the upgrade then closes (post-handshake notAllowed(), an edge dropping non-conforming upgrades, a CM restart loop), onopen zeroes failures before every onclose, so it can never exceed 1 and the five-attempt cap is dead code. Every open tab — including tabs on the sign-in page belonging to people who never logged in — retries every 2s forever. (Credit: Codex found this half; I verified it.)
Please note the interaction: the trigger for #2 is exactly the kind of server-side hardening someone might add later, so fix this before anyone tightens the CM side.
Suggested: cap the backoff, not the attempts (the Math.min(…, 30_000) already does that); only zero failures after the socket has stayed open ~10s; re-arm on window.addEventListener('online', …); and console.warn on give-up so it's greppable in a RUM session.
3 — an unparseable endAt means "never expires" (notificationHelpers.ts:86). toMs returns null for both "absent" and "present but unparseable", so an unreadable field short-circuits the expiry check — and getWindowStatus compounds it by labelling the row a confident 'Active'. Trigger: a SystemStatus row in a shape toMs doesn't handle (a Date object, a {$date: …} wrapper, a non-ISO locale string) — most plausibly created outside Studio, e.g. by the CM integration suite or by hand.
Plainly: a "maintenance complete at 3am" banner sits on every user's screen indefinitely, labelled Active, until an admin deletes the row by hand. Users can dismiss it — but only per-device, so it's back on their phone.
Worth making toMs distinguish the two cases and having isActive fail closed on unparseable. The instinct is already in this file — the severity mapping defaults up to critical — it just isn't applied to the time window. One case in the existing isActive describe block at notificationHelpers.test.ts:41 covers it.
4 — NotificationBell.test.tsx mocks away what it's testing (:16). With both hooks and acks mocked, the only production code under test is the bell's JSX. useActiveNotifications and useUnackedActiveNotifications return the same array, so "badge shows 2" asserts the mock, not the unread computation. And useNotificationAcks returning an empty Set means the acked-first branch of the SEVERITY_ORDER sort (NotificationBell.tsx:19) is never exercised — nothing in the PR sorts a mixed acked/unacked list.
Plainly: this test would still pass if the unread-count logic were replaced with "however many notifications exist."
Cheap upgrade: mock only the query (or seed the real queryClient) and let the hooks run. Worth saying that notificationHelpers.test.ts is the opposite and genuinely good — real functions, real-shaped sta- fixtures, boundary cases, XSS rejections. That file is the model.
Non-blocking
SEVERITY_ORDERis defined twice, identically (NotificationBell.tsx:19,index.tsx:17). It belongs next toSEVERITY_CONFIGinnotificationHelpers.ts, exported once — two copies of an ordering rule drift the moment someone adds a fourth severity, and then the bell and the center disagree about which notice is most serious.readAcks()trusts the shape, not just the JSON (acks.ts:18).safeParsehandles malformed JSON correctly — good reuse — but valid JSON of the wrong shape ({}) flows through thestring[]cast, and[...readAcks(), id]/new Set(data)then throwTypeError: not iterable, taking down bell, banner and center at once since all three are globally mounted.Array.isArray(v) ? v.filter(x => typeof x === 'string') : []closes it. (Codex flagged this too.)ackNotificationcan throw on a full localStorage (acks.ts:39).writeAckspersists beforequeryClient.setQueryData, so aQuotaExceededErrorthrows out of anonClickand the notice doesn't even disappear in-memory. Swap the order so a quota failure degrades to "ack didn't survive reload" rather than "dismiss is broken." Low likelihood, but Safari private mode has historically thrown on first write. (Codex too.)- Nothing prunes acks for deleted notifications (
acks.ts:39) — slow growth (~12 bytes/id), so hygiene rather than a real quota risk; a two-line intersection on query success makes it self-limiting. The per-browser scoping I'd leave alone — you state it as a deliberate v1 trade with a named successor, and being re-nagged once per device is a fair price for shipping without a CM schema change. One line in the PR body confirming that's deliberate would stop it being re-litigated. (Codex flagged the pruning.) - The admin form accepts a link it will never render (
index.tsx:248).submit()validates the message and start/end ordering but passesurlthrough unchecked — typejavascript:alert(1)or a typo'dhtp://…and it saves happily, thenparseNotificationLinkreturnsnulland the link silently never appears. Success toast, broken notice. CallparseNotificationLink(url)insubmitso the form can't drift from the renderer. new WebSocket(url)is unguarded (:30) — the constructor throws synchronously on a malformed URL, inside auseEffectwith notry.VITE_CENTRAL_MANAGER_API_URLis absolute in every checked-in env file so it's fine today, but a relative or//-prefixed value in a future deploy config would throw out of the effect on a page anonymous users load.- Following on from cb1kenobi's banner note:
aria-live="polite"is right for info/maintenance, but your severity model has acriticaltier — considerassertivewhen any strip in the stack is critical. That was in the original Barber AI comment; only thepolitehalf landed.
Verified and dropped
Recording these so nobody re-runs the same checks: the admin gate is not client-side-only (above); the datetime-local↔UTC round trip is correct (above); expiring notices do clear within 30s (useNow(30_000) re-renders and useActiveNotifications re-filters); there's no StrictMode double-subscribe (empty dep array, teardown sets cancelled, the stale closure's onclose early-returns, close() during CONNECTING is legal); and mutations do surface errors via the global MutationCache.onError. I also tried several XSS variants through the link and message fields and found no bypass.
One scoping note for the record: CM#483 does not cover SystemStatus — that guard was scoped to resources declaring static loadAsInstance = false, which SystemStatus doesn't. So the anonymous WS subscribe here works by omission rather than by decision. Not a leak — the table is public-read and carries admin-authored broadcast content — but worth making that permissiveness explicit somewhere rather than incidental, since the next person to read SystemStatus.js alongside #483 will wonder.
Performance is a non-blocking note: four independent 30s clocks where one shared tick would do, and an unmemoized sort in the bell that runs on every render of a component present on every page. Both cheap at this scale.
Cross-model note: ran this by Codex, whose findings are incorporated above and credited inline. The Gemini leg timed out at 240s on the 1539-line diff — not an auth failure, just size.
— Reviewed by DAIvid (Claude Opus 5), cross-checked with Codex
…sed expiry, hardening) Blocking — subscription (NotificationsSubscriptionManager): - reset backoff only after a ~10s healthy session, not the bare handshake, so an accept-then-close server can't reconnect-storm every 2s - cap the backoff (30s), never the attempt count, so a transient blip can't silently strand the tab on the 60s poll for the rest of the session; warn once when it goes unstable - reconnect on window 'online'; guard `new WebSocket` with try/catch Significant: - isActive/getWindowStatus fail closed on an unparseable timestamp (new parseInstant distinguishes absent from invalid) so a bad endAt can't pin a stale notice on screen forever - NotificationBell.test now mocks only the query and exercises the real hooks, ack store, and sort Non-blocking: - readAcks validates the array-of-strings shape; writeAcks updates the cache before persisting so a full localStorage degrades to "didn't survive reload" instead of a broken dismiss - admin form validates the link via parseNotificationLink before saving - banner uses aria-live=assertive when any active strip is critical - SEVERITY_ORDER exported once from notificationHelpers; one shared 30s clock; center sort memoized Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the exceptionally thorough pass — the subscription analysis especially. All addressed in Blocking — the subscription pair (
Significant:
Non-blocking, also done: Consciously deferred:
🤖 Addressed by Claude Code |
DavidCockerill
left a comment
There was a problem hiding this comment.
Approving at d04ce32. One commit fixed all four significants plus every non-blocking item — nice.
The part I most wanted to check: the subscription pair was fixed together at the shared root, not one half at a time. Verified with a fake-timer WebSocket harness — 14 reconnects over 5 minutes in both the offline case (was 5-then-permanently-silent) and the accept-then-close case (was ~150). Fixing either half alone would have made the other worse, so that mattered.
Also verified: an unparseable endAt now fails closed, and the bell test survives three mutations — it's a real test now rather than an assertion about its own mocks. Suite green, tsc clean, CI passing.
Two non-blocking threads. The second comes with a ~45-line test I already wrote and ran against this head — say the word and it's yours rather than something you have to write.
— Reviewed by DAIvid (Claude Opus 5)
…scription - onOnline no longer resets `failures`, so an `online` burst can't unwind the escalating backoff it was meant to skip ahead of; added a 2s floor between online-triggered attempts, which collapses a burst (each event followed by a failure) into a single connect - new NotificationsSubscriptionManager.test.tsx: fake timers + a stub WebSocket cover no-give-up after 6 failures, accept-then-close escalation, healthy-session reset, online debounce, online keeping the escalation, a throwing constructor, unset base URL, and unmount teardown Coverage on the file: 11.86% -> 93.65% statements, 9.09% -> 87.5% branches. Each assertion was mutation-checked: re-introducing `failures = 0`, dropping the floor, restoring the give-up cap, or resetting on the bare handshake each fails exactly one test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes #1259.
The bones of a GitHub-esque notification center for Fabric Studio, backed by the existing central-manager
SystemStatustable.What's here
NotificationBell.tsx./notifications— full list grouped active / upcoming / expired —features/notifications/index.tsx.NotificationBanner.tsx./admin/notifications(fabric-admin) — create / edit / delete via DataTable + dialog —features/admin/notifications/.NotificationsSubscriptionManager.tsx.Notable decisions
GET /SystemStatus/withAccept: text/event-streamreturns 0 bytes (the edge bufferstext/event-stream, even for CM's own tables), while the WS upgrade towss://…/SystemStatusreturns HTTP 101 and connects anonymously (matches the table's public read). Still a true subscription; no polling.SystemStatusalready exposes public read, fabric-admin-gated writes, and a WebSocket subscription — so start notification center for fabric studio #1259's "blocked on CM" is unblocked by the existing table.top-0, sub-navtop-20, contentmt-32) on every page, so a top banner would need a global offset refactor; a bottom bar is robust everywhere and never hides the nav. Easy to revisit.Scope / follow-ups
Starts global; org/user scoping is an additive next step (indexed columns + query filter, same center). Server-side per-user read state and a
titlefield are also easy follow-ups. A global notice should also render for signed-out users (currently the surfaces mount inside the authed Dashboard layout) — noted as a fast follow.Testing
tsc,oxlint,dprintclean.least_privileged, so the admin CRUD + live WS push are being verified ondevwith an admin account).🤖 Generated with Claude Code