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) directly. Refresh the snapshot (and rebuild the provider) on login or context change.

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