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 /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 ExposeToClient flags) |
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 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/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
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
- 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
Every feature flag is declared once with exactly one owner — a resolver class or the host's backend — and gates, the client snapshot, and variants all evaluate through the same catalog.
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.