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.

```ts excerpt
type Scope = Record<string, unknown> | null;
type Session = Record<string, unknown>;
```

`AdminConfig.scope` receives:

**ScopeContext**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `req` | `object` | yes |  |
| `session` | `Session \| null` | yes |  |

```ts excerpt
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.

```ts
export function assertResourceScope({ hasGlobal, resourceScope }: ScopeCheckInput): void;
```

**ScopeCheckInput**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `hasGlobal` | `boolean` | yes |  |
| `resourceScope` | `function \| "bypass" \| undefined` | yes |  |

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](/docs/guides/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.

**Publisher**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `publish` | `function` | yes |  |
| `subscribe` | `function` | yes |  |
| `close` | `function` | no | Release the transport's connections. Publishing afterwards throws. |

Build an SSE realtime publisher. No connection opens until the first publish.

```ts
export function createPublisher(opts: PublisherOptions): Publisher;
```

```ts excerpt
type PublisherOptions =
  | { driver: "memory" }
  | { driver: "redis"; url?: string; client?: RedisLike; keyPrefix?: string };
```

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.

The redis driver needs one of `url` or `client`, and says which is missing if it
gets neither. `client` takes an `ioredis`-compatible instance you already
configured — TLS, a sentinel, a retry strategy — and FlowPanel then opens no
connection of its own for publishing; it calls `duplicate()` only when something
subscribes, because a subscribed Redis connection can run no other command.
Whichever you pass, nothing connects until the first publish or subscribe:
`defineAdmin` stays pure.

Every connection gets an `error` listener, because an unhandled `error` on a
Redis connection is what takes a Node process down. It logs one line per error
kind rather than one per reconnect attempt, and never rethrows.

`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, so use a distinct
channel per event kind rather than multiplexing shapes onto one. On the Redis hop
the message body is the payload as JSON — an empty string when there is no
payload — and the channel is the Redis channel name; the `{ channel, payload }`
envelope is added by `stream()` on the way to the browser, and only there. A
payload is serialized in every driver, so the memory driver delivers what Redis
would: a `Date` arrives as an ISO string in development too, and a payload that
cannot be JSON-encoded is rejected with the channel and the reason named,
whether or not this process has a subscriber.

`close()` releases the connections a publisher opened; a `client` you passed in
stays yours, and the `error` listener FlowPanel added to it is removed. A closed
publisher stays closed: `subscribe` opens no connection and hands back a no-op
disposer, and `publish` throws rather than returning quietly.

### Publishing from a worker

A process that only publishes — a worker, a cron job, a script — imports
`@flowpanel/kit/publish` and nothing else. It needs no admin config, no adapter
and no Next.js, and it opens exactly one connection.

A realtime publisher for a process that only publishes — a worker, a cron job,
a script. It opens one connection, or reuses the one the host passes, and it
imports nothing but `ioredis` (lazily, and not at all when given a client).

```ts
export function createPublisher(opts: { redis: RedisLike | string; keyPrefix?: string; }): PublisherClient;
```

**PublisherClient** — A publish-only realtime client, for a worker, a cron job or a script.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `publish` | `function` | yes |  |
| `channelFor` | `function` | yes | The channel a resource's own events travel on: `resource.<name>`. |
| `close` | `function` | yes | Release the connections this client opened. Idempotent; a host's client is left open. |

**RedisLike** — The slice of `ioredis` FlowPanel uses, so a host can pass its own configured client.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `publish` | `function` | yes |  |
| `on` | `function` | yes |  |
| `off` | `function` | no |  |
| `removeListener` | `function` | no |  |
| `duplicate` | `function` | no |  |

```ts excerpt
import { createPublisher } from "@flowpanel/kit/publish";

const events = createPublisher({ redis: process.env.REDIS_URL! });
await events.publish(events.channelFor("orders"), { action: "update", id });
```

The SSE envelope is exported next to it, for a consumer that reads the stream
without going through FlowPanel's own client:

The SSE frame body, `{channel, payload}` (ADR 0014), with `payload` omitted when there is none.

```ts
export function encodeEnvelope(channel: string, payload: unknown): string;
```

Read an SSE frame body back. Returns `null` for anything that is not an envelope.

```ts
export function decodeEnvelope(raw: string): { channel: string; payload: unknown; } | null;
```

`decodeEnvelope` returns `null` for a frame that is not a channelled envelope,
which is how the browser bus drops a foreign frame instead of handing a
subscriber something it cannot read.

See [Realtime with Redis](/docs/guides/realtime-with-redis#publish-from-an-external-worker).

### The memory driver in a Redis deployment

The publisher is bound per process from `config.realtime`, and rebinds when those
options change, including a `client` that is a different instance than the bound
one — an HMR edit to `realtime` takes effect without restarting the dev server,
while the same config arriving as a different object from another route bundle
does not disturb subscribers that are already connected.

Binding also warns, once per process, when the driver resolves to `memory` while
`REDIS_URL` or `FLOWPANEL_REDIS_URL` is set in the environment:

```
[flowpanel] realtime driver is memory while REDIS_URL is set — other instances
and workers will not receive events; set realtime: { driver: 'redis', url:
process.env.REDIS_URL } in defineAdmin
```

That is the one misconfiguration that looks entirely healthy: every publish
succeeds, the tab that made the change refreshes, and no other instance or tab
ever hears about it.

See [Realtime in production](/docs/guides/realtime).

## RateLimiter

**RateLimiter**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `check` | `function` | yes | Returns true if the request is allowed; false if the limit is exceeded. |

```ts excerpt
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.
