Skip to content

Config as code

A project’s shape — collections, schemas, policies, functions, roles, integrations, webhooks, buckets, vector configs, OAuth config — is one serializable document. That is what makes it reviewable in a pull request and promotable between environments.

Need Primitive
Serialize a project’s shape ConfigService.export() — deterministic and byte-comparable
Show a reviewable diff plan() — a dry diff with before/after fragments per change
Apply a config import() — idempotent merge-upsert
Never leak secrets Export redaction, plus an importer that rejects any document carrying one

Over HTTP those are GET /api/v1/admin/config-export and POST /api/v1/admin/config-import (with ?dryRun=1 to plan). Through the CLI they are grove pull, grove status and grove push.

Every array is sorted by name and keys are emitted in a fixed order, so two exports of the same shape are byte-identical apart from exportedAt. The bundle form strips even that.

This is what makes a config diff meaningful: a commit appears only when the shape actually changed, and git status stays clean after a no-op pull.

The single most important property here. OAuth exports carry clientId and enabled only. Integration secrets, webhook signing secrets and BYO Mongo URLs never appear at all.

Defense in depth runs the other way too: the importer refuses any document containing clientSecret or secretEnc, so a hand-edited commit cannot smuggle one in.

Two consequences to plan for:

  • A newly imported OAuth provider has no secret, and sign-in stays broken until an operator enters one.
  • An imported webhook arrives disabled with no secret, and enabling it requires a secret on file. A shape export can never produce a live outbound sender.

Plan fragments are built from the same redacted views as the export, so a fragment cannot contain a secret either.

Merge-upsert by name. Create what is missing, update what exists, never delete what is absent. A second import of the same document plans as all-unchanged.

Everything validates before anything applies — schemas, policies, function-code syntax, role shapes, OAuth entries, duplicate names, plus state-dependent checks like built-in role collisions and HTTP endpoint conflicts. All collected and reported together, and nothing applies if any problem exists.

Not transactional. Steps apply in dependency order: collections → schemas → policies → indexes → vectors → roles → end-user roles → integrations → functions → webhooks → buckets → oauth. A mid-apply failure leaves earlier upserts in place; re-running converges.

Import is gated on config:manage, plus each composed operation re-checks its own permission (collections:manage, policies:manage, roles:manage, functions:manage, auth:config:read/write). A custom role needs the full set.

Terminal window
grove pull # project shape → groveback/
git add groveback && git commit -m "add orders collection"
# open a PR — the diff reads as the change
grove push # after review, apply it

Two guards make this safe in a team:

  • push refuses if the project moved since your last pull. Each pull records a sha256 per file in .grove/pull.json; if someone else pushed in the meantime, applying yours would silently clobber theirs. --force overrides.
  • --dry-run exits 2 when there are changes, so CI can gate a PR without parsing output:
Terminal window
grove push --dry-run || test $? -eq 2 && echo "config changes pending review"

Import never deletes. grove push --prune does, and only for functions, webhooks, integrations, roles and end-user roles.

Collections and buckets are never pruned — they hold data, so orphans are reported with advice to remove them deliberately. Vector configs and OAuth providers attach to a collection or a provider slot, so they are excluded too.

Every name is printed before anything is deleted, and confirmed separately from the apply.

The same machinery, without files:

Terminal window
curl -X POST "$URL/api/v1/control/environments/promote?dryRun=1" \
-H "authorization: Bearer $CONTROL_TOKEN" -H 'content-type: application/json' \
-d '{"fromProjectId":"proj_staging","toProjectId":"proj_prod"}'

Both projects must be in the same family and active, and you must be Owner of both. Like every import, it never deletes: resources that exist only in the target are untouched and do not appear in the plan.

The direction of truth is worth stating plainly: the platform is the source of truth, and git is a versioned backup and audit trail. Every admin-side change to a project’s shape is mirrored as a commit; restoring means feeding a committed bundle back through import().

Bidirectional sync is explicitly out of scope. Two-way reconciliation is error-prone, and the redaction asymmetry — git never has secrets, the platform does — makes true bidirectionality leaky by construction.

The flow runs in two places, and in this order.

1. Connect GitHub — Connections. Click Connect GitHub. You land on the deployment’s GitHub App install page, choose the account or organisation, and pick which repositories to grant. GitHub sends you back and a github connection is created for you, which is what replaces typing an owner and an installationId by hand.

Two details of that round trip:

  • The flow starts at the App’s install page, not at /login/oauth/authorize. The authorize endpoint alone never opens the repository picker, so its callback carries no installation_id — nothing to build a connection from.
  • The callback is necessarily unauthenticated (it comes from github.com), so the signed state is what carries your identity. The redirect is validated against the deployment’s own app URL: an open redirector here would hand an attacker a freshly minted connection.

2. Pick a repository — Environments → Git backup. Choose the connection, then a repository and a branch reachable through it. Repo and branch are stored on the project rather than on the connection: they are usage, and one connection can serve several projects.

If the repository has no commits yet, the panel says the first sync will create the branch being offered rather than pretending it already exists.

  • On demand — POST /api/v1/admin/gitsync/sync, or the button in the panel.
  • Automatically, debounced, whenever an admin action changes the project’s shape. A burst of edits collapses into one commit, and the work happens off the request path.

A sync whose export is byte-identical to the last one commits nothing and reports unchanged — which is the determinism guarantee doing its job.

Git backup needs a GitHub App configured on the deployment (GITHUB_APP_PRIVATE_KEY, plus GITHUB_APP_CLIENT_ID and GITHUB_APP_SLUG). Without it the /control/github routes answer 404 and the panel tells you so; a github connection can still be created by hand.

That App’s private key is a deployment secret: one per install of groveback, never per-project, never exported, and never returned over HTTP.

  • Data. No documents, no files, no embeddings. Local data comes from committed fixtures.
  • Connections. The connection registry holds credentials.
  • Appearance. A project’s color and icon are cosmetic and excluded.
  • BYO Mongo URLs. Never exported, never inherited.

See the bundle format for the exact file layout and document shape.