Skip to content

Architecture

┌──────────────┐
browser ─────▶│ │
or server │ http/server │ route() dispatches every path
│ │
└──────┬───────┘
┌──────▼───────┐
│ resolver │ project id or slug → that project's stack
└──────┬───────┘ 404 unknown · 403 suspended
┌──────▼───────┐
│ authenticate │ end-user JWT · API key · control token
└──────┬───────┘ aud project vs control — never interchangeable
┌──────▼───────┐
│ permissions │ data:<collection>:<op> · domain:action · *
└──────┬───────┘ cheap gate, before touching Mongo
┌──────▼───────┐
│ policy │ filter injected with $and into the query
│ engine │ + field-level read/write rules
└──────┬───────┘
┌──────▼───────┐
│ DocumentSvc │ schema validation · system-field stamping
└──────┬───────┘
┌──────▼───────┐
│ MongoDB │
└──────────────┘

Every read path — REST, GraphQL, realtime, a function’s ctx.db — enters at the same document service, so authorization cannot differ between transports.

Any failure short-circuits to deny.

1. Tenant isolation. A project resolves to its own database. This is structural: a request can only reach the services built for its own project, so a buggy filter cannot leak across tenants.

2. Permissions (coarse). A cheap check before touching Mongo — may this caller touch this collection and operation at all?

3. Document policy (the hard part). Expressed as a Mongo query fragment and injected with $and. The alternative — fetching documents and filtering in application code — breaks pagination and does not scale. Filter injection means the database enforces the rule, indexes apply, and limit/skip stay correct.

4. Field level. Reads strip deniedReadFields by projection; writes are rejected if they touch a protected or immutable field. Field checks compare actual value diffs, so echoing a field back unchanged is not a mutation.

For a document read the service applies, strictly in this order:

  1. the policy filter,
  2. strip deniedReadFields,
  3. strip internal __vec_* vector fields,
  4. apply the caller’s fields projection.

Projection is last, which is why a vector-search fields list can only ever narrow what you already may see — never widen it.

Get, update and delete return 404 both for “does not exist” and for “exists but is not yours”, because the policy filter is merged into the lookup before the fetch. The same convention holds for storage objects, control-plane projects, connections, and reserved collection names.

This is deliberate: a 403 would confirm the row exists.

Nearly everything denies by default:

  • No policy on a collection ⇒ the collection does not exist on the data plane.
  • No rule for the operation and no admin rule ⇒ deny, even for a * holder.
  • An unresolvable $auth.* placeholder ⇒ deny.
  • A CEL error, or any result other than true ⇒ reject the write.
  • A pre-hook that errors, times out, or finds a busy worker pool ⇒ reject the write.

Two deliberate exceptions:

  • Plan limits fail open. Billing disabled, unknown project, missing owner: everything is allowed. Limits are business caps, not security boundaries.
  • An unknown end-user role resolves to the default user role rather than locking the person out. The admin write boundaries reject typos, so that fallback only ever covers legacy data.
┌────────────────────────────────┐
│ host (has Mongo, has secrets) │
│ │
│ runner.ts ◀── RPC bridge ──┐ │
└──────────────────────────────┼─┘
┌──────────────────────────────┼─┐
│ Bun Worker │ │
│ · no Mongo client │ │
│ · no secrets │ │
│ · env stripped to PATH │ │
│ ▼ │
│ your handler ── ctx.db ──────┘
│ ── ctx.integrations
└────────────────────────────────┘

The worker strips its own environment at startup, preserving only PATH. It never receives a database client. ctx.db and ctx.integrations are host-mediated RPC: the host does the I/O and passes results back across a structuredClone boundary.

ctx.db runs below the policy engine — it is a service-role handle. It is available on post, HTTP and manual runs, and never on pre hooks, which run inside a write and must not re-enter the data path.

Buckets are logical. The bytes live in whichever backend is configured — MongoDB, an S3-compatible store, or memory — and signed URLs are served by the API itself, so they survive a change of backend.

For a BYO Mongo project, the split is sharper still: your data lives in your cluster, while every system collection (users, sessions, schemas, policies, roles, API keys, audit, usage, functions, config, blobs) stays in the platform’s own database. The decrypted connection string never leaves the connection pool.

By default a deployment serves one pre-built project stack; any other project id 404s exactly like a nonexistent project.

With GROVEBACK_MULTI_PROJECT=1 the project is resolved per request from the control plane — by id first, then by slug. Status is re-read every time, so a suspension takes effect immediately.

Optional services (API keys, storage, functions, audit, realtime, usage, config, introspection) return 404 rather than erroring when a deployment does not wire them.

Concern Module
HTTP dispatch src/http/server.ts
Project resolution and stack building src/projects/
Permissions, policy DSL, CEL, filter injection src/authz/
Document CRUD src/data/
Schema subset validation src/schema/
Sandboxed runtime and the ctx.db bridge src/functions/
Ports and their Mongo/S3 implementations src/adapters/

Each module carries its own ARCHITECTURE.md. See the codebase map.