Projects & members
The layer above projects: who owns what, who can administer it, and how a platform user’s membership becomes a project-scoped token.
Callers here hold control tokens (aud: "control"), which are never interchangeable with
data tokens.
Data model
Section titled “Data model”Organization 1 ── n Project 1 ── n User (end users of apps built on Groveback) │ │ └── n Membership ┘ (platform users ↔ projects, with a role) │Project 1 ── n Session (per end-user refresh sessions)Ids are prefixed strings generated server-side — org_…, proj_…, user_…, mem_…,
sess_…. A raw ObjectId never crosses the API boundary.
Control-plane collections live in a dedicated groveback_control database; per-project data
lives in that project’s own database, so an export or a delete is a single-database operation.
Projects
Section titled “Projects”curl -X POST "$URL/api/v1/control/projects" \ -H "authorization: Bearer $CONTROL_TOKEN" -H 'content-type: application/json' \ -d '{"name":"acme"}'Add mongoUrl to make it a BYO Mongo project.
| Route | What |
|---|---|
GET /control/projects |
Projects you can see, with your role. |
GET /control/projects/:id |
Detail. |
PATCH /control/projects/:id |
name, maxFileSize, color, icon. |
POST /control/projects/:id/suspend · /resume |
Suspension takes effect immediately — every request returns 403 while suspended, and the data is kept. |
DELETE /control/projects/:id |
Soft delete. Owner only. |
color is a preset slug (not a hex value or class name) and icon is a single emoji.
Cosmetic only, and never part of a config export.
A root project with live environments cannot be deleted — delete the environments first.
Authority comes from membership
Section titled “Authority comes from membership”Never from the user record. Roles are Owner, Admin, Editor, Viewer.
Non-members get 404, never 403 — project existence is never revealed.
Two invariants:
- The last Owner cannot be demoted or removed.
- Granting or revoking the Owner role, in either direction, is Owner-only. Everything else is Owner-or-Admin.
Members and invites
Section titled “Members and invites”curl -X POST "$URL/api/v1/control/projects/$PID/members" \ -H "authorization: Bearer $CONTROL_TOKEN" -H 'content-type: application/json' \ -d '{"email":"dev@acme.com","role":"Editor"}'If the email already has an account, a real membership is created and a notification is sent. If not, a pending invite is stored — idempotent, so re-inviting just refreshes the role — and an invite email goes out.
GET …/members returns active rows first, then pending ones, distinguished by
status: "active" | "pending". Pending invites materialize automatically when that email
registers.
Cancel one with DELETE …/members/pending/:email (URL-encode the address).
Token exchange
Section titled “Token exchange”The bridge between the two audiences:
curl -X POST "$URL/api/v1/control/projects/$PID/token" \ -H "authorization: Bearer $CONTROL_TOKEN"Mints a project JWT (aud: "project", default TTL 15 minutes) whose perms claim comes from
your member role in that project’s role registry. It works only for active projects.
This is how the dashboard talks to a project: log in once at the control plane, then exchange for a project token per project you open.
Environments
Section titled “Environments”An environment is an ordinary project with a parent. Owner-of-the-parent only; no nesting.
curl -X POST "$URL/api/v1/control/projects/$PID/environments" \ -H "authorization: Bearer $CONTROL_TOKEN" -H 'content-type: application/json' \ -d '{"environment":"staging","color":"amber"}'Labels match ^[a-z][a-z0-9-]{1,15}$ and must be unique among a parent’s live environments.
prod and production are reserved — the root project is production.
Parent memberships are copied in, and the parent’s config is cloned best-effort: a clone failure still yields a working environment plus a warning, rather than a failed create. BYO Mongo is never inherited.
Environments do not count toward the maxProjects meter.
Promotion
Section titled “Promotion”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"}'Exports one project’s shape and plans or imports it into a sibling. Requirements: both in the same family, both active, and you must be Owner of both.
It never deletes — resources that exist only in the target are untouched and do not appear
in the plan. The dry run renders before/after fragments per change, so a reviewer sees the
semantic diff, not only the JSON.
There is no plan gate; promotion works with billing disabled.
Connections
Section titled “Connections”A registry of named, credentialed connections to external services, scoped to an
organization (shared across its projects) or to a single project. The first kind is
github.
This is a registry only: it stores and gates credentials, and consumers do the actual I/O.
{ "id": "conn_…", "kind": "github", "scope": { "type": "org", "orgId": "…" }, "name": "acme-github", "config": { "owner": "acme" } }For github: config field owner (the account or org login), secret field installationId
(the GitHub App installation id, treated as a secret at rest).
kind and scope are immutable after create. name is a trimmed label of 1–64
characters, unique per scope (409 on a duplicate).
| Route | What |
|---|---|
GET /control/connections/kinds |
The catalog. Registered before /:id, so kinds is never read as an id. |
GET · POST /control/connections |
List or create org-scoped connections. |
PATCH · DELETE /control/connections/:id |
Update or delete. The gate derives from the stored scope, not the route shape. |
GET · POST /control/projects/:id/connections |
The merged view (project rows first; scope disambiguates), or create a project-scoped one. |
Authorization is membership-based rather than permission-based: any authenticated user manages connections in their own personal organization; project-scoped reads need membership and mutations need Owner or Admin. Anything short of that answers 404.
Secrets are write-only: reads redact to { configured: boolean }, and an update that omits or
blanks a secret field preserves the stored value.
Connections are deliberately excluded from config export — they are credentials.
First-run setup
Section titled “First-run setup”GET /api/v1/control/setup-status is unauthenticated and sits before the auth gate, so the
dashboard can show an install wizard.
Registration is self-service. The first user, and any email holding a pending operator
invite, is promoted to super-admin at registration; everyone else registers as a normal user.
Operators manage invites at /api/v1/control/admin/operators.
Platform operator routes
Section titled “Platform operator routes”Gated on a control token whose platform role is super-admin, and only reachable when
GROVEBACK_OPERATORS is set — unset means they fail closed.
| Route | What |
|---|---|
GET /control/admin/users |
All platform users. |
POST /control/admin/users/:id/plan |
Assign a plan slug — a manual grant, no Stripe. |
PUT · DELETE /control/admin/users/:id/limits |
Pin or clear bespoke per-user limits. |
GET · PATCH /control/admin/plans[/:slug] |
The editable plan catalog. |
GET · POST · DELETE /control/admin/operators |
Manage super-admins. |
GET · PUT /control/admin/cost-model |
The unit-cost assumptions behind the price calculator. |
A manual plan grant has no Stripe customer, so the billing portal is unavailable for it — and Stripe wins: a later real Stripe event overwrites a manual grant.
Billing and limits
Section titled “Billing and limits”Billing is per platform user, never per project and never stored in a project database. A
subscription counts only in status active or trialing; anything else resolves to free.
Limits are enforced per project, using the project owner’s plan — each project gets the full allowance, and usage is never summed across projects.
What is metered: storage bytes, monthly function runs, stored vectors, live realtime connections, max projects, and environments per project.
Every denial message starts with plan limit: , at 403s, 429s, run-log entries and WebSocket
errors alike, so clients can detect quota problems uniformly.
Gauges clamp at 0 on read, so a deployment that enables billing later starts counting from zero rather than reporting negative usage for pre-existing data.