# System owner ("Systemeier"): plans, usage, suspension, impersonation

Snakkbar's own staff (platform admins, rows in `platform_admins`) run every customer from the
**Systemeier** view of the hosted panel (`/panel`). It is only shown to, and its API only answers,
a platform admin signed in with Supabase Auth.

## Who can do what

| Caller | `/platform/*` | Operator API | Visitor API |
| --- | --- | --- | --- |
| Platform admin, Supabase token (hosted panel) | yes | any tenant (`X-Snakkbar-Tenant`) | — |
| Platform admin impersonating an operator | **no** | as that operator, audited | — |
| Minted operator token (customer's UI) | no | own tenant | — |
| Secret API key (`sk_snakkbar_…`) | no | — (server API) | — |

`authPlatform` (server/auth.ts) refuses minted tokens, visitor tokens and API keys outright,
verifies the Supabase token, checks `platform_admins` on every request (no cache, so removing an
admin takes effect at once) and only accepts browser calls from Snakkbar's own origin.

## Plans and limits

Per tenant, set in Systemeier › Kunder (columns on `tenants`):

| Column | Meaning | `NULL` |
| --- | --- | --- |
| `plan` | Label shown to the customer ("Standard", "Pro", …) | — (default `standard`) |
| `monthly_turn_limit` | Assistant replies per calendar month (Europe/Oslo) | unlimited |
| `max_operators` | Active operators (platform admins' own rows do not count) | unlimited |
| `max_documents` | Uploaded files and imported pages (approved answers do not count) | unlimited |
| `features` | `jsonb` map of flags; a missing key means **on** | — |

Existing tenants get `NULL` limits, so nothing changes for them until a limit is set. New
tenants created in the panel start at 2 000 replies, 5 people and 100 documents (editable).

**Enforcement**

- *Assistant replies*: `snakkbar_claim_turn` counts a turn before it runs (visitor messages and
  `@assistant` messages from the team). Over the monthly limit, or over the per-day abuse cap
  (`max(500, monthly / 4)`, or 2 000 a day without a monthly limit — what used to be hard-coded),
  the visitor gets the usual graceful answer (code `assistant_limit`, the widget says "contact the
  team"; the callback/ask-for-a-person options stay). A team `@assistant` message is stored but the
  assistant stays quiet. A claimed turn that produced nothing (the assistant failed) is refunded.
  Admins see a banner when the month is used up and the numbers in Settings › «Bruk denne måneden».
- *Operators*: provisioning (`PUT /server/operators/:externalId`, the first
  `POST /server/operator-tokens` for a new person, reactivation) and the panel's «Legg til
  bruker» answer `409 limit_reached` when the plan is full.
- *Documents*: `POST /operator/knowledge/documents` (file, upload intent, URL) answers
  `409 limit_reached` when full.

**Feature flags** (`server/plan.ts` `FEATURES`): `liveVisitors`, `proactiveChat`, `translations`,
`callbacks`, `serverApi`, `webhooks`, `notifications`. Enforced today: `liveVisitors` (public
config and `/visitor/activity`) and `serverApi` (API keys are refused). The others are there for
the features that will read them: `hasFeature(tenant.features, "name")`. Unknown keys are kept, so
a new feature can store its own flag without a migration. The operator API exposes the resolved
map as `TenantSettings.features`.

## Usage metering

`tenant_usage` has one row per tenant per day (Europe/Oslo), incremented atomically by
`snakkbar_meter(tenant, counters, visitorId)` (fire-and-forget after the response; metering never
breaks a chat):

| Counter | Where |
| --- | --- |
| `conversations` | `POST /visitor/conversations` |
| `visitor_messages` | `POST /visitor/conversations/:id/messages` (not replays) |
| `operator_messages` | `POST /operator/conversations/:id/messages` (not replays) |
| `assistant_turns` | `snakkbar_claim_turn` (minus refunds) |
| `assistant_tokens_in/out`, `assistant_cost_usd` | summed from eve's `step.completed` usage (provider-reported, not estimated; cost only when the provider reports one) |
| `embedding_tokens`, `documents_indexed` | knowledge indexing (`embedMany` usage) |
| `active_visitors` | distinct visitors per day: a new browsing session, a conversation or a message |

Not metered: the embedding of the search query inside the assistant's knowledge tool, the
translation calls and portrait generation.

`GET /operator/usage` (admins) returns the month with limits and states (`ok`, `warning` from 80 %,
`reached`). The per-day visitor sets (`tenant_usage_visitors`) are pruned after 40 days by the
cleanup cron; the counters are kept.

## Suspension

`tenants.suspended_at` (+ `suspended_reason`, shown to system owners only). While set:

- `GET /tenants/:id/config` answers `available: false` with nothing else of note; the widget renders
  nothing and does not create a visitor or track. It reappears on the next page load after reopening.
- Visitor API (`POST /visitors` and everything with a visitor token) answers `403 tenant_suspended`.
- Operator API answers `403 tenant_suspended`, except `GET /operator/me` (so the admin can explain),
  and except platform admins and impersonation sessions. The admin shows a "Kontoen er stengt"
  screen to the tenant's people and a red banner to system owners.
- Server API (API keys) answers `403 tenant_suspended`.

Tenant settings are cached for 60 s per server instance, so a suspension reaches every instance
within a minute.

## Tenants and people

Systemeier › Kunder: create (id slug, name, sites, origins, assistant name, plan, limits, features,
first admin), edit, suspend/reopen, delete (type the id; files in the `documents`, `attachments`
and `avatars` buckets are removed first, then the row cascades) and open (the tenant switcher).

Systemeier › Brukere: every operator across tenants, add a person to a tenant, change role,
deactivate/reactivate (deactivation leaves open conversations, as the server API does), send a
sign-in link, «Logg inn som», and the list of system owners (add; remove, never the last one —
removing one also deactivates their operator rows, which mostly exist because they opened tenants).

**Invites and password links.** A new e-mail address gets a Supabase invite
(`auth.admin.inviteUserByEmail`); «Send innloggingslenke» re-sends the invite to someone who never
signed in, otherwise a password reset e-mail. Both land on `/panel/set-password`, which signs the
person in from the link and lets them choose a password. Links are **only e-mailed**, never shown
in the panel: a system owner who could read them could sign in as the person outside the audited
impersonation.

## Impersonation («Logg inn som»)

1. `POST /platform/operators/:id/impersonate` (platform admin's Supabase token; optional reason)
   records a `platform_impersonations` row (≤ 1 hour) and mints an operator token with an `imp`
   claim `{ sid: <row id>, uid: <platform admin's user id> }`, signed with `SNAKKBAR_TOKEN_SECRET`.
   Impersonating a platform admin (or yourself) is refused.
2. The admin UI keeps its own client for the system owner and runs on the minted token (kept in
   `sessionStorage` for the tab) until «Avslutt» or expiry. A black banner says
   "Du er logget inn som X (Kunde) — Avslutt" the whole time; the tenant switcher, sign-out and the
   Systemeier view are hidden.
3. «Avslutt» calls `POST /platform/impersonations/:id/end` with the system owner's own token and
   returns to the Systemeier view.

**Unforgeable.** Only the platform routes pass an `imp` claim to `mintToken`; customers' API keys
cannot. Every request with an impersonation token re-checks the row (not ended, not expired, same
operator, tenant and actor) and that the actor is still a platform admin, so ending a session or
removing an admin stops the token at once, whatever its `exp` says. The claim's lifetime is checked
too (≤ 1 hour).

**Attribution and audit.** Requests are made *as the operator* (they see what the operator sees and
messages appear under the operator's name), but every change (any non-GET except heartbeats, typing,
read markers and live-view renewals) is written to the audit log with the platform admin as actor
before it runs; if the log cannot be written, the request is refused. Messages sent while
impersonating carry `meta.impersonatedBy = { userId, email, sessionId }` (never sent to the
visitor or exposed by the API; it is in the database and the audit log has the request).
System notices that joining writes ("Ola ble med i samtalen") are attributed to the operator.

**Presence.** Heartbeats from an impersonation session are ignored and `available` cannot be
changed, so the real person never appears online. Typing indicators are shown as the operator.

## Audit log

`platform_audit` (append-only: updates are blocked by a trigger; no foreign keys, so entries
outlive tenants and users): `tenant.create|update|suspend|unsuspend|delete`,
`operator.add|update|deactivate|reactivate|login_link`, `platform_admin.add|remove`,
`impersonation.start|end|request`. Each has actor (user id, e-mail), tenant, target, impersonation
id and details (changed fields with before/after, reason, method and path). Systemeier › Logg lists
it newest first with filters by kind and actor; `GET /platform/audit?action=tenant.&tenantId=&actor=&before=`.

## API

All under `/api/v1/platform`, platform admin only; SDK: `createOperatorClient(…).platform`.

| | |
| --- | --- |
| `GET /platform/overview` | totals this month, top tenants, replies per day |
| `GET /platform/tenants` · `POST` · `PATCH /:id` · `DELETE /:id` `{ confirm }` | tenants |
| `POST /platform/tenants/:id/suspend` `{ reason? }` · `/unsuspend` | suspension |
| `GET /platform/users?q=&tenantId=` · `POST` `{ tenantId, email, name, role }` | people |
| `PATCH /platform/operators/:id` `{ role?, name?, deactivated? }` | change a person |
| `POST /platform/operators/:id/login-link` | invite again or password reset e-mail |
| `POST /platform/operators/:id/impersonate` `{ reason? }` → `ImpersonationSession` | start |
| `POST /platform/impersonations/:id/end` | end |
| `GET /platform/admins` · `POST` `{ email, name }` · `DELETE /:userId` | system owners |
| `GET /platform/audit` | audit log |

## Setup

- Migration `supabase/migrations/20261017150000_platform.sql` (applied on push to main).
- Supabase Auth › URL configuration: add `https://<snakkbar host>/panel/set-password` (and preview
  hosts) to the redirect allow list, and configure SMTP so invites and reset e-mails are delivered.
  The "Invite user" and "Reset password" templates may stay as they are.
- The first platform admin is still added by hand: `insert into platform_admins (user_id, display_name) values (…)`.
- No new environment variables.
