Elarion

Client capabilities

One bootstrap snapshot — modules, feature flags/variants, and the user's grants — projected to the frontend over OpenFeature so the UI can hide or adapt itself.

A frontend usually needs to hide or adapt UI based on what the backend actually offers for the current user and deployment: which modules are enabled, which feature flags/variants are on, and the user's roles and permissions. Elarion ships a single, framework-owned client-capability bootstrap that returns all of that in one snapshot, and a generated TypeScript client (plus an OpenFeature provider) that the frontend reads. The backend stays the source of truth — the client reflects it, it never re-evaluates flags or re-hosts provider config.

This is a read-only UX projection, not an enforcement boundary. A hidden button is not a secured operation — the real gate is the handler's [RequirePermission]/[FeatureGate], enforced server-side on every call. Use the snapshot to adapt the UI; never to authorize.

The snapshot

The bootstrap returns one object for the current user and deployment:

{ "user":     { "id": "u-123", "isAuthenticated": true, "roles": ["admin"], "permissions": ["billing.write"] },
  "modules":  { "Billing": true, "Experiments": false },
  "flags":    { "new-checkout": true },
  "variants": { "ForecastAlgorithm": "neural" } }

It composes existing seams only — the generated IsModuleEnabled, IFeatureFlagService, the declared feature-flag catalog, and ICurrentUser — so module enablement is deployment-scoped while flags, variants, and grants are per-user. See ADR-0030.

Exposing flags — ExposeToClient

A flag is exposed to the client by declaring it so: ExposeToClient = true on its [FeatureFlag] or [BackendFeatureFlag] declaration (see feature flags). Nothing reaches the wire unless its declaration says so, so exposure is leak-safe by construction:

[AppModule("Billing")]
[BackendFeatureFlag("new-checkout", ExposeToClient = true)]
[BackendFeatureFlag("dashboard-v2", ExposeToClient = true)]
public static class BillingModule { }

The bootstrap evaluates only the exposed flags of enabled modules (a disabled module registers no flags), through the same IFeatureFlagService and one FeatureEvaluationContext the handler gates use, so the snapshot cannot disagree with a gate. A flag needs no server-side [FeatureGate]/[FeatureVariant] behind it — a pure UI flag is first-class. A flag whose owner allocates a variant also appears under variants.

GetClientCapabilityManifest() is emitted for every host and carries only the module map (name and enabled state); the flags come from the runtime catalog. A host with no exposed flags gets module enablement plus the user's grants, with empty flags/variants. Using the session bootstrap purely for grants + module enablement is a fully supported case.

Wiring the host

The bootstrap handler is framework-shipped (you don't own its class), so it is exposed imperatively — see exposing a handler you don't own and ADR-0031. A host opts in and chooses surfaces:

// DI — registers the handler and the deployment manifest.
builder.Services.AddElarionSession(builder.Configuration.GetClientCapabilityManifest());

// Named bus (JSON-RPC + MCP) — chain MapElarionSession into the same RegisterHandlers delegate, so the
// shared dispatcher is still built once.
var registerHandlers = (HandlerDispatcher dispatcher, IConfiguration configuration) =>
    ElarionBootstrapper.RegisterHandlers(dispatcher, configuration).MapElarionSession();

builder.Services.AddElarionJsonRpc(registerHandlers);
builder.Services.AddElarionMcp(configuration.GetMcpMetadata(), registerHandlers, configure);

// REST — a concrete, framework-authored endpoint (AOT/trim-safe; see ADR-0031).
app.MapElarionSession();   // GET /session

Nothing else is needed for Native AOT: AddElarionSession contributes the framework-owned SessionJsonContext to the canonical IElarionJsonSerialization itself (ADR-0023), so the session's wire types serialize reflection-free on every transport — the same self-registration every other subsystem's Add… performs.

On the client — one mechanism over OpenFeature

When the schema exposes elarion.session, the TypeScript generator emits a self-contained session-client.ts alongside the RPC client: a typed ClientSnapshot, synchronous SessionCapabilities accessors, and an OpenFeature web-SDK provider. The provider is hydrated from one fetched snapshot and answers every key from the cache via reserved namespaces, so React only ever uses OpenFeature:

OpenFeature keyresolves from
module.Billingmodules["Billing"] (deployment-scoped)
permission.billing.writeuser.permissions.includes(...)
role.adminuser.roles.includes(...)
new-checkoutflags[...] (the ExposeToClient flags)
ForecastAlgorithmvariants[...] (string / .variant)
import { createElarionOpenFeatureProvider, Keys } from './generated/session-client'

const snapshot = await rpc.elarion.session({})         // typed, via the generated RPC client
OpenFeature.setProvider(createElarionOpenFeatureProvider(snapshot) as unknown as Provider)

// then, anywhere — one API for modules, flags, variants, and grants:
client.getBooleanValue(Keys.module('Billing'), false)
client.getBooleanValue(Keys.permission('billing.write'), false)
client.getStringValue('ForecastAlgorithm', 'control')

Teams that don't want OpenFeature can use SessionCapabilities (isModuleEnabled, hasPermission, hasRole, isFlagEnabled, getVariant, getSection) directly.

Keeping the snapshot live

Capabilities change during a session — a login, a tenant switch, a subscription upgrade, an admin granting a role. createSessionCapabilitiesStore wraps the fetch in a store: the same synchronous reads, plus refresh() and subscribe(). Its identity is stable while its answers change, so it is placed once and every later read sees the current snapshot:

import { createSessionCapabilitiesStore, type ClientSnapshot } from './generated/session-client'
import { createContributionRegistryStore } from '@swimmesberger/elarion-contributions'

export const capabilities = createSessionCapabilitiesStore(
  async () => (await rpc.elarion.session({})) as ClientSnapshot
)
await capabilities.refresh()                  // boot

// The router context holds the store itself — `redirectUnless` re-reads it on every navigation.
const router = createRouter({ routeTree, context: { caps: capabilities } })

// Slots re-render when the snapshot changes; resolution still goes through the pure registry.
const registry = createContributionRegistryStore(manifests, capabilities)
// <ContributionProvider registry={registry}>  (React)   |   provideContributions(registry)  (Angular)

await capabilities.refresh()                  // after a mutation that changes what the user may see

Every read before the first successful refresh fails closed — modules, permissions, roles, and flags answer false — so gated UI stays hidden while the snapshot is in flight instead of flashing and retracting. A failed refresh rejects and leaves the previous answers in place. Contributor-fed sections ride along automatically, since they are part of the same snapshot.

Applications without a session snapshot get the same reactivity from createCapabilityStore(createStaticCapabilities(...)), and applications that prefer to own the fetch can keep building a fresh SessionCapabilities/provider per refresh — the store is opt-in, not a replacement. See ADR-0074.

There is deliberately no server push for capability changes yet. A session.changed client-event topic needs per-user targeting and its own authorization story; refresh() after a mutation covers the demand today, and a push topic would call the same refresh() when it lands.

Contributing session sections

The snapshot's fixed shape — user, modules, flags, variants — is framework-owned. Application bootstrap data that a frontend would otherwise fetch with a second round trip (the current tenant, branding, a server clock, an onboarding state) rides along as a named section. Implement IClientSnapshotContributor, declare its payload in a source-generated context, and register both together:

public sealed record TenantSection {
    public required string Name { get; init; }
    public required string Theme { get; init; }
}

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(TenantSection))]
public sealed partial class TenantSectionJsonContext : JsonSerializerContext;

public sealed class TenantSectionContributor(ITenantContext tenant, ITenantDirectory directory) : IClientSnapshotContributor {
    public string SectionName => "tenant";

    public async ValueTask<object?> GetSectionAsync(CancellationToken ct) {
        if (tenant.TenantId is not { } id) return null;
        var info = await directory.FindAsync(id, ct);   // app-owned lookup; ITenantContext only carries the id
        return info is null ? null : new TenantSection { Name = info.Name, Theme = info.Theme };
    }
}

// Program.cs — the resolver argument contributes the payload types to the canonical serialization chain.
builder.Services.AddElarionClientSnapshotContributor<TenantSectionContributor>(TenantSectionJsonContext.Default);

The snapshot then carries a sections object keyed by section name:

{ "user": { … }, "modules": { … }, "flags": { … }, "variants": { … },
  "sections": { "tenant": { "name": "Acme", "theme": "dark" } } }

Contributors are scoped, so they may inject the current user or a DbContext. Returning null omits the section entirely rather than emitting a null, so a section can be conditional on the caller without the frontend having to tell "absent" from "null". A host with no contributors keeps the exact pre-existing wire shape — sections is omitted, not empty. Two contributors declaring the same name is a wiring bug and throws: section names are wire keys, and a silent shadow would hand the frontend the wrong payload under a name it trusts.

Re-export the schema and regenerate the client after adding a contributor. The generated result validators are z.object(...), which strips unknown keys — so against a rpc-schema.json exported before the contributor existed, sections is silently removed client-side and getSection always returns undefined. Re-run the schema export and the client generator whenever the snapshot's shape changes:

dotnet msbuild src/MyApp.Api/MyApp.Api.csproj \
  -t:GenerateElarionJsonRpcSchema \
  -p:ElarionJsonRpcGenerateSchema=true
npx elarion-jsonrpc-client-generator --schema rpc-schema.json --out src/generated

Read a section through the generated accessors — the type parameter is your assertion about the contributor's payload, since the wire carries no runtime type information:

interface TenantSection { readonly name: string; readonly theme: string }

const tenant = capabilities.getSection<TenantSection>('tenant')   // undefined when not contributed

A section is the same read-only UX projection as the rest of the snapshot. Put in it exactly what the UI needs to render, and nothing whose absence you would rely on as a control: the operation is still gated server-side by the handler's [RequirePermission]/[FeatureGate]. Sections are explicit and named — the framework never reflects over your services to decide what to publish.

Because section payloads are serialized from their runtime type, the payload type must be reachable through a source-generated context; that is what the resolver argument to AddElarionClientSnapshotContributor wires, and it keeps the snapshot reflection-free under Native AOT.

Typed vocabulary — no stringly-typed capability checks

The exported schema carries the application's capability vocabulary (module names, each module's client-exposed flags, and the [RequirePermission]/[RequireRole] catalog) in an optional capabilities block — resolved automatically by the schema tool from the app's own registrations. The generator turns it into typed constants and literal unions in session-client.ts, so a typo is a compile error instead of a silent false:

import { Modules, Permissions, Keys, createSessionCapabilities } from './generated/session-client'

caps.hasPermission(Permissions.invoices.read)          // typed — 'invocies.read' would not compile
client.getBooleanValue(Keys.module(Modules.Invoicing), false)

ModuleName/FlagName/PermissionName/RoleName are literal unions when the vocabulary is present and fall back to string on older schemas; accessors accept Name | (string & {}), so out-of-vocabulary names remain expressible. This is the frontend analog of the generated ElarionPermissions static — one vocabulary, compile-checked on both sides of the wire (see ADR-0032).

See also

On this page