Skip to content

Workspace

A WorkspaceDefinition declares one branch of the navigation tree the SPA renders in its left rail. Workspaces nest up to four levels deep (ADR-044) and host two kinds of items: links to entities (rendered through their EntityDefinition) and links to dashboards. The composer filters the tree per-user using the same defense-in-depth rule as the entity manifest — items the user lacks permission for are dropped from the payload, never just hidden.

using Granit.Workspaces;
public sealed class CrmWorkspaceDefinition : WorkspaceDefinition
{
public override string Name => "Showcase.Crm";
protected override void Configure(WorkspaceBuilder builder) =>
builder
.DisplayKey("Showcase:Workspace.Crm")
.Icon("users")
.Order(10)
.RequiresPermission("Showcase.Crm.Read")
.Section("identities", section => section
.DisplayKey("Showcase:Workspace.Crm.Section.Identities")
.Entity<Party>(item => item
.DisplayKey("Showcase:Workspace.Crm.Item.Customers")
.Icon("user-circle")
.Order(0))
.Entity<Lead>(item => item
.DisplayKey("Showcase:Workspace.Crm.Item.Leads")
.Order(10)));
}

Register via services.AddWorkspaceDefinition<CrmWorkspaceDefinition>(). The host mounts the composed tree with endpoints.MapGranitWorkspacesEndpoints("/api/workspaces"). A ?includeShells=false query parameter strips the framework shells from the response when the SPA already renders them separately.

The same entity may be referenced by multiple workspaces; each can carry an additive preset overlay that constrains the default list (extra filters, fixed sort, predefined columns). Two workspaces can both expose Party with different presets — the CRM workspace shows the full list, the Accounting workspace shows the same Party with an “outstanding balance > 0” preset.

.Entity<Party>(item => item
.DisplayKey("Showcase:Workspace.Accounting.Item.OverdueParties")
.Preset(new Dictionary<string, object?>
{
["filters"] = new[] { new { field = "OutstandingBalance", op = "gt", value = 0 } },
["sort"] = new[] { "-OverdueAt" },
}))

The overlay must be additive only — the framework rejects presets that try to relax filters baked into the entity’s EntityDefinition / QueryDefinition. The validation surface lives in ADR-048 §4.

Other modules graft items onto a workspace they don’t own through IWorkspaceContributor (ADR-045):

internal sealed class FeatureFlagsOnFrameworkWorkspaceContribution : IWorkspaceContributor
{
public void Contribute(IWorkspaceContributionContext context) =>
context.ForWorkspace(FrameworkWorkspaceNames.System)
.Section("automation", section => section
.Link(item => item
.DisplayKey("Features:Workspace.FeatureFlags")
.Icon("flag")
.Url("/admin/feature-flags")
.RequiresPermission("Features.Flags.Read")));
}

Register with services.AddWorkspaceContribution<FeatureFlagsOnFrameworkWorkspaceContribution>(). The composer folds contributions into the matching workspace at boot — empty shells (a sub-workspace nobody contributed items to) are auto-omitted from the rendered tree.

Granit.Workspaces.Framework ships eight pre-declared shell sub-workspaces under the root Granit.Framework workspace:

System, Users, Automation, Data, Email, Integrations, Monitoring, Privacy.

Modules graft their admin pages onto the appropriate shell — BlobStorage and BackgroundJobs land in Data and Automation respectively, Notifications in Email, and so on. Hosts that don’t load Granit.Workspaces.Framework simply don’t see the shells.

MapGranitLandingRouteEndpoints exposes GET /api/me/landing-route — a 5-tier precedence resolver that picks the user’s effective landing URL on app start (ADR-049):

  1. Personal sticky — last-visited route persisted via PUT /api/me/landing-route/pinned.
  2. Personal pinned — explicit user choice.
  3. Role default — admin-configured per role.
  4. Tenant default — tenant-wide override.
  5. Framework default/w/{first-workspace} from the composed tree.

The route URL is gated by LandingRouteAllowedPrefixes (default ["/w/", "/e/"]) to prevent open-redirect attacks via a poisoned saved-route value.