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.
The primitives
Section titled “The primitives”| 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.
Determinism is the point
Section titled “Determinism is the point”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.
No secret ever reaches git
Section titled “No secret ever reaches git”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.
Import semantics
Section titled “Import semantics”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.
The git workflow
Section titled “The git workflow”grove pull # project shape → groveback/git add groveback && git commit -m "add orders collection"# open a PR — the diff reads as the changegrove push # after review, apply itTwo guards make this safe in a team:
pushrefuses if the project moved since your lastpull. Each pull records a sha256 per file in.grove/pull.json; if someone else pushed in the meantime, applying yours would silently clobber theirs.--forceoverrides.--dry-runexits 2 when there are changes, so CI can gate a PR without parsing output:
grove push --dry-run || test $? -eq 2 && echo "config changes pending review"Deleting
Section titled “Deleting”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.
Promoting between environments
Section titled “Promoting between environments”The same machinery, without files:
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.
Git backup
Section titled “Git backup”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.
Connecting a repository
Section titled “Connecting a repository”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 noinstallation_id— nothing to build a connection from. - The callback is necessarily unauthenticated (it comes from github.com), so the signed
stateis 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.
When it commits
Section titled “When it commits”- 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.
Requirements
Section titled “Requirements”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.
What is not config
Section titled “What is not config”- Data. No documents, no files, no embeddings. Local data comes from committed fixtures.
- Connections. The connection registry holds credentials.
- Appearance. A project’s
colorandiconare cosmetic and excluded. - BYO Mongo URLs. Never exported, never inherited.
See the bundle format for the exact file layout and document shape.