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 /sessionNothing 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 key | resolves from |
|---|---|
module.Billing | modules["Billing"] (deployment-scoped) |
permission.billing.write | user.permissions.includes(...) |
role.admin | user.roles.includes(...) |
new-checkout | flags[...] (the [ClientFeatures] set) |
ForecastAlgorithm | variants[...] (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
- Frontend modules — the contribution model whose
whenclauses evaluate against this snapshot. - ADR-0030: Client capability bootstrap
- ADR-0031: Imperative handler transport mapping
- ADR-0032: Frontend contribution model and the typed capability vocabulary
- Feature flags — the flag/variant seams the snapshot reads.
- Custom transports — declarative vs. imperative exposure.
Feature flags & variants
Declarative, transport-neutral feature-flag gating for handlers — [FeatureGate] over an OpenFeature-backed IFeatureFlagService, with any provider behind it.
Frontend modules
The contribution model — typed extension points, declarative module manifests, and capability-gated resolution — extends "a module only touches its own code" to the web app.