Core concepts
Every term you will meet, defined once.
Project
Section titled “Project”The unit of isolation. A project owns its own database (or, for BYO Mongo, your cluster’s), its own end users, its own collections, policies, functions and API keys. Nothing crosses between projects.
Projects have an id (proj_…) and a slug. The slug is what makes custom function URLs
readable: /api/v1/run/my-app/webhook.
A project is active, suspended or deleted. Suspension takes effect immediately and
returns 403 on every request; deletion is soft.
Environments are ordinary projects with a parent. Labels match ^[a-z][a-z0-9-]{1,15}$,
must be unique among a parent’s environments, and prod/production are reserved — the
root project is production.
The two populations
Section titled “The two populations”| Platform users | End users | |
|---|---|---|
| Who | Developers using the dashboard | People signing into the app you built |
| Identity | A platform account plus a membership per project | A User in the project’s database |
| Roles | Owner, Admin, Editor, Viewer, or custom member roles | The built-in user, or custom end-user roles |
| Token audience | aud: "control" |
aud: "project" |
The two token audiences reject each other. A control token cannot be used on data routes and
a data token cannot be used on control routes. The bridge between them is token exchange:
POST /api/v1/control/projects/:id/token mints a project token from your membership.
Collection
Section titled “Collection”A MongoDB collection exposed over the data API. A collection can carry:
- a schema — optional JSON-Schema-subset validation. No schema means no validation.
- a policy — who can do what to which documents. No policy means nobody but an admin.
- indexes — which you should create for every field a policy filter mentions.
- a vector config — for similarity search.
Some names are reserved: system collections
(users, sessions, projects, …), API route names (auth, admin, storage, …), and
anything starting with __ or system..
Document
Section titled “Document”A JSON object in a collection. Three fields are service-owned and stamped by the server:
id, createdAt, updatedAt. A fourth, ownerId, is forced from the authenticated caller
on any non-admin create — you cannot spoof ownership by putting someone else’s id in the body.
create does accept an optional client-supplied id matching ^[A-Za-z0-9_-]{1,64}$, for
idempotent retries. A duplicate returns 409 Conflict.
Policy
Section titled “Policy”A per-collection, per-operation rule set. The operations are read, list, create,
update, delete, plus admin. A rule can carry:
{ "read": { "filter": { "ownerId": "$auth.uid" } }, "create": { "validate": "document.title.size() > 0" }, "update": { "filter": { "ownerId": "$auth.uid" }, "immutableFields": ["ownerId"], "allowedWriteFields": ["title", "body"], "deniedReadFields": ["internalNotes"] }}The filter is a Mongo query fragment injected with $and into the real query, so the
database enforces it. $auth.uid, $auth.role, $auth.pid and $auth.token.<claim>
interpolate from the caller’s context; an unresolvable placeholder denies the request.
Everything here is fail-closed: no policy at all, or no rule for the operation and no admin
rule, means deny — even for a caller holding *. Policy shape gates admins too.
Full detail: authorization policies.
Permission
Section titled “Permission”A string a caller holds. Three shapes:
data:<collection>:<op>— e.g.data:posts:readdomain:action— e.g.functions:manage,roles:manage,audit:read,config:manage*— everything
Wildcards work per segment: data:*, data:*:read and data:posts:* all satisfy
data:posts:read.
Permissions and policies are two different gates, and a request must pass both. A permission says whether you may touch this collection and operation at all; a policy says which documents.
API key
Section titled “API key”A long-lived server-to-server credential, formatted gb_sk_<projectId>.<secret>. The project
id is embedded, so a key needs no project header. The raw key is shown exactly once — only
a hash is stored.
| Scope | Grants |
|---|---|
read |
data:*:read, data:*:list |
write |
data:*:create, data:*:update, data:*:delete |
admin |
* — everything |
storage |
storage:read, storage:write |
functions |
functions:invoke |
A key is not a policy bypass. Its identity is uid = <key id>, so documents it creates
are owned by the key, and collection policies apply on top of the scope.
Function
Section titled “Function”A per-project ESM handler run in a sandboxed Bun Worker. Triggered by a database, auth or
storage event, by a cron schedule, or served as a custom HTTP endpoint at
/api/v1/run/<project>/<path>.
Timing matters: pre runs synchronously inside a write and can abort or mutate it
(database triggers only); post runs after, queued.
The sandbox has no database client and no secrets. Functions reach data through a
host-mediated ctx.db bridge that runs below the policy engine — available on post, HTTP
and manual runs, never on pre hooks.
See functions and hooks.
Shape versus data
Section titled “Shape versus data”The single most useful distinction in the whole product.
| What it is | How it travels | |
|---|---|---|
| Shape | collections, schemas, policies, functions, roles, integrations, webhooks, buckets | both ways — grove pull down, grove push up |
| Data | the actual documents and files | never between projects; local data comes from committed fixtures |
A config export is shape-only by design, so there is no path that pulls documents out of
production or writes documents into it. This is why grove seed --dump is local-only:
exfiltration wearing a DX hat is still exfiltration.
Bundle
Section titled “Bundle”The on-disk form of a project’s shape — one file per resource, under groveback/:
groveback/ meta.json collections/posts.json functions/ingest.ts ← the real handler, editable functions/ingest.json ← everything else, pointing at the .ts roles.jsonCommit it. Shape changes then show up as a reviewable diff in a pull request. Details in the bundle format.
Config import semantics
Section titled “Config import semantics”grove push and POST /api/v1/admin/config-import are merge-upsert by name: create what
is missing, update what exists, never delete what is absent from the file. Deletion is
opt-in via grove push --prune, which names every resource first and refuses to touch
collections or buckets — those hold data.
Imports are idempotent but not transactional. A mid-apply failure leaves earlier upserts in place; re-running converges.
Realtime
Section titled “Realtime”WebSocket subscriptions over MongoDB Change Streams. The collection’s read rule is
re-evaluated against every single event before it is emitted, not once at subscribe time.
Anything that cannot be proven visible is silently dropped.
Delete events are id-only — the payload never carries a document.
Integration versus webhook
Section titled “Integration versus webhook”Both reach the outside world; they differ in who initiates.
- An integration is a named, credentialed connection (Resend, Telegram, generic HTTP)
that your function code calls via
ctx.integrations.call(name, op, args). - A webhook is declarative: a collection plus events, and Groveback POSTs a signed payload to your URL. No code.