Skip to content

Administration

Tenancy

The host of a request selects its tenant. The tenant's themes, settings and feature flags then shape every page and room it serves, and the tenant's id scopes every database transaction of the request.

Hosts

VWS_TENANCY sets how a request finds its tenant (Configuration): multi by its host, as this section describes, and single serves one tenant on every host, as Single-tenant platforms describes. Production names the mode; development defaults to multi. The license the client bought decides which a build that checks licenses may run: multi needs an extended license (Licensing).

  • A host is lowercased, without port and trailing dot, and looked up among the verified rows of domain.
  • Each tenant has one primary domain and any number of aliases; every verified host serves the whole tenant.
  • An unknown host gets a 404 problem, "no site is served at this host", from the API, and a neutral page without branding from the client.
  • A tenant an operator suspended or deleted (Central admin) keeps its hosts, which serve it no more: every endpoint answers 403 with the problem code suspended, the partner API permission_denied, the realtime gateway ERROR_CODE_SUSPENDED, and the client shows the suspended page, without the tenant's branding. tenant.status is active or suspended, and tenant.deleted_at marks a deletion that waits 30 days before a sweep removes the tenant.
  • The resolved tenant id travels in the request's context. Every transaction sets app.tenant_id through db.WithTenant, and an access token's tid must equal it (Identity).

Platform and custom domains

  • A new tenant gets <slug>.<VWS_TENANT_DOMAIN> as its primary domain, verified at once, where VWS_TENANT_DOMAIN is a domain the operator controls.
  • A tenant admin adds a custom domain as unverified, with a random challenge. The admin publishes it as a TXT record at _vws-challenge.<host>; a background job looks every 5 minutes for 7 days, then sets verified_at or gives up. A domain is pending while the job looks and failed after that.
  • Several tenants may claim the same host while it is unverified, so that nobody can block a domain they do not control. The first whose challenge appears keeps it; the other claims fail and their checks stop.
  • Hosts under VWS_TENANT_DOMAIN belong to the platform: a tenant cannot claim them.
  • The certificate is issued once the domain is verified: on a self-hosted platform, Caddy gets it when the host is first visited (Self-hosting).
  • Making a custom domain primary moves the tenant's token issuer, so the next refresh issues tokens for the new host.

Single-tenant platforms

With VWS_TENANCY=single the platform runs for one organisation, with no subdomains, no domain verification and no tenant commands:

  • On start, every process settles the tenant: the one VWS_TENANT names, else the only tenant in the database, else a new tenant main (or the slug of VWS_TENANT) on the plan self-hosted, which caps nothing. A database with several tenants and no VWS_TENANT, or a VWS_TENANT that names none of them, stops the start with a message naming the variable.
  • Every host the platform is reached at resolves to that tenant: its domain, a LAN address, a tunnel.
  • The host of VWS_PUBLIC_URL becomes the tenant's verified primary domain at every start, so it names the issuer of the tenant's tokens and carries its default theme; a former public host stays as an alias.
  • vws tenant create refuses a second tenant, vws tenant admin --email needs no --host. The admin API refuses to add or remove domains or to move the primary one, with 409; a domain's theme still changes. GET /api/v1/admin/tenant answers tenancy: single, and the administration leaves its Domains page out; the Themes page chooses the default theme, the primary domain's, which every host takes.
  • VWS_TENANT_DOMAIN and VWS_DEV_TENANT are refused with single, and VWS_TENANT with multi.
  • Switching back to multi keeps the tenant with its domains, which then route as any tenant's do.

Caching

Each node caches host lookups in memory: up to 10,000 hosts for 60 seconds, and unknown hosts for 10 seconds. Tenants, sites and themes are cached the same way. A change to a tenant, a domain or a theme publishes the tenant's id on vws:topic:tenancy, and every node drops that tenant's entries, its unknown hosts and the issuer of its tokens, and reloads its running rooms of the tenant. Should the message be lost, the entries expire within the minute.

Themes

A theme (theme) holds:

FieldContents
app_namethe product's name under this brand: page titles, the installed app
tokenscolours for the client's design tokens, below
assetsthe logo, the loading logo, the favicon, the app icon (512 × 512 PNG) and the login background, as files in object storage
legallinks to the terms, the privacy policy and the legal notice
email_fromthe sender of the tenant's emails

tokens maps a design token of the client to #rrggbb or #rrggbbaa:

surface, on-surface, canvas, panel, on-panel, danger, muted, accent, on-accent, field, outline, avatar, avatar-self, avatar-outline, avatar-label, status-online, status-away, status-busy, status-silent

Unknown tokens are refused, and tokens a theme leaves out keep the client's values.

Assets

PUT /api/v1/admin/themes/{theme}/assets/{slot} stores an image for one of the slots logo, loadingLogo, favicon, appIcon and loginBackground: PNG, JPEG, WebP, GIF, ICO or SVG, up to 2 MiB, and for the app icon a 512 × 512 PNG. The server names the file after the SHA-256 of its content, stores it under tenants/<tenant id>/themes/ in object storage and serves it at GET /api/v1/theme-assets/<name> on the tenant's hosts, cacheable for a year. SVG images are served with a sandboxing Content-Security-Policy, so an image opened on its own runs nothing. An image counts against the plan's storage once, however many themes use it (Plans and usage), and an image new to the tenant past the limit is refused with 403. Once no theme of the tenant uses an image any more, replaced or removed, it is deleted from storage. Images stored before they counted, by an earlier version, count from their next upload.

The built-in theme

Where no theme applies, the API answers the built-in theme: the nil UUID as its id, the app name VirtualWorkSpaces and no tokens, which keeps the client's own.

Which theme applies

  1. In a room: its world's theme (world.theme_id).
  2. The theme of the request's host (domain.theme_id).
  3. The tenant's default: the theme of its primary domain.
  4. The client's built-in theme.

Pages outside a world, such as the sign-in screens, start at step 2.

Delivery

  • GET /api/v1/site answers the host's tenant name, theme, default locale and the tenant-level flags the pre-game screens need.
  • GET /api/v1/resolve names the theme of the room, and GET /api/v1/themes/{id} delivers it.
  • When the binary serves the client, it writes the host's theme into the page: the title, the favicon, theme-color (the surface token), the manifest link, and the tokens as a style element under the page's CSP nonce. The first paint therefore already has the brand. In a room whose world has its own theme, the client fetches it and sets its tokens on the document.
  • GET /manifest.webmanifest builds the host's web app manifest from its theme: the app name, the surface colour and the app icon. A theme without an app icon installs with the built-in one, two people in a bubble in the client's own colours, which the product draws when a browser first asks for it: GET /api/v1/app-icons/any.png with rounded corners and maskable.png filling its square, for devices that cut icons to a shape of their own, both 512 × 512 and cached for a day. The page links the app icon, or the maskable built-in one, as its apple-touch-icon.
  • The page's Content-Security-Policy allows scripts and stylesheets only from the page's origin, plus the style element of its nonce, and WebAssembly ('wasm-unsafe-eval', which compiles WebAssembly and evaluates no script text), which the camera's background effects and the microphone's noise filter run on. Images, media and connections may reach any https origin, such as the object storage that map files redirect to; workers may come from blob: URLs, where Pixi.js decodes images, and connections may read data: URLs, which those workers fetch to find out whether they can decode. Frames may show the page's origin and any https page, as the websites, embeds and files of a map's areas do (Map format). On a development platform, frames and images may also use http, so that module authors can serve their pages and icons from their machines (Extensions). Websites and embeds open in a sandbox that keeps them from navigating the client away; files do not, because browsers refuse to show a PDF in a sandboxed frame.

Caching

Theme responses carry an ETag derived from theme.updated_at and Cache-Control: no-cache, so clients revalidate cheaply on every load. Theme assets are stored under content-hashed names and served as immutable. Nodes cache themes in memory and drop them on vws:topic:tenancy, so an edited theme shows on the next page load without a deploy.

Feature flags

FlagLevelsDefaultPlatform featureControls
anonymoustenantonvisitors without an account (Identity)
gueststenant, worldoffworld invites and meeting links for guests (Identity)
blockingtenantoffmembers blocking each other
invitationstenant, worldonplayers inviting each other to talk
builtInAvatarstenantonthe built-in avatar textures beside the tenant's own (Avatars)
editortenant, world, roomoneditorthe editor in the game, for those allowed to edit
chattenant, world, roomonchatproximity chat and persistent channels
mediatenant, world, roomonmediaaudio and video in bubbles and meetings
screenSharetenant, world, roomonmediascreen sharing in meetings
broadcasttenant, world, roomonmediabroadcasts to speaker and listener areas
deviceChecktenant, world, roomonmediathe camera and microphone check before a visitor's first entry
recordingtenant, world, roomoffrecordingrecording bubbles, meetings, stages and broadcasts through LiveKit's egress service; vws seed turns it on for the tenants it creates
scriptingtenant, world, roomonscriptingmap scripts (Scripting API)
externalLinkstenant, world, roomonwebsites, embeds and file links that leave the product
installtenant, world, roomonthe screen that offers a returning visitor to install the app before it enters
tourtenant, world, roomonthe tour of a first visit to a world
calendarstenantoncalendarsconnecting Google and Microsoft calendars, the day's meetings and tasks in rooms, and meeting reminders (Extensions); the platform offers it where it has an OAuth client of Google or Microsoft and VWS_ENCRYPTION_KEY, and in development, where the development calendar stands in for them

A flag holds on or off, and at the tenant and world levels also forbidden. The effective value walks from the default through the tenant, the world and the room: each level that sets the flag replaces the value, and forbidden turns it off for good, so no level below can turn it back on.

Two limits apply on top. VWS_FEATURES is the platform's ceiling: a flag whose platform feature the operator left out is off everywhere. screenShare, broadcast, recording and deviceCheck also need media. Plans limit usage (Plans and usage), not flags: every tenant has every flag.

TenantWorldRoomEffective
the default
offonon
onoffoff
forbiddenononoff
onforbiddenonoff

The room snapshot and GET /api/v1/resolve carry the room's effective flags in features: editor, chat, media, screenShare, broadcast, deviceCheck, recording, scripting, externalLinks, install and tour, and also invitations and blocking, which only the world and the tenant set. GET /api/v1/site carries every flag at the tenant level. POST /api/v1/auth/anonymous answers 403 while anonymous is off.

Settings

Settings live in the settings column of tenant, world and room:

{
  "features": { "recording": "forbidden", "guests": "on" },
  "locale": "en-US",
  "guestLifetime": "168h",
  "identityProviders": ["acme-sso", "google"]
}
SettingLevelsDefaultMeaning
featurestenant, world, roomnone setthe flags above, for the levels each allows
localetenant, worlden-USthe default language: the client's for visitors whose browsers ask for none it speaks, and the language of the world's invites and meeting links (Languages)
guestLifetimetenant168h, at most 720hthe default period of world guests; an inviter may pick up to 720h (Identity)
identityProviderstenantnoneslugs of the providers the tenant offers, in order; none offers every provider available to the tenant, and development always offers development too
entrytenant, world, roomasked name and avatar, provided nameshow a first visit enters and which name the room shows for an account signed in with an identity provider, as Entry describes
bubblestenant, world, roomdistance 64, radius 48, capacity 4proximity bubbles: how close in map pixels two players stand to start one and how far from its centre its members keep, each from 16 to 320, and how many share one, from 2 to 16; a level that leaves a field out keeps the value of the level above
modulestenant, world, roomnone onthe tenant's modules by their ids, on, off or, at the tenant and world levels, forbidden, as Modules describes
recordings.retentionDaystenant30, from 0 to 3650how many days the tenant keeps a completed recording before a sweep deletes it with its file, every hour; 0 keeps recordings until somebody deletes them, and a change applies to the recordings kept already (HTTP API)

Writes are validated against this list: unknown keys, flags set at a level they do not allow, and malformed values are refused.

Modules

modules turns the tenant's modules (Extensions) on and off as features does its flags, under the module ids their manifests give: each level that sets a module replaces its value, and forbidden turns it off for every level below. A module is off where no level turns it on, and everywhere while the platform's VWS_FEATURES does not name modules. A setting may name a module the tenant does not have, which does nothing. GET /api/v1/resolve lists the manifests of the modules a room turns on.

Entry

entry decides what a room asks a first visit without an account, and which name it shows for an account signed in with an identity provider:

{
  "entry": {
    "name": "numbered",
    "fixedName": "Guest",
    "avatar": "fixed",
    "fixedAvatar": ["body-5", "clothes-2", "hair-4"],
    "providerName": "forced"
  }
}
FieldValuesDefaultMeaning
nameask, generated, fixed, numberedaskthe display name of a first visit: asked on the name screen; generated by the client, such as Visitor 4821 in the visitor's language; fixedName; or fixedName, a space and four random digits. Anything but ask skips the name screen where the tenant's anonymous flag is on.
fixedName1 to 32 characters, 27 for numberednonethe name of fixed and numbered, which need it; other names refuse it
avatarask, random, fixedaskthe avatar of a first visit: picked in the picker; random from the tenant's catalogue; or fixedAvatar. Anything but ask skips the picker.
fixedAvatar1 to 6 texture idsnonethe avatar of fixed, which needs it, as Avatars names textures; textures that leave the catalogue are passed over, and an avatar left without a body is dressed at random
providerNametyped, provided, forcedprovidedthe name of an account signed in with an identity provider: typed keeps the name the visitor typed, as the name screen asks for one before a sign-in too and a new account takes it; provided gives a new account the provider's name unless the visitor entered under a name first; forced shows the provider's name of the latest sign-in in the room, whatever the account's own (Identity)

A level replaces the fields it sets, the room last; a level that sets name or avatar sets the fixed value that goes with it, so a room that asks again drops its world's fixed name. The client reads the room's resolved entry in GET /api/v1/resolve; the room shows the forced names to players who join after a change, as with its other settings.

Admin interface

The administration endpoints under /api/v1/admin answer only the tenant's admins: registered accounts whose app_user.role is admin; others get 403. On a multi-tenant platform, operators create tenants in the central admin and invite their first admins by email (Central admin). On the command line, an operator grants the role with vws tenant admin --host <host> --email <address>, without --host on a single-tenant platform, once the person has signed in, and creates tenants with vws tenant create --slug <slug> --name <name>, which gives them their platform subdomain (Operations).

The tenant administration pages, at /admin of every host of the tenant, expose:

  • the tenant's name and slug, and its plan as read-only;
  • domains: adding one with the TXT record to publish, its verification state, the primary choice, the theme of each host, and removal;
  • themes: app name, tokens with a live preview, assets, legal links and email sender;
  • background images that visitors show behind them on camera;
  • feature flags at the tenant level, and at the world and room levels in their own settings;
  • modules: adding one by the address of its manifest, approving a newer version that asks for more, removing it, and turning it on in the settings of the tenant, a world or a room (Extensions);
  • the other settings above, including the identity providers on offer;
  • the bans of each world, which an admin lifts;
  • the audit log of every change to these, all of it or one family of actions at a time.

GET /api/v1/admin/flags describes the flags to these pages, in the order of the table above: for each, the levels whose settings set it, its default, whether the platform offers its feature through VWS_FEATURES, and the flag it requires, such as media for recording.

Every change writes an audit_event in the same transaction, such as tenant.updated, domain.added, domain.made_primary, domain.theme_changed, domain.removed, theme.created, theme.updated, theme.asset_changed, theme.deleted, module.added, module.approved and module.removed, with the admin and their IP address; the verification job writes domain.verified and domain.verification_failed without an actor. Each change also publishes on vws:topic:tenancy.

Plans and usage

Every tenant is on a plan, which limits what it uses and prices usage past a limit (Central admin). The plan development limits nothing, and neither does self-hosted, the plan of a single-tenant platform's tenant.

  • GET /api/v1/admin/usage answers the tenant's month so far: its plan, and for each metric what it used, the monthly peak of people online, rooms and storage or the total of the others, against the limit and price, with the units past the limit and their charge; then the month's extra charges and the tenant's choice of extra charges.
  • PUT /api/v1/admin/usage/extra-charges sets that choice: blocked, the default, refuses usage past a limit, and charged lets it through and charges it, up to cap a month in minor units of the plan's currency. Usage that would take the month's charges past the cap is refused, as is usage past a limit without a price. The change goes to the audit log as tenant.extra_charges_changed.
  • Each limit applies where its usage grows, and a refusal names the limit, or the cap, in the visitor's language:
    • People online are the accounts a tenant has in its rooms at once, across the platform's processes; an account in several rooms or tabs counts once, and guests count too. A Join past the limit is refused with ERROR_CODE_PLAN_LIMIT, and the client offers to try again. The statement bills the month's peak.
    • Rooms are the rooms the tenant's worlds hold; creating one past the limit answers 403.
    • Storage is the files the tenant keeps: map packages, avatar and entity textures, virtual backgrounds, theme images, notice sounds, chat attachments and recordings. An upload that would take it past the limit answers 403, a map package that replaces another counting only what it adds; a chat attachment is checked once it is stored, and deleted again when it is refused.
    • Recording minutes count each recording's length, rounded up to whole minutes when it ends. A recording does not start while the storage is past its limit or no minute is left, and stops when the minutes it was allowed run out; its recorder receives recording_failed with ERROR_CODE_PLAN_LIMIT either way.
    • API calls count every partner API call that passes its key's rate limit; past the limit, calls answer resource_exhausted with Retry-After until the next month (Partner API).
    • World invites count as they are sent (Identity); a refused invite answers 429 with Retry-After until the next month.
  • Every 15 minutes the platform records the month's peaks of rooms and storage, and emails every admin of the tenant, in their language, when a metric reaches 80 % and 100 % of its limit, and when the month's extra charges reach 80 % and 100 % of the cap: once a month for each, saying what happens past the limit. A mail that fails is sent again on the next round. The platform's billing webhook hears of the same usage and extra charges, and of the limits reached (Central admin).

Worlds and rooms

Tenant admins manage worlds under /api/v1/admin/worlds and their rooms under /api/v1/admin/worlds/{world}/rooms and /api/v1/admin/rooms/{room}:

  • Slugs are lowercase letters, digits and dashes, unique among a tenant's worlds and among a world's rooms. The world slugs admin, api, app, assets, auth, invites and operator are reserved, because the client and the server use those first path segments themselves.
  • Names have 1 to 100 characters. A world may name one of the tenant's themes and a capacity, the most accounts its rooms hold at once; a room names one of the tenant's maps, its policy (public, members, invite or sso) and a capacity, the most players it holds at once. Settings are checked at the world's or the room's level.
  • bubbles shapes proximity bubbles at any level: distance, the pixels at which two players standing still start one (16 to 320, default 64); radius, how far from its centre its members keep (16 to 320, default 48); and capacity, how many share one (2 to 16, default 4). A level replaces the fields it sets, the room last.
  • Running rooms take a change of their tenant, their world or their own at once. A new map or new features ask their players to load the room again (NOTICE_KIND_REFRESH_REQUIRED); other changes, such as a capacity or the bubbles, apply silently, the bubbles at once and the rest to the next join. The players of a deleted room hear so (NOTICE_KIND_MAP_DELETED) and may stay until they leave, while nobody enters it again.
  • Lists come in pages ordered by slug, 50 by default and 100 at most, with an opaque next cursor.
  • Deleting a world deletes its rooms. Their slugs become free at once; their rows stay, so that their chat history and audit records keep their subject.
  • Every change writes an audit_event: world.created, world.updated, world.deleted, room.created, room.updated, room.deleted, and invite.created for world invites.

Found a mistake? Tell us.