Hopp til innholdet

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 APIVisitor API
Platform admin, Supabase token (hosted panel)yesany tenant (X-Snakkbar-Tenant)—
Platform admin impersonating an operatornoas that operator, audited—
Minted operator token (customer's UI)noown 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):

ColumnMeaningNULL
planLabel shown to the customer ("Standard", "Pro", …)— (default standard)
monthly_turn_limitAssistant replies per calendar month (Europe/Oslo)unlimited
max_operatorsActive operators (platform admins' own rows do not count)unlimited
max_documentsUploaded files and imported pages (approved answers do not count)unlimited
featuresjsonb 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):

CounterWhere
conversationsPOST /visitor/conversations
visitor_messagesPOST /visitor/conversations/:id/messages (not replays)
operator_messagesPOST /operator/conversations/:id/messages (not replays)
assistant_turnssnakkbar_claim_turn (minus refunds)
assistant_tokens_in/out, assistant_cost_usdsummed from eve's step.completed usage (provider-reported, not estimated; cost only when the provider reports one)
embedding_tokens, documents_indexedknowledge indexing (embedMany usage)
active_visitorsdistinct 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/overviewtotals this month, top tenants, replies per day
GET /platform/tenants · POST · PATCH /:id · DELETE /:id { confirm }tenants
POST /platform/tenants/:id/suspend { reason? } · /unsuspendsuspension
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-linkinvite again or password reset e-mail
POST /platform/operators/:id/impersonate { reason? } → ImpersonationSessionstart
POST /platform/impersonations/:id/endend
GET /platform/admins · POST { email, name } · DELETE /:userIdsystem owners
GET /platform/auditaudit 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.