Skip to content

Core concepts

Every term you will meet, defined once.

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.

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.

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..

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.

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.

A string a caller holds. Three shapes:

  • data:<collection>:<op> — e.g. data:posts:read
  • domain: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.

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.

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.

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.

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.json

Commit it. Shape changes then show up as a reviewable diff in a pull request. Details in the bundle format.

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.

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.

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.