elsa-workflows / elsa-workflows/elsa-core
/features/installed reports every discovered shell feature, including ones the shell disabled
- Dominant language
- C#
- Stars
- 7.9k
- Forks
- 1.5k
- Avg merge
- 15h 22m
- Merged PRs (30d)
- 114
Description
## Description
On a CShells-based host, `GET /features/installed` (and `GET /features/installed/{fullName}`) returns **every feature discovered in the runtime feature catalog**, including features the shell has explicitly disabled or simply never enabled. The endpoint reports installed-in-the-host packages rather than active-in-this-shell features.
This matters because the primary consumer treats the endpoint as an enablement check: Elsa Studio's `RemoteFeatureProvider.IsEnabledAsync(featureName)` calls `GET /features/installed/{fullName}` and returns `true` when the lookup succeeds. Modules decorated with `[RemoteFeature(...)]` therefore activate against backends that do not actually run the feature.
## Steps to Reproduce
Reproduction rate: every time.
1. Host an Elsa server on CShells that references the diagnostics packages, so they land in the runtime feature catalog:
```xml
```
2. Disable some of them in shell configuration, leaving one enabled as a control:
```json
{
"CShells": {
"Shells": {
"Default": {
"Features": {
"StructuredLogs": true,
"StructuredLogsDashboard": true,
"ConsoleLogs": false,
"ConsoleLogsDashboard": false,
"OpenTelemetry": false
}
}
}
}
}
```
3. Confirm the disable actually took effect — it did:
- `GET /elsa/api/diagnostics/console-logs/sources` → `404` (route not mapped)
- `GET /elsa/api/diagnostics/opentelemetry/storage` → `404`
- `GET /elsa/api/diagnostics/structured-logs/sources` → `200` (control, still enabled)
- `GET /elsa/api/dashboard/overview` → `diagnostics.consoleLogs.capability.status = "NotInstalled"` while `diagnostics.structuredLogs.capability.status = "Available"`
So the shell composition and the dashboard's own capability detection both agree the features are not running.
4. Now call `GET /elsa/api/features/installed`.
## Expected Behavior
The response lists the features the shell actually activated. `ConsoleLogs`, `ConsoleLogsDashboard` and `OpenTelemetry` should be absent; `StructuredLogs` and `StructuredLogsDashboard` should be present.
## Actual Behavior
All 60 discovered features are returned, including the three disabled ones:
```
ConsoleLogs listed=True
ConsoleLogsDashboard listed=True
OpenTelemetry listed=True
StructuredLogs listed=True
total 60
```
The same list is what CShells logs at startup as the discovered catalog:
```
CShells.Features.RuntimeFeatureCatalog[0]
Committed runtime feature catalog generation 1 with 60 feature(s): FastEndpoints, Elsa, ...,
ConsoleLogs, ConsoleLogsDashboard, OpenTelemetry, StructuredLogs, StructuredLogsDashboard, ...
```
A second, simpler symptom of the same cause: a host that references `Elsa.ExternalAuthentication` but enables it in no shell still reports `ExternalAuthentication` from `/features/installed`.
## Root Cause
[`ShellInstalledFeatureProvider`](https://github.com/elsa-workflows/elsa-core/blob/main/src/common/Elsa.Features/Services/ShellInstalledFeatureProvider.cs) injects `IEnumerable` and filters only on `StartupType != null`:
```csharp
public IEnumerable List()
{
return _shellFeatures
.Where(sf => sf.StartupType != null)
.Select(MapToElsaFeatureDescriptor);
}
```
In CShells (`CShells 0.0.28`, `src/CShells/Lifecycle/ShellProviderBuilder.cs`), that service is registered per shell from the **full catalog**, not the enabled subset:
```csharp
RegisterCoreServices(services, settings, holder, catalog.FeatureDescriptors);
// ...
var descriptorList = featureDescriptors.ToList().AsReadOnly();
services.AddSingleton>(descriptorList);
services.AddSingleton>(descriptorList);
```
The activated set lives on `ShellSettings.EnabledFeatures`, which the same method computes just above and writes back after dependency resolution, so it includes dependency-pulled features:
```csharp
var orderedFeatures = _dependencyResolver.GetOrderedFeatures(availableFeatures, catalog.FeatureMap);
settings.EnabledFeatures = [..orderedFeatures];
```
`ShellSettings` is registered as a shell singleton (`services.AddSingleton(settings)`), so the enabled set is directly injectable.
Worth noting: the legacy non-shell implementation had the opposite semantics. [`Module.cs`](https://github.com/elsa-workflows/elsa-core/blob/main/src/common/Elsa.Features/Implementations/Module.cs) builds the registry from the features actually added to the module, under the comment *"Add a registry of enabled features to the service collection for client applications to reflect on what features are installed"*. The shell-based provider changed that contract without the consumers changing with it.
## Impact
- Elsa Studio (`3.8.0-preview.1667`) gates modules on `[RemoteFeature(...)]`, resolved through `RemoteFeatureProvider.IsEnabledAsync` → `GET /features/installed/{fullName}`. A disabled feature still resolves, so Studio renders its pages, menu entries and dashboard widgets, and the resulting API calls fail against routes that were never mapped. Concretely: disabling `ConsoleLogs` on the server leaves the Console Logs page in Studio, and its requests fail.
- Operators lose the ability to switch a subsystem off for a deployment without also removing the package reference, which is not an option for a shared/prebuilt image where the same binaries serve every deployment.
- The API is internally inconsistent: `/dashboard/overview` reports diagnostics capability correctly as `NotInstalled` for the same feature that `/features/installed` claims is present.
## Recommendation
Intersect the descriptors with the shell's enabled features, in both `List()` and `Find()`:
```csharp
public class ShellInstalledFeatureProvider(
IEnumerable shellFeatures,
ShellSettings shellSettings) : IInstalledFeatureProvider
{
public IEnumerable List()
{
var enabled = new HashSet(shellSettings.EnabledFeatures, StringComparer.OrdinalIgnoreCase);
return shellFeatures
.Where(sf => sf.StartupType != null && enabled.Contains(sf.Id))
.Select(MapToElsaFeatureDescriptor);
}
// Find(): same predicate before the MapToFullName comparison.
}
```
Because `ShellSettings.EnabledFeatures` is rewritten from the dependency-resolved order during shell build, this reports effective enablement (a feature pulled in as another feature's dependency is correctly reported as present).
If the host-wide catalog semantics are intentional, then the alternative is to keep this endpoint as-is and make enablement explicit — for example an `isEnabled` field on `FeatureDescriptor`, or a separate `/features/enabled` route — and point `RemoteFeatureProvider` at that instead. Either way the current pairing is wrong, since the consumer-side API is literally named `IsEnabledAsync`. The filtering option looks preferable: it needs no client change and matches the pre-shell behaviour.
Happy to open a PR for whichever direction you prefer.
## Environment
- **Elsa Package Version**: `3.8.0-preview.5413`
- **Elsa Studio Version**: `3.8.0-preview.1667`
- **CShells Version**: `0.0.28`
- **.NET**: 10.0
- **Operating System**: macOS 15 (Darwin 25.5.0); not platform specific
- **Hosting**: CShells multi-shell host, single `Default` shell, FastEndpoints under the `elsa/api` prefix
## Related Issues
None found.
Contributor guide
Research direction
Start with src/common/Elsa.Features/Services/ShellInstalledFeatureProvider.cs and trace its registration in CShells/Lifecycle/ShellProviderBuilder.cs. Compare the provider's catalog input with ShellSettings.EnabledFeatures, including dependency-resolved features, then verify both installed-feature endpoints omit disabled features while retaining enabled ones.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100