Skip to content

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.

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.

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

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.
Terminal window
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).

The bridge between the two audiences:

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

An environment is an ordinary project with a parent. Owner-of-the-parent only; no nesting.

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

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"}'

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.

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.

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.

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