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

docs/LIFECYCLES.md §9
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-boundary route guards await session.whenSettled().
  • refresh() is single-flight (optionally under a Web Lock); state syncs across tabs over BroadcastChannel; createAuthorizedTransport adds grant headers and turns a 401 into one refresh and one retry.
  • A session change with requiresEpochRefresh triggers an app-wide refresh through a contributed SessionListener; createSessionScope gives each principal a session injector that logout disposes.
apps/studio/src/app/compose.ts (abridged)
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

TypeScript
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

TypeScript
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