Section 16

16. The operator surface

/internal/v1/* requires an internal_ key and refuses pk_live_ outright. Customer keys are refused here and internal keys are refused on /v1/*; there is no overlap.

EndpointPurpose
GET /internal/v1/health/fullevery dependency (postgres, OpenObserve, Dokploy) with latency and error. 503 when any fails, for load balancers; ?soft=1 returns the same body with 200 for dashboards, the verdict staying in status (ready | not_ready)
GET /internal/v1/providersevery provider row, enabled or not
PATCH /internal/v1/providers/{name}enable/disable, timeout, capability overrides
`GETPUT /internal/v1/catalog/models/{name}/pricing`
POST /internal/v1/catalog/modelsseed catalog rows
`GETPOST /internal/v1/admin/model-bindings`
POST /internal/v1/admin/byok/resealcarry every sealed BYOK credential onto the current master-key version (rotation step two; needs internal:credentials:reseal)
`GETPOST /internal/v1/admin/byok/platform-credentials`
`PATCHDELETE /internal/v1/admin/byok/platform-credentials/{id}·POST …/{id}/verify`
POST /internal/v1/blueprintsauthor a self-hosting recipe
GET /internal/v1/rtr/providerswhat the router actually registered at boot
POST /internal/v1/db/queryread-mostly SQL
GET /internal/v1/forensics/*orphan pods, instance composites
POST /internal/v1/scenarios/{name}/runend-to-end gates

Operator scopes are separated on purpose: internal:catalog:manage files a model (inert), internal:pricing:manage changes a rate (moves money), internal:providers:manage decides whether an upstream serves at all, internal:credentials:manage decides which upstream accounts the platform spends from. A key trusted with one is not thereby trusted with the others.

Platform keys live in the database, not the compose env. A provider with any platform key row is served from that pool; its api_key_env is only the fallback for a provider with none.

Provider changes apply without a restart. The router fingerprints the provider rows every 30 seconds and hot-swaps its registry when they change; every patch response says applies_within_secs: 30.

Doors for operators managing tenants:

EndpointPurpose
GET /internal/v1/admin/tenants?q=&limit=&offset=every tenant: plan, overrides, organization / project / active-key counts, month-to-date spend in µ¢; q matches name, slug or id. Scope internal:tenants:read
GET /internal/v1/admin/tenants/{tenant_id}one tenant with its organizations, projects, entitlement in force, keys (previews only) and month-to-date spend. Scope internal:tenants:read
DELETE /internal/v1/admin/tenants/{tenant_id}hard-delete a tenant and all of its data in one transaction. Dry run by default (empty body or {"dry_run": true} returns per-table row counts); a real run needs {"dry_run": false, "confirm_slug": "<slug>", "confirmation_token": "<catastrophic token>"}. Refused while an instance is not stopped, and refused naming any tenant table the door does not know. api.audit_log is kept; users are never deleted. Scope internal:db:query
GET /internal/v1/tenant-keys · PATCH /internal/v1/tenant-keys/{id}read a tenant's keys; set a key's scope set (absolute, audited) — the only way to grant keys:write
POST /internal/v1/db/querythe current path for custom_overrides on an entitlement, e.g. {"monthly_credits": null, "max_concurrent_instances": null} for metered-but-unbounded

The operator surface answers on https://ops.opennozzle.com/internal/v1. Use that host: https://api.opennozzle.com/internal/v1 and the plain-HTTP http://api.49-12-240-8.traefik.me still answer on the current server but do not survive the move off it, and an internal_ key on the plain-HTTP host crosses the network in the clear.