DokumentasjonSystemeierMarkdown
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_turncounts a turn before it runs (visitor messages and@assistantmessages 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 (codeassistant_limit, the widget says "contact the team"; the callback/ask-for-a-person options stay). A team@assistantmessage 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 firstPOST /server/operator-tokensfor a new person, reactivation) and the panel's «Legg til bruker» answer409 limit_reachedwhen the plan is full. - Documents:
POST /operator/knowledge/documents(file, upload intent, URL) answers409 limit_reachedwhen 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/configanswersavailable: falsewith 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 /visitorsand everything with a visitor token) answers403 tenant_suspended. - Operator API answers
403 tenant_suspended, exceptGET /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»)
POST /platform/operators/:id/impersonate(platform admin's Supabase token; optional reason) records aplatform_impersonationsrow (≤ 1 hour) and mints an operator token with animpclaim{ sid: <row id>, uid: <platform admin's user id> }, signed withSNAKKBAR_TOKEN_SECRET. Impersonating a platform admin (or yourself) is refused.- The admin UI keeps its own client for the system owner and runs on the minted token (kept in
sessionStoragefor 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. - «Avslutt» calls
POST /platform/impersonations/:id/endwith 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.