Guide · @staticflux/auth · @staticflux/gate · @staticflux/config
Auth, gating & credits
One session model for every identity provider; one gate that turns facts into explainable, O(1) decisions at every level — app, workspace, route, feature, capability, UI slot.
#Sessions
guest → authenticating → authenticated ⇄ refreshing
authenticated → expired | revoked → guest | authenticating
- Providers:
bffCookieProvider(recommended — tokens stay in the BFF),oidcPkceProvider(tokens in memory only),passkeyProvider,embedHostProvider(the host owns OAuth; the widget never holds a token),customProvider,guestProvider. - Guest and logged-in are the same app: boot never waits on auth;
auth-boundaryroute guards awaitsession.whenSettled(). refresh()is single-flight (optionally under a Web Lock); state syncs across tabs over BroadcastChannel;createAuthorizedTransportadds grant headers and turns a 401 into one refresh and one retry.- A session change with
requiresEpochRefreshtriggers an app-wide refresh through a contributedSessionListener;createSessionScopegives each principal a session injector that logout disposes.
const auth = [
bffCookieProviderUnit({ endpoints: BFF_ENDPOINTS, csrf: { header: CSRF_HEADER } }),
createSessionUnit({ channelName: 'studio/session' }),
epochRefreshListenerUnit, // SessionListenersToken → supervisor.refresh()
guestDraftUpgradeHookUnit, // GuestUpgradeHooksToken → handoff carries the guest draft
];
#Facts → policies → decisions
export const studioPolicies = definePolicySet('studio/policies', [
definePolicy({
id: 'studio/route-requires-login',
resource: { level: 'route', ids: ['route:/studio'] },
when: fact(GateFacts.authenticated).truthy(),
deny: { reason: 'login' },
}),
definePolicy({
id: 'studio/summarize-pro',
resource: { level: 'capability', ids: ['studio/summarize'] },
when: fact(GateFacts.plan).eq('pro'),
deny: { reason: 'upgrade', meta: { plan: 'pro' } },
}),
definePolicy({ // agents are distinct, narrower subjects
id: 'studio/agent-insert-image',
subjects: ['agent'],
resource: { level: 'capability', ids: ['agent/insert-image'] },
when: fact(GateFacts.authenticated).truthy(),
cost: { meter: CreditsMeter, amount: 2 }, // allow-with-cost
}),
]);
const decision = gate.decide('capability', 'studio/summarize'); // sync, O(1), reactive
if (decision.effect === 'deny') openUpsell(decision.reason, decision.alternatives);
const why = gate.explain('capability', 'studio/summarize'); // devtools: "why?"
Policies are JSON-serializable data compiled to decision tables with a fact → entry reverse index, so a fact change recomputes only the affected decisions. Permits are OR-ed, any matching forbid wins, missing facts fail closed. Remote overrides (applyRemote) gate a cohort without a deploy and are validated all-or-nothing. Facts come from plugins — session, plan, entitlements, flags (flagFact), platform — and the gate imports none of their owners.
#Meters: credits, quotas, rates
export const CreditsMeter = defineMeter('billing/credits', { unit: 'credits', mode: 'balance', lowThreshold: 3, reservationTtlMs: 60_000 });
const meter = meters.get(CreditsMeter);
if (!meter.ok) return meter;
const hold = await meter.value.reserve(2, { signal }); // estimating → reserved | denied(quota)
if (!hold.ok) return hold;
const result = await runAgentCall(signal);
if (result.ok) await hold.value.commit(result.value.cost); else await hold.value.release();
The server balance is authoritative (setServerState); the client view is optimistic. Low balance and exhaustion surface through messaging, and the capability pipeline reserves and commits for you. Flags (@staticflux/config) feed the gate as facts and DI as variant bindings — layered default < app < workspace < remote < experiment < local override, OpenFeature-compatible without the SDK.
AGENTS.md on GitHub: @staticflux/auth · @staticflux/gate · @staticflux/config · @staticflux/handoff