True parallel operation, no forced re-consent: existing tenants keep signing
in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2).
Model "default legacy, funnel primary, learn in the background":
- Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started
CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an
operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001
one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link;
am_login_attempt pins redirect returns to the app that started them.
- Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via
PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves
the acting app per tenant (no-op until the legacy config pair is set); Graph
client-credentials, admin-consent URLs and the surfaced client id follow the
homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares
the middleware audience builders (was single-audience - would 401 legacy
tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly.
- well-known association file now lists both app ids (publisher domain);
deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP;
mcp-server.bicep default client id -> C4A8 app.
Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): landing page v2 — scroll story, green brand, Autopilot Monitor wordmark
Complete rebuild of the landing page as a scroll story of one enrollment:
- Hero shows the product live (animated event-stream timeline, real event vocabulary)
- Story acts: 09:02 power-on -> 09:41 ANALYZE-ESP-001 hit (most-firing rule, ~22% hit rate)
-> 09:43 MCP terminal demo (full session report via get_session_summary +
get_time_attribution) -> fleet zoom-out
- Floating pill navbar, capabilities strip, restyled comparison, shared SiteFooter
(now also mounted on /about, /terms, /privacy which had no footer at all)
- Green mark (#33B161) unified as the only brand mark via BrandMark.tsx;
blue-indigo gradient marketing mark removed
- Wordmark is now "Autopilot Monitor" (two words) across all user-visible text,
metadata and JSON-LD; technical identifiers (script names, ProgramData paths) untouched
- Theming via lp-* CSS variables on :root/.dark — new components never collide with
the global .dark !important overrides; old .landing-page block kept for /preview
- StatsBand replaces PlatformStats: adds success rate (successfulEnrollments payload
field, previously unused) and a loading skeleton instead of fake defaults
- New opengraph/twitter/apple images in the new design (green mark on dark slate)
- All animations hand-rolled (rAF/CSS/IntersectionObserver), CSP-safe, zero new
dependencies, prefers-reduced-motion respected; auth path (AuthGate/LoginButton/
hostRouting) and staticwebapp.config.json unchanged
Checks: tsc clean, vitest 611/611, static export build clean, no horizontal
overflow at 375px.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): landing feedback round — linear story timeline, honest examples, dev stats
- Story is now one vertical timeline with a strict top-down reading order
(time rail left, text left, visual right) instead of the zigzag grid;
act 1 gets a "session appeared" card so no column is left empty
- Hero live session decoupled from the story device (CONTOSO-0912/Surface
Laptop 7 completes; the story's CONTOSO-4711 is the one that gets stuck)
- Defender for Endpoint removed from the demo stream (it is onboarded, not
installed during ESP) — replaced with Microsoft 365 Apps
- StatsBand: sample numbers in local dev only; in production the band shows
real figures or hides entirely on fetch failure (never fake numbers,
never an endless skeleton)
- Capabilities copy: "Pull a diagnostics package" instead of the incorrect
"Pull ETL and event logs"
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hero v3 — mission-control cockpit instead of single-session card
The first screen now shows the product as a live fleet cockpit: KPI strip
(active-now count ticks), five enrollments running concurrently with
advancing progress bars, a new session popping in, two completing during
the ~23s loop, and the analysis engine visibly flagging the stuck device
inline (HIGH ANALYZE-ESP-001 with the culprit app). Event stream column on
desktop. Headline block is left-aligned and more compact to break the
centered-template look. Deterministic single-tick choreography,
IntersectionObserver start, prefers-reduced-motion gets a rich static
frame. Replaces LiveTimeline.tsx.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hero v4 — real portal screenshots instead of fabricated cockpit
Hero visual is now the actual product: the live portal dashboard (captured
via Playwright from portal.autopilotmonitor.com, demo fleet staged in the
DOM — no customer data, preview banner removed, wordmark as it ships) with
a real analysis finding card (ANALYZE-APP-002 incl. remediation + evidence
JSON, from the docs assets) overlapping bottom-right. Bonus asset
time-attribution.png captured for later use. Removes HeroCockpit.tsx.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hero v5 — clean dashboard, real-analysis terminal, refined header
- Analysis-finding overlay removed: the dashboard shot stands alone (was
colliding with the stats band and not catching)
- Terminal demo moved into the hero as its own "Then just ask" section and
rescripted from a real production analysis (anonymized): failed attempt
with 0x8007EA61 decoded to unmapped installer exit code 60001 (PSADT),
transient proof via clean retry, actionable recommendation
- Headline block refined: uppercase eyebrow instead of pill badge, tighter
type and buttons
- Wordmark "Monitor" back to ink color (green variant parked for compare)
- Story act 3 now shows the real time-attribution screenshot instead of
duplicating the terminal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): story act 3 — notification + one-click diagnostics instead of cropped screenshot
"See where the minutes went" (half-cut time-attribution raster) replaced
with the admin's real next move: the Teams/webhook alert that fired with
the rule, and the on-demand diagnostics flow (requested -> collected
mid-enrollment -> ZIP ready). Crafted JSX cards consistent with the other
acts; unused time-attribution.png removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hero v6 — portal-first layout from approved design draft
- Full-width sticky navbar (brand, Product/Capabilities/Compare/Docs,
Sign in + Get started) replaces the floating pill
- Hero per draft: gray eyebrow, "See every Autopilot enrollment. Live.",
sub left / CTA group right with trust line, portal shot nearly full
width (max-w-7xl), green background glow removed
- Stats as a full-bleed band with dividers under the shot; "issues
detected" replaces success rate (underlines automated analysis),
events compact-formatted (8.3M)
- "Then just ask" terminal extracted into its own JustAsk section after
the band
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): get-started flow page, honest Teams card, calm buttons, light-only landing
- New public /get-started page: five-step schematic (sign in -> request
access -> consent -> deploy bootstrapper -> watch live); "Get started"
in navbar and hero now lead here, "Sign in" keeps the login handoff —
resolving the two-identical-CTAs problem. Wired into hostRouting
PUBLIC_PATH_PREFIXES, sitemap, robots allow-list.
- StoryAction Teams card corrected against the backend renderer: the real
adaptive card has exactly one action ("Open session") — the invented
"Collect diagnostics" button is gone (NotificationAlertBuilder.cs:60).
- Buttons no longer jump (translate-y removed); hover is brightness +
shadow only. Final CTA rebuilt as a calm two-column band per draft
(no dark box, no glow).
- Landing/marketing surface is light-only for now: lp-* tokens re-declared
under .dark .landing-v2, theme toggle removed from LandingNavbar
(screenshots are light; portal keeps full dark support).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): about/terms/privacy on the landing shell — no more sidebar flash
The three public pages now render exactly like /get-started: LandingNavbar
+ landing-v2 tokens + eyebrow/headline header + SiteFooter. GlobalSidebar
no longer treats them as sidebar pages for anonymous visitors — that was
the "weird loading": app sidebar + PublicSiteNavbar popping in after
hydration on marketing pages. PublicSiteNavbar and PublicPageHeader are
dead and deleted; terms/privacy links switched from indigo to the accent
green. Authenticated users keep the portal chrome on these pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): landing navbar anchors root-anchored so they work from subpages
#story/#features/#comparison -> /#story/... — clicking Product/Capabilities/
Compare on /get-started (or any public subpage) now navigates back to the
landing section instead of dead-ending on a missing local anchor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): calmer CTA green, about de-blued, public-page typography aligned
- All real CTAs (navbar, hero, final CTA, get-started, story mock button)
switch from bright #33b161 to the deep accent-ink green — matches the
dark-green family in the portal screenshot, less loud
- About page fully on the new design language: rainbow colorMap collapsed
to the single green accent, audience cards/step bubbles/links/quick-link
hovers de-blued
- About/terms/privacy typography scaled to landing sizes: 15px body,
smaller page h1s and section headings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): terms callouts on lp tokens — no more stray blue/amber banners
Info callout ("Who can use it") now uses the green accent-soft style;
the AS-IS warning uses the palette warn tones. Verified terms/about carry
no remaining raw blue/amber/indigo utility colors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): about tech stack — drop version numbers, fix stale SSR claim
Versions age silently on marketing pages (.NET 10 / Next.js 15 / React 18
removed, technologies stay). Also corrects "server-rendered React app" —
the portal has been a static export since the RSC-prefetch incident fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): terminal demo app renamed to Contoso CRM App
Neutral fictional app instead of a vendor-adjacent print driver name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): analysis card confidence score, 40+ rules claim, final CTA to /get-started
- StoryAnalysis card gets the portal-style confidence bar (80%)
- Act 2 copy: "40+ built-in rules" (exactly 40 in rules/analyze today)
- "Start monitoring now" leads to /get-started like the other Get-started
CTAs; sign-in stays in the navbar and on /get-started itself
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): portal brand aligned to landing, subtle Enterprise anchoring
- Portal navbar (both variants) now uses BrandMark + the landing wordmark
style (w-6 mark, 15px bold tracking-tight, strong ink in light/dark) —
completes the brand consolidation follow-up
- Enterprise plan anchored softly, free-first tone preserved: final-CTA
subline + /get-started closing note ("same service, nothing to
migrate") + "Plans" link in the footer Product column, all pointing to
the docs plans page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): final CTA right-aligned, get-started enterprise note "and more"
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): social images refreshed to current hero claim and tagline
"See every Autopilot enrollment. Live." + the site tagline (real-time
monitoring, automated analysis, AI-ready telemetry) on the green-mark
dark card; opengraph + twitter regenerated at 1200x630.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): social card wordmark single-color, matching the site
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): portal sidebar selection states green — brand-aligned like the docs
Active nav items, expandable group actives, page-section title/divider
switch from blue to the green brand accent (light + dark variants).
Purple Global Admin mode stays purple (semantic distinction); broader
portal blues (status pills, buttons, KPI tiles) deliberately untouched —
that is a separate re-theme pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): preview feedback round — dark-mode leak, alignment, renamed repo links
- About/terms/privacy no longer render half dark for dark-mode visitors:
the light-only marketing scope now neutralizes the global .dark utility
overrides (bg-white / text-gray-* / border-gray-* / gray hovers) inside
.landing-v2
- All landing sections aligned to one container width (max-w-7xl): story,
just-ask, capabilities, comparison, how-it-works now share the same
left edge as hero, stats band and final CTA
- GitHub links updated to the renamed repository okieselbach/AutopilotMonitor
(old hyphenated URLs redirected, now correct at the source)
- /get-started wording: "Preview" instead of "Private Preview" (legal pages
and portal status texts intentionally unchanged)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): preview page on the new design + ?demo=1 review mode; footer width aligned
- /preview rebuilt on lp tokens: BrandMark, single wordmark, calm card,
accent notify box, neutral next-steps, ghost actions — "Preview"
wording instead of "Private Preview"
- ?demo=1 skips the auth redirects and renders placeholder identity so
the page can be reviewed unauthenticated (locally and on SWA previews)
- SiteFooter container widened to max-w-7xl to match navbar and sections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): preview page — prominent approval-pending badge, honest status copy
- Amber "Approval pending" status pill above the headline
- Next-steps wording per review ("sign out and wait a bit or contact
me…", capacity clause dropped)
- Corrected the false "approval status on your dashboard" claim: a
blocked tenant never reaches a dashboard — signing in again lands back
on this page while pending, and goes straight into the portal once
approved (verified against AuthContext PrivatePreview handling)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): about/terms/privacy cards flattened to the landing pinstripe style
All raised cards (shadow / shadow-sm) become flat lp-surface panels with
the thin lp-line-soft border; inner plan boxes and quick-link tiles
switch from gray-200 borders to the same pinstripe line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): portal primary actions green — buttons, avatar, Progress Portal icon
- Primary buttons (bg-blue-600/indigo-600 + hover pairs) switch to
green-600/700: admin backups, customs archive, vulnerability data,
session reports, and the settings SaveResetBar
- Navbar user avatar circles green instead of blue (all four variants)
- Progress Portal sidebar MonitorIcon gets a deliberate green accent
(light + dark) instead of inheriting gray
- Semantic blues (status pills, info banners, KPI tiles) untouched
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): portal links and focus rings on brand green
- All focus states unified: focus:ring/border blue-500 and indigo-500
(207 occurrences) -> green-500 across inputs, selects, buttons
- Link patterns text-blue-600/indigo-600 with hover 800, hover:underline
variants (36) -> text-green-700 hover:text-green-800; icon hovers
hover:text-blue-600 (12) -> green
- New dark override .dark .text-green-700 -> green-400, mirroring the
existing blue-700 convention
- Semantic blues untouched: info banners, status pills, KPI tiles
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): local dev sign-in repaired; progress portal brand accents green
Local sign-in appeared broken (always landed on /progress as a role-less
user) — two stacked regressions in the dev API proxy:
1. trailingSlash:true (static-export change) made next dev 308-redirect
/api/* to /api/*/ BEFORE the proxy rewrite; the Functions backend
rejects trailing-slash routes, so /me silently failed.
-> skipTrailingSlashRedirect: true (no effect on the static export;
SWA handles trailing slashes itself).
2. .env.local DEV_API_PROXY_TARGET still pointed at the pre-EU-cutover
autopilotmonitor-api (dead since 07-18) -> now -api-eu; proxy verified
healthy (v1.5.1027).
Progress portal: header icon, icon circle, "Change device" link and
"Check Status" button on brand green; the blue activity indicators
(downloading/installing) stay blue — that is the portal-wide in-progress
semantic, not branding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): loading screens and remaining primary buttons on brand green
- All four loading spinners (AuthGate, ProtectedRoute, CalculatingCard,
UnmappedSoftwareTab) green instead of blue
- Blue-indigo gradient loading/error backgrounds (AuthGate overlay,
ProtectedRoute states, error page, CalculatingCard, SectionPlatformUsage)
replaced with the neutral lp background (theme-aware in the portal)
- 19 non-contiguous primary buttons (bg-blue-600 text-white ...
hover:bg-blue-700) the first sweep missed -> green, incl. ProtectedRoute
retry/back buttons, error page, audit, diagnosis, session table
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): last blue interactive states green; monitoring headers wrap on mobile
- Time-range toggles (fleet health, software, usage, dashboard, platform
usage), software lens tabs, MCP-usage segmented controls, SLA report
button, gather/analyze rule save buttons, and the page-header icons on
fleet health / geographic performance -> brand green. Zero remaining
bg-blue-600/indigo-600 interactive patterns.
- Monitoring page headers (fleet health, software, SLA, usage metrics,
geographic performance, audit): title row gets flex-wrap + gap-y-3 and
the control group (tenant scope + range buttons) wraps as a compact
second line on narrow screens instead of pushing off the right edge;
range buttons slightly compacted (px-3 py-1.5).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): geo map dev crash — per-mount container key for Leaflet
"Map container is already initialized": dev-mode remounts (StrictMode
re-runs, Fast Refresh over the dynamic() wrapper) hand Leaflet the same
container div twice and it refuses. A per-mount key on MapContainer
forces a virgin div for every (re)mount; MapCleanup keeps handling
instance teardown. No production behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): form control accents (radios/checkboxes) green portal-wide
Checked-state colors for radios and checkboxes (indigo/blue/amber/teal/
sky variants, 19 controls incl. the diagnostics destination radios and
subfolder checkboxes) unified on green, plus the diagnostics "Add" button.
Deliberately kept: red danger-confirmation checkboxes and the purple
global-admin control.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): pin native form-control accent-color to brand green
Root cause of the "red" radios/checkboxes: there is no @tailwindcss/forms
plugin, so text-* utilities never styled native controls — their check
color silently followed the user's OS accent color. A global accent-color
rule (green-600, green-500 in dark) pins all radios/checkboxes to the
brand; the danger-red and global-admin-purple controls override via
accent-* utilities.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): progress portal icon inherits item color again
Permanent green stood out against the gray siblings — the sidebar icon
now follows the item state like all others: gray at rest, green when
active.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): geo map root fix (react-leaflet 5) + readable dark muted text
- Root cause of "Map container is already initialized": Next 15 app
router runs its bundled React 19 regardless of the installed react@18,
and react-leaflet 4.x is incompatible with React 19 StrictMode
double-invoked effects. Upgraded react-leaflet 4.2.1 -> 5.0.0
(@react-leaflet/core 3) and removed our now-counterproductive
workarounds (MapCleanup manual remove, per-mount container key) — v5
owns the map lifecycle including unmount teardown. Verified via a
throwaway repro page: map renders, remount toggle survives, console
clean (repro page deleted).
- Dark mode: .dark .text-gray-400/500 lightened (#64748b -> #7b8ba3 /
#8896ad) — notification timestamps and other muted text were nearly
invisible on raised dark surfaces (unread bg, hover rows).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): subtler dark hover surfaces — hovered rows stay readable
.dark hover:bg-gray-50/100 dropped from #334155 to #283548/#2e3c52 (one
step above the card surface): the bright hover wash made notification
timestamps and other muted text illegible on highlighted rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): GA/tenant notification rows dark-aware — opacity variants escaped the overrides
The global-admin and tenant notification rows use bg-purple-50/30 and
bg-blue-50/30 (+ /50 hovers) — opacity-variant class tokens the global
.dark .bg-*-50 overrides never match, leaving light rows with light text
on dark mode. Explicit dark: variants (purple/blue-900 at low opacity)
fix the readability.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): notification row hover clearly distinguishable in dark mode
GA (purple) and tenant (blue) rows: dark hover raised from /30 to /45
opacity so the highlighted row visibly differs from its resting tint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): one SegmentedControl for all range/group toggles
New shared components/SegmentedControl.tsx (the geo-style connected
pills on a gray track) replaces the three mixed patterns: loose button
trios on fleet health and software, the months <select> on SLA, and
geo's two inline toggles (time range + group-by) now all render the
same control with shared 7/30/90-day labels. Dark mode: active pill
uses arbitrary-value colors because the global .dark .bg-white override
would blend it into the track.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): MCP usage sections use the shared SegmentedControl too
The last remaining variant (joined bordered buttons with raw 7d/30d/90d
labels in admin metrics + reporting MCP usage) replaced by the shared
control with the unified "7/30/90 Days" labels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: clickable notification deep links in the bell dropdown
GA notifications now carry hrefs (session/diag-files report -> report
modal, preview signup -> tenant management filtered), GA rows render the
View link, and every bell row with an href navigates on row click and
closes the dropdown. Target sections accept ?reportId= / ?tenantId= via
the proven Suspense+useSearchParams pattern from DeviceBlockSection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): npm overrides pin react-leaflet peer deps to installed react
react-leaflet 5 peer-depends on react 19; the app router runs Next's
bundled React 19 at runtime while the installed package stays react 18.
Without the overrides a plain npm ci (SWA/Oryx build) fails on the peer
conflict.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
With ASP.NET Core integration the HttpRequestData body is a
forward-only stream whose Length property throws NotSupportedException
(verified in prod: 500 + System.NotSupportedException on the first
POST). The size cap is now enforced by reading at most cap+1 chars via
ReadBlockAsync instead of asking the stream for its length.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New anonymous POST ops/alert-webhook receives Common Alert Schema
notifications from an Action Group webhook receiver and re-emits them
as ops events (category Platform, type AzureMonitorAlert), so Azure
Monitor alerts flow through the existing OpsEventService ->
OpsAlertDispatchService fan-out (Telegram/Teams/Slack + portal
ops-events list) like every internally raised operational event. The
delivery path is independent of the portal: a SWA outage cannot
suppress the notification; the action group's e-mail receiver stays as
the backend-independent fallback.
Security: action-group webhooks cannot send custom headers, so the
shared secret travels as ?secret= and is compared in constant time
(FixedTimeEquals) against the OpsAlertWebhookSecret app setting -
fail-closed while unset. 64 KB payload cap, defensive essentials-only
parsing (only alertRule is mandatory), severity mapped Sev0->Critical,
Sev1->Error, Sev2->Warning, Sev3/4->Info with unknown values falling
back to Info so schema drift can never escalate.
Dual-registered per convention: EndpointAccessPolicyCatalog entry
(PublicAnonymous), OPS_EVENT_TYPES in OpsAlertRulesSection (rule
wiring) and Platform category in OpsEventsSection (badge + filter).
Backend suite 3511 green (incl. new helper tests + catalog
completeness), web tsc + vitest 651 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Agent Binaries check + ops watcher now probe the canonical download alias
(download.autopilotmonitor.com) AND the legacy blob keepalive; alias failure
is unhealthy, legacy failure a warning
- McpServerBaseUrl fallback moved from the stale kindwave WEU ACA FQDN to the
stable custom domain mcp.autopilotmonitor.com (CNAME to the new GWC instance)
- health/detailed and health/mcp include the probed endpoint URLs in check
details for Global Admins only (storage table endpoint, WEBSITE_HOSTNAME,
alias/legacy download URLs, MCP server URL)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- High: TenantConfigurationService.SaveConfigurationAsync now THROWS when the repository reports a failed persist (it swallows storage exceptions and returns false) - plan/trial/config writes can no longer be audited and returned as 200 without being persisted. Cache is invalidated on every attempted save (finally), covering in-place mutations of the cached instance; the now-redundant invalidate-on-failure wrapper in PlanManagementFunction is removed. AuthFunction login side-effect saves (first-login domain write, auto-re-enable) are wrapped best-effort so a storage blip cannot 500 auth/me - they self-gate and retry on next login.
- Medium: MCP usage increment moved from AuthenticationMiddleware (fire-and-forget BEFORE the quota decision) into McpQuotaEnforcementMiddleware: strictly check-then-increment and only for requests actually served (GA tracked but quota-exempt; denied 403/429 requests no longer inflate counters; the exact-limit request is now deterministic - 1..limit pass, limit+1 blocks).
- Low: fixed both new nullable warnings (PlanTierDefinitionParser guarded json, McpQuotaEnforcementMiddleware principal null-flow).
Tests: +3 save-semantics regression tests (throw on false, cache invalidation on failed/successful save). Backend 2656/2656 green, build clean.
Fail-closed entitlement layer (FeatureEntitlementCatalog + TenantEntitlementService): edition resolves at read time (PlanTier == 'enterprise' OR active trial; legacy free/pro => Community, no migration).
- Plan/trial endpoints: PATCH config/{tid}/plan (GA: tier + grant/extend/end trial, audited, cache-safe) and POST config/{tid}/trial (tenant-admin self-service 30d, once per tenant, 409 after). Generic PUT preserves plan/trial fields for all callers. Fixes SetPlanTier writing past the config cache without audit/UpdatedBy.
- Rate limits: Enterprise floor 150/min for BOTH user (portal/JWT) and device (agent/cert) paths - floor raises the global default, an explicit per-tenant override still wins; MSP users follow their home tenant.
- Retention cap: Community 90d / Enterprise 365d, clamped in the retention fanout (warn log) + non-GA write validation 7..cap (only on changed values; 0=infinite stays GA-only).
- Delegated admin (MSP): Enterprise gate on the delegated admin's HOME tenant (JWT tid, fail-closed on unknown) - managed target tenants may be any edition. Grant-time target check removed; inert grants are suppressed at resolve time (single choke point in GetScopeAsync, applied after the UPN-keyed cache).
- MCP quota: McpQuotaService + McpQuotaEnforcementMiddleware (X-Client-Source: mcp only, GA exempt) enforce daily/monthly budgets - per-user plan override > tenant edition plan; limits from SectionUsagePlans with catalog fallbacks (community 100/3000, enterprise 1000/20000); 429 with X-MCP-Quota-* headers + Retry-After; fail-open on counter reads, fail-closed on plan resolution. /api/metrics/mcp-usage/me reports effective plan + counters.
- Trial visibility: TrialExpirySweepFunction (daily 03:30 UTC, informational) emits TenantTrialExpired (Warning, 24h look-back) + TenantTrialExpiring (Info, <=3d heads-up); dual-registered in web OPS_EVENT_TYPES.
- Web: EditionBadge + one-time trial CTA in settings header, Plan & Trial card in the GA tenant edit modal (own PATCH save), retention cap hints from entitlements, pure lib/edition.ts (vitest), feature-flags surface edition/trial/entitlements; MCP TENANT_SAFE_FIELDS + trial fields.
Verified: solution build clean; backend 2653/2653 tests; web tsc + 516/516 vitest; MCP server tsc + tests green.
Revoke enforcement (L4): a revoked delegated (MSP) user kept receiving pushes on
already-joined SignalR groups until the connection dropped (join-time-only
authorization). Negotiate now goes through the Management SDK and binds the
client token to a SignalR user id (lowercased UPN); new DisconnectUserAsync
strips the user from all groups server-side and pushes "accessRevoked", on
which the web client restarts its connection so every re-join re-runs
authorization against the fresh scope. Wired into delegated revoke/disable and
tenant-group unassign / remove-tenant / delete-group. Residual (documented): a
tampered client ignoring the push, and connections negotiated before this
deploy (no user id), keep streams until the connection drops.
Revoke audit integrity (L5): POST /global/delegated-admins now requires a GUID
tenantId (mirrors the tenant-group path), and DELETE reports 404 for an
assignment that never existed instead of returning 200 and writing a false
customer-visible "access removed" audit row (repo delete now checks the 404
response of the idempotent DeleteEntityAsync).
Casing (L6): the SignalR group join/leave cross-tenant check now compares
tenant IDs OrdinalIgnoreCase, aligned with the middleware and the delegated
scope dictionary, so a casing difference can never flip which branch
authorizes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M1: hoist the McpUsers Disabled-row kill-switch above the policy switch so an
explicitly disabled account is denied MCP access under EVERY policy and grant
path (AllMembers, platform roles, delegated auto-grant) — previously inert
outside the WhitelistOnly branch.
M2: drop the MCP access-guard verdict cache from 5 min to 60s. The verdict
carries the kill-switch and the delegated tenant set; 5 min stretched the
backend's 30s revocation target to minutes for MCP callers.
M3: gate same-tenant joins of the plain tenant-{tid} SignalR broadcast group
on a member role (new pure SignalRGroupHelper.IsTenantBroadcastJoinDenied) —
roleless authenticated users (Progress Portal) could passively stream
MemberRead-tier live telemetry they cannot fetch over REST. Companions:
MarkSessionSucceeded/Failed additionally push "newevents" to the per-session
group so a roleless progress watcher still sees admin-marked completion, and
the Progress Portal no longer joins the tenant group (session group covers
all its signals).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tenant notification-group join/leave checks gated on the caller's HOME-tenant role
(IsTenantAdmin / IsTenantMemberOrGlobalAdmin) without verifying the group belonged to that
tenant. Once this branch admits a cross-tenant delegated ("MSP") reader past the broadcast
cross-tenant gate, a home-tenant Admin/member role let them join a DIFFERENT (managed)
tenant's admin/member notify group and receive its higher-tier notification pushes -
breaking the documented "delegated caller gets no notification pushes" contract.
Fix: a single pure gate SignalRGroupHelper.CheckNotifyGroupAccess binds the role to the
GROUP's tenant - admin tier = (sameTenant && IsTenantAdmin) || IsGlobalAdmin; member tier =
(sameTenant && IsTenantMemberRole) || HasGlobalScope. Add/Remove both call it (symmetric).
Preserves own-tenant Admin/member, GA cross-tenant, and GlobalReader cross-tenant member
read; closes the same latent gap for a GlobalReader who is also home-admin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make a delegated ("MSP") admin a "scoped Global Reader": the same pages a Global
Admin sees, but every tenant switcher/filter is bounded to the managed allow-list.
Reader path only (readWrite deferred). MCP stays read-only (Phase 6 already covers
delegated reads).
Backend (bounded aggregate — reuses the GA per-tenant fan-out):
- global/sessions + global/stats/sessions: GlobalReadOrAdmin -> GlobalReadOrDelegatedSubset
(keep QueryParam). A delegated caller WITHOUT ?tenantId= is admitted as a bounded
aggregate; the handler restricts the cross-tenant fan-out to RequestContext.AllowedTenantIds.
With ?tenantId=X it drills into one managed tenant (middleware validates X in scope).
- TableStorageService: GetAllSessions{,Page,Stats}Async gain an optional allowedTenantIds
set; FetchAllSessionsPageInternalAsync swaps its Step-1 tenant source to that subset
(everything downstream — per-tenant query, inverted-tick merge, cursor — is reused).
- IsDelegatedReadTier += GlobalReadOrDelegatedSubset so the single-tenant drill rescue fires.
- SignalR AddToGroup/RemoveFromGroup: admit a delegated caller to a managed tenant-{id}
group (in scope); global-admins stays platform-scope-only.
Web:
- useAggregatedAdminScope / useGlobalAdminScope: cross-tenant mode is now on for a delegated
admin too (isGlobalAdmin = crossTenant; new isDelegatedScope). Tenant list bounded to the
managed subset; always a concrete tenant (no "All tenants" aggregate on analytics pages).
- TenantScopeSelector hides the aggregate option for delegated; GlobalAdminBanner gains a
blue "Delegated Admin View" variant (wired on all analytics pages + dashboard).
- Nav: Fleet group exposes Dashboard + Fleet Health + Software + Geographic + SLA + Usage;
showDashboard now includes delegated.
- Dashboard delegated-ready: crossTenant drives the /global endpoints + filter UI; empty
filter = bounded aggregate over managed tenants, a selected tenant = drill-in; global-admins
SignalR join stays real-GA-only; read-only (mutations already GA-gated). `?tenant=` deep-link
seeds the filter.
- Fleet is now a pure stats card grid; the per-tenant session browser /fleet/[tenantId] is
removed and cards link to /dashboard?tenant=<id>.
Deferred follow-ups: Audit Log + Geographic session drill-in for delegated (both diverge from
the scope-hook pattern / need tenantId threading).
Tests: backend 2484 green (catalog/middleware contract updated for the new subset tier),
web 481 green, MCP 292 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delegated (scoped-global / MSP) callers get cross-tenant ROUTING bounded to their
managed tenant set: every tool call must name a managed tenantId (no aggregate),
platform-only tools stay hidden, and enforceDelegatedTenant(ForPage) validates the
effective tenant client-side as defense-in-depth.
Backend: McpUserService resolves delegated scope and auto-grants MCP access under
WhitelistOnly; McpUserFunction emits delegatedTenantIds/delegatedRole. Recatalog
global/metrics/summary to QueryParam so delegated readers reach it only on their
managed ?tenantId= path (aggregate stays blocked). Authoritative enforcement is the
PolicyEnforcementMiddleware delegated rescue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses two linked Codex findings created by the Phase-3a preview-gate bypass.
(1) HIGH — auto-admin escalation: the first-login auto-admin rule excluded only
GlobalReader, not delegated callers. An external MSP user delegated to manage
tenant B, logging into a home tenant A with no existing admins, would be silently
written as Tenant Admin of A — converting a read-only delegated assignment into
write authority over the MSP home tenant. Fix: needsAutoAdmin also excludes
delegated callers (!isDelegated). A delegated user who legitimately administers
their own tenant has a TenantAdmins row, so this only suppresses the silent grant.
Regression test added (delegatedTenantIds non-empty, memberRole null,
hasTenantAdmins false → NeedsAutoAdmin false, isTenantAdmin false, role null).
(2) side-effect class: /auth/me read the home-tenant config via the auto-creating
GetConfigurationAsync and then ran first-login onboarding side-effects (domain /
OnboardedBy write, "new tenant signup" Telegram, auto-re-enable). For a
delegated-only external MSP login this phantom-onboarded the home tenant. Fix:
read via the non-creating TryGetConfigurationAsync, and run the onboarding
side-effects only for a genuine home-tenant participant (not delegated-only, OR
global, OR an existing member). Genuine first-users still onboard normally.
Full Functions suite green (2473).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for the web MSP/fleet experience: the app now knows whether the signed-in
user is a delegated admin and which tenants they manage.
Backend (/api/auth/me):
- Resolve the caller's delegated scope (cached) alongside the existing parallel
fetches and return delegatedTenantIds (the managed tenant set) + isDelegated.
- Delegated admins bypass the private-preview gate, exactly like Global Admin /
Reader — an MSP whose own home tenant is not on the waitlist must still be able to
log in and manage its authorized tenants. Gate 1 (suspended home tenant) still
takes precedence. BuildAuthResult gains an optional delegatedTenantIds param
(existing call sites unaffected); 3 new tests + the side-effect harness updated.
Web (AuthContext):
- UserInfo gains isDelegated + delegatedTenantIds; AuthContextType gains
hasFleetScope = hasGlobalScope || isDelegated for fleet/switcher gating. All
/auth/me parse paths (success, prefetch, preview/error fallbacks) populate them.
Full backend suite green (2472); web tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GlobalReader = cross-tenant READ visibility like a Global Admin, but no mutating
operations and no platform settings. Provisioned exactly like a GA: a GlobalAdmins
table row with Role=GlobalReader (empty/legacy Role = GlobalAdmin, back-compat).
Semantics are ADDITIVE — a user who is also their own tenant's Admin keeps edit
rights on that tenant; a pure GlobalReader is read-only everywhere.
Backend:
- GlobalAdmins.Role column + GlobalAdminService.GetGlobalRoleAsync; IsGlobalAdminAsync
(incl. TableAdminRepository) delegates so a GlobalReader row never reads back as admin
- RequestContext.IsGlobalReader + HasGlobalScope
- Split GlobalAdminOnly into GlobalReadOrAdmin (cross-tenant reads) vs GlobalAdminOnly
(writes/settings) + TenantAdminOrGlobalReader for config-read
- PolicyEnforcementMiddleware: extracted testable DecideAsync seam; cross-tenant gate keys
off HasGlobalScope; additive own-tenant role carried so IsTenantAdmin stays correct
- Config secret redaction for readers (RedactedCopyForReader) + save-time placeholder
restore (RestoreRedactedSecretsFrom) so a redacted view can't blank real secrets
- raw table/log tools stay GlobalAdminOnly; SignalR global-admins read group admits HasGlobalScope
- /auth/me emits isGlobalReader (+ preview-gate bypass, no auto-admin); /auth/mcp emits globalRole
MCP: hasGlobalScope (GA or Reader) drives routing + tool catalog; raw secret-bearing tools
(query_table/list_tables/query_backend_logs) gated strict-GA only.
Web: AuthContext isGlobalReader + hasGlobalScope; reader reaches /dashboard + cross-tenant
views; platform Settings/Ops/Backups/Software GA-only (nav-hidden + requireGlobalAdmin route
layouts); mutating controls on mixed data pages disabled via useCanMutatePlatform; rules pages
read-only on foreign tenants; additive own-tenant edit preserved.
Tests: backend 2369, MCP 252, web 469 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two related fixes for the end-user Progress Portal experience:
Live updates / permissions: realtime/groups/join+leave were elevated to
MemberRead (regression c4dabeee), which 403'd roleless end users tracking
their own device - the page loaded initial status but never received live
eventStream pushes (looked frozen). Introduce a new EndpointPolicy tier
AuthenticatedUserWithRole that admits any authenticated caller but still
resolves IsGlobalAdmin/IsTenantAdmin/UserRole onto RequestContext, so the
SignalR functions can do per-group authorization. Real gating now lives in
SignalRAddToGroup/RemoveFromGroupFunction: tenant match, global-admins group
on GA, admin-tier notify group on TenantAdmin, member-tier notify group on
tenant membership. Plain AuthenticatedUser was rejected - it would leave the
role flags always-false and break GA/admin group joins.
Sidebar: hide the global Dashboard nav item for regular users (no
admin/operator access); they only see the Progress Portal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grant tenant member roles via Entra ID app-role assignments on the
Enterprise App (the token's "roles" claim), in addition to the
TenantAdmins table. Per-tenant opt-in via the new GA-only
EntraAppRolesEnabled flag (backend-only, no ConfigVersion bump).
Resolution is table-first and tri-state (NotPresent/Disabled/Enabled):
an enabled table row wins, a disabled row is an explicit deny that
suppresses the claim entirely (a disabled member can never be
re-authorized through their app-role assignment), and only a missing
row falls back to the claim. Only Admin and Operator are claim-mappable
(Admin outranks Operator); Viewer and the platform GlobalAdmin role are
never claim-derived.
All effective-role consumers share one pure decision authority
(EntraAppRoleResolver.Resolve): PolicyEnforcementMiddleware, auth/me,
and feedback/status — the latter previously used a table-only check, so
Entra-only members did not see the feedback bubble.
GA toggle wired through the BootstrapToken pattern: TableConfigRepository
persistence, UpdateTenantConfigurationFunction GA-only protection, and a
checkbox in the admin tenant editor.
Tests: EntraAppRoleResolverTests (incl. disabled-row deny) + an
auto-promote regression guard; 2308/2308 backend green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MCP server health probe previously sat inside PerformAllChecksAsync's
Task.WhenAll, so /health/detailed blocked on the MCP timeout before returning
any card, and a scaled-to-zero cold start was reported as an immediate warning.
Backend:
- Move the MCP probe out of the blocking all-checks batch onto its own
GET /api/health/mcp endpoint (AuthenticatedUser; visible to all, not GA-only).
- CheckMcpServerAsync now uses a wake-capable timeout (McpServerHealthTimeoutSeconds,
default 30s). Azure Container Apps holds the request during cold-start activation,
so the probe doubles as the wake signal: a successful wake -> healthy ("woke from
idle" when >2.5s), only a failed wake -> warning, a reachable-but-erroring server
(non-2xx) -> unhealthy.
- Register health/mcp in EndpointAccessPolicyCatalog (fail-closed).
Frontend:
- Fetch the MCP check on its own track, concurrently with and independent of the
fast checks. The card renders a blue "checking..." state immediately and updates
in place when the probe resolves; the other cards never wait on it.
- The MCP result folds into the overall banner/counts only once resolved, so a slow
cold start no longer flips the page to "Warnings detected".
Test: warning-path assertion updated for the new wording (10 HealthCheckService
tests green; 61 policy/endpoint tests green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Normal (non-Global-Admin) MCP users no longer see any Global-Admin / cross-tenant
hints — reducing both confusion and attack surface / curiosity. The per-request
stateless server already runs inside the access-guard's runWithCaller context, so
the caller role is known at server-creation time.
- index.ts: createMcpServer(ga) reads isGlobalAdmin() and tailors instructions
(non-GA gets a tenant-scoped scope line, no cross-tenant mention).
- tools.ts / sessions.ts / search.ts / admin.ts: thread `ga` into every registrar.
* GA-only tools (list_tenants, list_tables, query_table, query_backend_logs,
get_platform_metrics, get_ops_events, list_session_reports, list_blocked_devices,
get_api_usage) are registered only when ga — absent from tools/list for non-GA.
* Dual-role tools: GA/cross-tenant wording in descriptions and tenantId param
text is gated behind `ga ? ... : ...`; non-GA sees clean tenant-scoped text.
- prompts.ts: compare-agent-versions (uses GA-only get_platform_metrics) hidden for
non-GA; cve-exposure-audit cross-tenant wording scrubbed for non-GA.
- error-handler.ts: 403 message no longer names "Global Admin".
- backend McpUserFunction (/api/auth/mcp): omit isGlobalAdmin from the response when
false, so a normal user gets no hint that a GA tier exists. The MCP guard reads
`=== true` (absent → non-GA), so behavior is unchanged.
Verified: GA catalog 29 tools / non-GA 20 (9 GA-only hidden), zero GA/cross-tenant
wording in any non-GA description, prompts split 3/2. tsc clean; backend targeted
suite 135 green; mcp unit 52 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three async workers (analyze-on-enrollment-end, vulnerability-correlate,
telemetry-index-reconcile) park 5x-failed messages on <name>-poison. Until now
there was no operator-visible signal when this happened: backlogs could grow
silently and sessions stayed un-analyzed / vulnerability reports were lost.
Adds a "Poison Queues" entry to the detailed health-check next to "SignalR
Quota" with the same shape: parallel probe via IPoisonQueueProbe, tri-tier
classifier (0=healthy, >=1 warning, >=10 critical, thresholds App-Setting
configurable via PoisonQueueWarningThreshold / PoisonQueueCriticalThreshold),
GA-only visibility. Per-queue 404 counts as zero because poison queues are
created lazily on first poison-move. Per-queue exceptions surface in details
as "error: <msg>" and force at least warning so a flaky Storage call cannot
mask a real backlog.
Web UI needs no change - the generic checks[] render loop on /health-check
picks up the new card automatically.
Backend: 1193/1193 (1178 + 15 new). Build 0 warnings, 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two notification-bell React contexts polled GET /api/notifications and
GET /api/global/notifications every 60s regardless of activity, generating
3017+618 = 3635 calls/24h with near-zero hit rate (notifications are emitted
sparsely). SignalR was already used app-wide for live session updates; the
bell was the lone hold-out on a pull model.
Switch to the same push model. Steady-state traffic drops ~86% and the bell
updates sub-second instead of within 60s.
Architecture (Option C+):
- Mount -> 1x HTTP fetch (cold-start hydration via existing GET endpoint)
- SignalR onreconnected -> re-fetch (covers the disconnect window)
- SignalR push -> live append/dismiss
- No periodic polling, no setInterval
Audience-tier security: TenantNotificationAudienceCatalog still drives
visibility (Member vs Admin), but enforcement moves into SignalR group
membership: tenant-{id}-notify-member is joined by every tenant member,
tenant-{id}-notify-admin only by Tenant Admins / Global Admins. The
AddToGroup function fail-closes with 403 on Member-tier joins to the
admin group, so Admin-tier notifications cannot leak via push.
Backend
- ISignalRNotificationService extracted; SignalRNotificationService
implements it. Resolves the long-standing test-mocking blocker
(the real service's constructor validates AzureSignalRConnectionString
and BuildServiceManager(); previously you could not unit-test
consumers). Tenant + Global NotificationService consume the
interface; the three legacy callers (Analyze, Ingest, VulnCorrelate)
still bind to the concrete class -- migrate per test need.
- Six new push methods: SendTenantNotification{,Dismissed,DismissedAll}
and SendGlobalNotification{,Dismissed,DismissedAll}. Tenant variant
routes to the audience-correct group.
- Tenant + Global service push after a successful repo write
(fire-and-forget; never throws into the caller).
- SignalRGroupHelper: parsing handles the two new -notify-{member,admin}
suffixes; new IsTenantNotifyAdminGroup predicate; group-name builders.
- SignalRAddToGroupFunction: explicit 403 when a non-admin attempts
to join the admin-tier group.
Frontend
- TenantNotificationContext: setInterval gone. useSignalR() to
join member-group always, admin-group iff isTenantAdmin || isGlobalAdmin.
Listens for tenantNotification (append + dedupe), tenantNotificationDismissed
(filter), tenantNotificationsDismissedAll (clear). onreconnected
triggers re-fetch.
- GlobalNotificationContext: same pattern against global-admins group.
- app/layout.tsx: SignalRProvider hoisted above the notification
providers so useSignalR() resolves inside them.
Tests
- SignalRGroupHelperTests (8): parsing for all four group formats
incl. new -notify suffixes, IsTenantNotifyAdminGroup predicate.
- TenantNotificationServiceSignalRPushTests (8): audience-correct push
on Create (Admin / Member / Unknown -> Admin fail-closed), no-push
on repo failure, no-push on DismissAll(count=0), no-push on empty
tenantId.
- FakeSignalRNotificationService: in-memory spy implementing the
new interface; replaces the unmockable real service in tests.
- AuthFunctionSideEffectTests + TenantNotificationServiceTests:
constructor signatures updated for the new SignalR dep.
Suites
Backend xUnit: 1161/1161 (was 1146, +15 net).
Web vitest: 259/259 (unchanged; existing notification-API +
gate tests still cover).
Solution build: 0 warnings, 0 errors.
Expected post-deploy reduction (24h):
GET GetTenantNotifications: 3017 -> ~50
GET GetGlobalNotifications: 618 -> ~10
POST AddToGroup: ~246 -> ~350-450 (two joins per tab)
Net bell load: -86%, sub-second live UX.
Disconnect tradeoff (accepted): if SignalR drops without reconnect
(rare -- withAutomaticReconnect retries 1/2/4/8/16/30s ... forever),
the bell shows the snapshot from the last fetch but no live updates
until next reload. The initial-3-retry give-up path of the SignalR
client still gets the user a hydrated bell because the HTTP fetch
runs independently on mount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MCP server needs to know the caller's GA status to make routing
decisions (GA → /api/global/* with tenantId filter; non-GA → /api/*
JWT-bound). Previously the access-check response only carried
allowed/reason/accessGrant — the MCP layer had no way to detect role.
McpUserService now resolves IsGlobalAdmin unconditionally (it's needed
for both whitelist and AllMembers paths), and the result rides through
on McpAccessCheckResult.IsGlobalAdmin. Existing factory signature stays
backward-compatible via default-param.
Prep for the upcoming MCP role-aware routing refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>