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, 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 seeEvery 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/generatedRead 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 contributedA 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
- 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
- ADR-0074: Reactive client capabilities
- 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.