# Brokered app access (/docs/infrastructure/sandbox/brokered-access)

---
title: Brokered app access
description: How a sandboxed guest reaches your collections and files without importing your app, which route carries the calls, why the guest loses its sockets when you turn this on, and the operations the broker still refuses.
kind: guide
package: "@questpie/sandbox"
---

A compute-only guest is easy to reason about. It gets input, it gets a host
allowlist, it returns JSON. The moment it needs your data the question becomes
who holds the credentials, and the answer here is that the guest never does.

## The three pieces

Pass `appBindings` and a `brokerUrl` to `run()` and the executor mints a
short-lived token bound to that pair of capabilities and target, hands the
supervisor the broker coordinates, and revokes the token the moment the run
settles. The guest sees `globalThis.questpie`, whose every method call is one
RPC out over stdio.

```ts
const result = await ctx.executor.run({
	isolation: "sandboxed",
	source: entrySource,
	capabilities: {
		data: { collections: { posts: ["read"] } },
		timeoutMs: 5_000,
	},
	appBindings: target, // the capability-scoped surface you built
	brokerUrl, // from config, never from request input
});
// inside the guest:
//   const posts = await questpie.collections.posts.find({ limit: 10 });
```

<Callout type="warn" title="The broker URL comes from config, always">
	It is the address the supervisor calls carrying the run token. Sourcing it
	from an inbound request's `Host` or origin lets a spoofed header mail that
	token to a host the caller picked. Read it from `executor.brokerUrl` or
	`SANDBOX_BROKER_URL`.
</Callout>

## Register the route

The broker endpoint is a route in a module, so it exists only once you add that
module. Codegen picks it up from `modules.ts` like any other.

```ts title="src/questpie/server/modules.ts"
import { sandboxModule } from "@questpie/sandbox/modules/sandbox";

const modules = [sandboxModule] as const;
export default modules;
```

Run `questpie generate` and the route lands at `sandbox/rpc` under your handler's
base path, so `/api/sandbox/rpc` in the starters. That path is what `brokerUrl`
and the supervisor's `SANDBOX_BROKER_URL` both have to name. Calls arrive with
the per-run token in the `x-questpie-sandbox-token` header, and the broker
rechecks the run's capabilities on every one of them.

## What the guest can call

| Call                               | Reaches                                                        |
| ---------------------------------- | -------------------------------------------------------------- |
| `questpie.files.read/write/list`   | Files, scoped by `capabilities.files` path globs               |
| `questpie.collections.<name>.<op>` | `find`, `findOne`, `create`, `update`, `delete`                |
| `questpie.store.<name>.<op>`       | The same collection ops against one `document_store` namespace |
| `questpie.tools.list/call`         | Custom MCP tools you explicitly released                       |

`questpie.store.posts.create(x)` is exactly
`questpie.collections.document_store.create({ ...x, store: "posts" })`. The sugar
saves the argument, it is not a second enforcement path, and the host still
clamps the call to the stores the manifest granted.

## What it refuses

The manifest types `services`, `jobs` and `workflows`, and the broker will
capability-check them, but none of them dispatches. They answer
`not_implemented`, which is a deferral rather than a silent success. Globals are
refused earlier still: the four namespaces in the table above are the whole
proxy, so guest code has no `questpie.globals` to call. The broker would refuse a
guest's `globals.set` anyway, because a global is a singleton with no per-tenant
boundary to clamp a write against.

Everything else depends on the target you built. The broker dispatches only to
handlers present on it, so an operation the manifest allows but the target never
wired still resolves to `not_implemented`. Capability enforcement runs before
dispatch, so a missing handler can only narrow access, never widen it.

## The guest loses its sockets

Turning bindings on changes the network story completely. The subprocess is
spawned with no `--allow-net` flag at all, which in Deno means no sockets, and
the guest's native `fetch` is replaced by a shim that relays an `http.fetch` RPC
over the same framed channel as the `questpie` proxy. Your declared `net` hosts
stay on the host side and gate the broker's fetch instead.

That is what buys the stronger egress guarantee. The broker resolves the host
itself, validates every A and AAAA record against the private, loopback,
link-local, CGNAT and metadata policy, rejects the host outright if any one
address fails, then pins the socket to a validated IP literal while keeping the
original hostname for SNI and the `Host` header. No DNS resolution happens at
connect time. Redirects are not auto-followed, so every hop is re-parsed,
re-resolved, re-validated and re-pinned, and a hop leaving the run's allowlist is
blocked mid-chain.

<Callout type="info" title="A plain `net` run does not get the pin">
	A compute-only guest holds a real Deno network grant, and its hosts are
	validated once at manifest time. Continuous validation is a property of the
	brokered path, where the guest cannot open a socket in the first place.
</Callout>

## Next

**[Sandbox](/docs/infrastructure/sandbox)** covers the adapters, the capability
manifest and the run result.
