Scope & realtime
Tenant scoping, the publisher contract, and the rate limiter.
Three small contracts that sit under the rest of the framework: how a request is narrowed to a tenant, how realtime messages travel between instances, and how throttling counts.
Scope
A scope is resolved once per request and applied by the adapter to every query.
type Scope = Record<string, unknown> | null;
type Session = Record<string, unknown>;AdminConfig.scope receives:
Prop
Type
defineAdmin({
scope: async ({ session }) => ({ orgId: session?.user?.orgId }),
// …
});The returned object reaches every context as ctx.scope, and the adapter turns
it into a predicate on ctx.applyScope.
The opt-out is mandatory
Once an admin declares a global scope, every resource must say what it does
with it — use it, or opt out with scope: "bypass". A resource that says
nothing throws FlowpanelAccessError when it is first queried, rather than
serving another tenant's rows. That is a per-request failure, not a startup
one: defineAdmin is pure and runs no such check.
export function assertResourceScope({ hasGlobal, resourceScope }: ScopeCheckInput): void;Prop
Type
That check runs for you inside the request prologue. You only call it directly when writing an adapter or a route of your own.
See Multi-tenant scope for the full setup.
Publisher
The realtime transport. createPublisher builds one from AdminConfig.realtime;
implement the interface yourself to sit on a bus FlowPanel does not ship.
Prop
Type
Build an SSE realtime publisher.
export function createPublisher(opts: PublisherOptions): Publisher;The memory driver only reaches the process that published, so a second instance
never sees the message. That is fine locally and wrong in production — use the
Redis driver whenever more than one instance serves the admin. The Redis
driver loads ioredis on first use and throws if it is not installed; it is an
optional peer dependency.
keyPrefix namespaces channels when several admins share a Redis, and it
defaults to "flowpanel" — the wire channel for resource.orders is
flowpanel:resource.orders unless you set it. It never changes the logical
channel name application code sees. The rate limiter has its own, unrelated
default prefix: "fp:rl:".
Routing is by channel: one channel carries one payload shape, and the SSE layer
adds the { channel, payload } envelope on the way to the browser — and only
there — so a subscriber always knows which channel a message came from without
inspecting it. Use a distinct channel per event kind rather than multiplexing
shapes onto one.
RateLimiter
Prop
Type
function createRateLimiter(opts: RateLimitOptions): RateLimiter;check returns false when the caller is over the limit, and the request
prologue turns that into a 429. The key is derived from
AuthConfig.userId for per: "user", falling back to the IP when the session
carries no id.
Like the publisher, the memory driver counts per process — a limit of 100 across four instances is really 400 until you move to Redis.