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 APIpermission_denied, the realtime gatewayERROR_CODE_SUSPENDED, and the client shows the suspended page, without the tenant's branding.tenant.statusisactiveorsuspended, andtenant.deleted_atmarks 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_idthroughdb.WithTenant, and an access token'stidmust equal it (Identity).
Platform and custom domains
- A new tenant gets
<slug>.<VWS_TENANT_DOMAIN>as its primary domain, verified at once, whereVWS_TENANT_DOMAINis 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 setsverified_ator gives up. A domain ispendingwhile the job looks andfailedafter 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_DOMAINbelong 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_TENANTnames, else the only tenant in the database, else a new tenantmain(or the slug ofVWS_TENANT) on the planself-hosted, which caps nothing. A database with several tenants and noVWS_TENANT, or aVWS_TENANTthat 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_URLbecomes 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 createrefuses a second tenant,vws tenant admin --emailneeds 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/tenantanswerstenancy: 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_DOMAINandVWS_DEV_TENANTare refused withsingle, andVWS_TENANTwithmulti.- Switching back to
multikeeps 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:
| Field | Contents |
|---|---|
app_name | the product's name under this brand: page titles, the installed app |
tokens | colours for the client's design tokens, below |
assets | the logo, the loading logo, the favicon, the app icon (512 × 512 PNG) and the login background, as files in object storage |
legal | links to the terms, the privacy policy and the legal notice |
email_from | the 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
- In a room: its world's theme (
world.theme_id). - The theme of the request's host (
domain.theme_id). - The tenant's default: the theme of its primary domain.
- The client's built-in theme.
Pages outside a world, such as the sign-in screens, start at step 2.
Delivery
GET /api/v1/siteanswers the host's tenant name, theme, default locale and the tenant-level flags the pre-game screens need.GET /api/v1/resolvenames the theme of the room, andGET /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(thesurfacetoken), 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.webmanifestbuilds the host's web app manifest from its theme: the app name, thesurfacecolour 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.pngwith rounded corners andmaskable.pngfilling 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 itsapple-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 fromblob:URLs, where Pixi.js decodes images, and connections may readdata: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
| Flag | Levels | Default | Platform feature | Controls |
|---|---|---|---|---|
anonymous | tenant | on | visitors without an account (Identity) | |
guests | tenant, world | off | world invites and meeting links for guests (Identity) | |
blocking | tenant | off | members blocking each other | |
invitations | tenant, world | on | players inviting each other to talk | |
builtInAvatars | tenant | on | the built-in avatar textures beside the tenant's own (Avatars) | |
editor | tenant, world, room | on | editor | the editor in the game, for those allowed to edit |
chat | tenant, world, room | on | chat | proximity chat and persistent channels |
media | tenant, world, room | on | media | audio and video in bubbles and meetings |
screenShare | tenant, world, room | on | media | screen sharing in meetings |
broadcast | tenant, world, room | on | media | broadcasts to speaker and listener areas |
deviceCheck | tenant, world, room | on | media | the camera and microphone check before a visitor's first entry |
recording | tenant, world, room | off | recording | recording bubbles, meetings, stages and broadcasts through LiveKit's egress service; vws seed turns it on for the tenants it creates |
scripting | tenant, world, room | on | scripting | map scripts (Scripting API) |
externalLinks | tenant, world, room | on | websites, embeds and file links that leave the product | |
install | tenant, world, room | on | the screen that offers a returning visitor to install the app before it enters | |
tour | tenant, world, room | on | the tour of a first visit to a world | |
calendars | tenant | on | calendars | connecting 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.
| Tenant | World | Room | Effective |
|---|---|---|---|
| the default | |||
off | on | on | |
on | off | off | |
forbidden | on | on | off |
on | forbidden | on | off |
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"]
}
| Setting | Levels | Default | Meaning |
|---|---|---|---|
features | tenant, world, room | none set | the flags above, for the levels each allows |
locale | tenant, world | en-US | the 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) |
guestLifetime | tenant | 168h, at most 720h | the default period of world guests; an inviter may pick up to 720h (Identity) |
identityProviders | tenant | none | slugs of the providers the tenant offers, in order; none offers every provider available to the tenant, and development always offers development too |
entry | tenant, world, room | asked name and avatar, provided names | how a first visit enters and which name the room shows for an account signed in with an identity provider, as Entry describes |
bubbles | tenant, world, room | distance 64, radius 48, capacity 4 | proximity 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 |
modules | tenant, world, room | none on | the tenant's modules by their ids, on, off or, at the tenant and world levels, forbidden, as Modules describes |
recordings.retentionDays | tenant | 30, from 0 to 3650 | how 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"
}
}
| Field | Values | Default | Meaning |
|---|---|---|---|
name | ask, generated, fixed, numbered | ask | the 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. |
fixedName | 1 to 32 characters, 27 for numbered | none | the name of fixed and numbered, which need it; other names refuse it |
avatar | ask, random, fixed | ask | the avatar of a first visit: picked in the picker; random from the tenant's catalogue; or fixedAvatar. Anything but ask skips the picker. |
fixedAvatar | 1 to 6 texture ids | none | the 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 |
providerName | typed, provided, forced | provided | the 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/usageanswers 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-chargessets that choice:blocked, the default, refuses usage past a limit, andchargedlets it through and charges it, up tocapa 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 astenant.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
Joinpast the limit is refused withERROR_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_failedwithERROR_CODE_PLAN_LIMITeither way. - API calls count every partner API call that passes its key's rate limit; past the limit, calls answer
resource_exhaustedwithRetry-Afteruntil the next month (Partner API). - World invites count as they are sent (Identity); a refused invite answers 429 with
Retry-Afteruntil the next month.
- 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
- 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,invitesandoperatorare 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,inviteorsso) and a capacity, the most players it holds at once. Settings are checked at the world's or the room's level. bubblesshapes 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); andcapacity, 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
nextcursor. - 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, andinvite.createdfor world invites.
Found a mistake? Tell us.