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.
inviterefuses theclientrole. Customer identities belong toprovision_portal_invitation, which in turn refuses anyone whose role is notclient; 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-inviterefuses a client row for the same reason.is_employeemust 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
peoplerow is deleted before the error is returned. - Invitations, resends, and role changes all write an
auditrow.
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.jsweb/api/environment.js,_environment.jsweb/api/admin-costs.jsweb/api/_usage.js,_pricing.js— theai_usageledger this route reads