Skip to main content

Admin, Environment, and Costs API

Three admin-only routes, grouped because none of them touch the spine directly.

/api/users — user and role management​

Admin-only. Lists the org's people with their real sign-in state, provisions a new internal teammate, and changes a person's role, from admin | lead | consultant | client.

GET -> people with role, is_employee, access_state, last_sign_in_at, last_active_at
POST ?action=set-role -> { person_id, role }
POST ?action=invite -> { name, email, role, is_employee } -> creates the person and mails an invitation
POST ?action=resend-invite -> { person_id } -> re-sends access to someone already on the roster

access_state is active (invitation accepted), invited (identity exists, not yet accepted), or none (no usable identity — this person is on the roster but cannot sign in). It is derived from a bounded sweep of the Supabase Auth directory; when that sweep does not complete, an unresolved person is reported as invited rather than falsely as none.

last_sign_in_at comes from Supabase Auth and only moves when someone enters a password or opens a sign-in link; silent session refreshes do not update it. last_active_at is the last verified API request, stamped by _auth.js at most once per person every five minutes. It is null for anyone who has not used SABLE since the column shipped.

Guardrails:

  • The last admin cannot be demoted — a lockout an admin route must never be able to cause by accident.
  • invite refuses the client role. Customer identities belong to provision_portal_invitation, which in turn refuses anyone whose role is not client; minting a client-role person here would create an account that can sign in and see nothing. The error names the portal flow instead. resend-invite refuses a client row for the same reason.
  • is_employee must be sent explicitly as a boolean. It decides eligibility for delivery ownership, staffing, and availability, and is never inferred from the email domain.
  • Invitations are rate-limited per admin (SABLE_INVITE_RATE_LIMIT, default 12/hour). Role changes are internal writes; an invitation sends mail to an arbitrary address, so it gets a ceiling.
  • A failed invitation leaves no half-provisioned row. If Auth delivery fails without creating an identity, the new people row is deleted before the error is returned.
  • Invitations, resends, and role changes all write an audit row.

Delivery adapts to what already exists for that address: a new address gets inviteUserByEmail; an address that already has an unconfirmed Auth identity gets a recovery link (auth-callback.html shows the same set-a-password form for invite and recovery); an address with a confirmed account is linked to the person row and told to sign in with their existing password.

/api/environment — the Environment tool's metadata bridge​

Admin-only. Reads and writes the env-control-plane tables (env_apps, env_repos, env_platform_targets, env_var_requirements, env_manifest_ingests, and friends) — names and metadata only, never values, mirroring the same discipline the env-manifests/*.json files follow.

GET ?action=summary -> inventory, registry freshness, recorded verification
GET ?action=app&slug=... -> app targets, requirements, recorded deployment state
GET ?action=providers -> provider registry and requirement counts
GET ?action=provider&slug=... -> provider metadata and its requirements
GET ?action=shared -> shared credential metadata
GET ?action=shared&slug=... -> one shared credential and its requirements
GET ?action=audit&limit=...&offset=... -> paginated catalog audit events
GET ?action=sync-preview -> source_hash, registry_hash, registry status, per-app change preview
POST ?action=ingest-all -> { expected_source_hash, expected_registry_hash } -> atomic source sync
POST ?action=preview-manifest -> { manifest } -> one-app change preview with source_hash and registry_hash
POST ?action=ingest-manifest -> { manifest, expected_source_hash, expected_registry_hash } -> atomic one-app sync

sync-preview returns the deployed source hash and per-app missing, changed, current, or obsolete status, with added, removed, and changed requirement names. Send both reviewed hashes as expected_source_hash and expected_registry_hash to ingest-all; a stale source or catalog review returns HTTP 409 and must be refreshed before applying. The batch is atomic. For manual imports, call preview-manifest first and send both returned hashes as expected_source_hash and expected_registry_hash with ingest-manifest. Editing the manifest after preview requires a new preview; a changed catalog also requires reviewing again. A manual import replaces that application’s requirement/target metadata, removes absent entries, and preserves existing observations for retained deployments.

These actions update the metadata catalog only. They do not provision or rotate provider secrets. Refresh reads catalog state; it does not query each provider. Recorded deployment observations are assessed against a seven-day validity window, with unverified state separate from missing or stale observations. A catalog sync must not reset verification timestamps or imply credential health.

See the Env Manager Guide for source validation, generated bundle checks, review/apply workflow, and actual provider verification.

/api/admin-costs — AI and API spend​

Admin/lead only (cost data is treated as sensitive, not just internal). Reads the ai_usage ledger scoped to the viewer's org via the service-role client and aggregates in JavaScript (org-wide, by provider/model/feature/user/day).

GET /api/admin-costs?range=7d|30d|90d|mtd
GET /api/admin-costs?range=30d&format=csv

format=csv streams raw rows for finance rather than the aggregated view — a different shape for a different audience, not a superset/subset of the same data.

Pagination is capped: PAGE = 1000 rows per page, MAX_PAGES = 200 — a hard 200,000-row safety ceiling so a huge range parameter can't run away and exhaust the function's own runtime.

What's captured vs. pending​

captured: chat, weekly_update, vision_ocr, embed, rerank, meeting_extract,
interview_synth, kickoff_intake, drive_sync, agent_session
pending: (none currently)

This COVERAGE constant is the route's own honesty check on itself — it spans web routes, Edge Functions, and the CMA/Opus agent, and agent_session is explicitly noted as best-effort, reconstructed from session events rather than a first-class logged call.

Where the code lives​

  • web/api/users.js
  • web/api/environment.js, _environment.js
  • web/api/admin-costs.js
  • web/api/_usage.js, _pricing.js — the ai_usage ledger this route reads