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 IFeatureVariantService variant accessor, and ICurrentUser — so module enablement is deployment-scoped while flags, variants, and grants are per-user. See ADR-0030.

Exposing flags per module — [ClientFeatures]

A module declares the flag/variant names it exposes to the client on its [AppModule] type. Nothing reaches the wire unless a module names it, so exposure is leak-safe by construction:

[AppModule("Billing")]
[ClientFeatures("new-checkout", "dashboard-v2")]   // exposed to the frontend
public static class BillingModule { }

The bootstrap evaluates only those names, only for enabled modules. A listed name needs no server-side [FeatureGate]/[FeatureVariant] behind it — a pure UI flag is first-class, evaluated by the same provider and the user's context. The names are collected into a per-deployment manifest by the bootstrapper generator (configuration.GetClientCapabilityManifest()).

GetClientCapabilityManifest() is emitted for every host, so the one-liner below compiles even when no module declares [ClientFeatures] — you then get 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; you don't need a single client flag to adopt it.

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 [ClientFeatures] set)
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 tenants) : IClientSnapshotContributor {
    public string SectionName => "tenant";

    public async ValueTask<object?> GetSectionAsync(CancellationToken ct) {
        var tenant = await tenants.GetCurrentAsync(ct);
        return tenant is null ? null : new TenantSection { Name = tenant.Name, Theme = tenant.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 [ClientFeatures], 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