Volter World

Self-host the platform

Run the whole product on your own machines: a host that serves your team's Worlds, and the platform in front of it, where people sign in with the identity provider you already use, make orgs, invite each other and open Worlds as themselves.

This page is executed as written by packages/cli/src/journeys/tutorials.test.ts.

Three processes, each usable without the next: the World your app runs against, a host (volter-host) that serves many Worlds under one address, and the platform (volter-platform), which knows the people. The platform reaches Worlds only through the host's own endpoints, and the host trusts the platform's short-lived passes, so no World's token ever reaches a browser. Nothing here bills: a self-hosted platform limits and prices nothing.

Your identity provider

The platform signs people in through one provider. Register a web application for it in your provider's console, with this callback:

http://127.0.0.1:4700/-/sign-in/callback

The provider gives back an issuer, a client id and a client secret. Hand them to the platform in its environment:

ProviderNamesIssuer
Google, Okta, Microsoft Entra, Keycloak, any OpenID Connect issuerOIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_NAME for the sign-in buttonhttps://accounts.google.com, https://<you>.okta.com, https://login.microsoftonline.com/<tenant>/v2.0
GitHub, or GitHub Enterprise ServerGITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, with --provider github; GITHUB_URL for an Enterprise Server—
Volter IdentityVOLTER_CLIENT_ID, VOLTER_CLIENT_SECRET, with --provider volterhttps://id.volter.ai

The platform reads them from its environment, not from a file. In the shell that starts it:

export OIDC_ISSUER=https://accounts.google.com
export OIDC_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
export OIDC_CLIENT_SECRET=GOCSPX-…

(This page's runner sets these three to a stand-in provider of its own before the steps below.)

A person joins an org they were invited to when they sign in with the invited address, and only an address the provider says it verified counts. Microsoft Entra sends no email_verified, and a tenant's admins can give a user any address, an outside one included: name the domains your tenant has verified, OIDC_TRUST_EMAIL_DOMAINS=acme.com,acme.co.uk, and only addresses in them count. Leave it unset for an issuer that says which addresses it verified.

With an OpenID Connect provider or GitHub, the platform keeps its own directory of people, orgs, members and invitations in its database, platform.db in its state directory. With Volter Identity they live at id.volter.ai, which mails each invitation; a person who signs in to the platform without opening the mail sees the invitation on their first page and joins with Join.

The host

The host's directory holds the host, the dashboard (a World's pages) and the twins its Worlds use, and one World per <org>/<world>. Start it trusting the platform's passes: a member the platform signs in opens a World on its own address, as themselves.

npm install -g @volter/world
mkdir -p ../worlds && cd ../worlds

Give this directory its own package manifest so npm installs the host and its binary here.

../worlds/package.json
{ "name": "shared-worlds", "private": true, "type": "module" }
npm install @volter/world-host @volter/world-console @volter/twin-github
volter world init --bare acme/team --twins github --world acme/team
./node_modules/.bin/volter-host serve --dir . --port 4701 --trust http://127.0.0.1:4700 &
host ready  http://127.0.0.1:4701  1 world
admin token  tok_a_

The platform

The platform keeps its state (its sessions, its directory, the key it signs passes with) in one directory. It prints its address, which provider people sign in with, and its operator token, which opens its operator endpoints and nothing a person does.

mkdir -p ../platform && cd ../platform
../platform/package.json
{ "name": "world-platform", "private": true, "type": "module" }
npm install @volter/world-platform
export OIDC_NAME="Acme SSO"
./node_modules/.bin/volter-platform serve --dir ./state --port 4700 --provider oidc &
platform ready  http://127.0.0.1:4700  (0 hosts enrolled; sign-in oidc; no billing)
operator token  tok_op_

Enroll the host: the platform checks that the host's admin endpoints answer the token it is given, and makes every new World there.

curl -s -X POST http://127.0.0.1:4700/-/hosts -H "x-volter-token: $(cat state/admin)" -H 'content-type: application/json' -d "{\"id\":\"local\",\"base\":\"http://127.0.0.1:4701\",\"adminToken\":\"$(cat ../worlds/.volter-host/admin)\"}"
"id":"local"

The platform says who people continue with, and sends a sign-in to your provider:

curl -s http://127.0.0.1:4700/-/platform
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:4700/-/sign-in
"provider":{"kind":"oidc","name":"Acme SSO"}
"billing":false
302 http://127.0.0.1:

Sign in, make an org, invite your team

Open http://127.0.0.1:4700/ and choose Continue with Acme SSO. Your provider signs you in and sends you back; with no org yet, the platform opens on Create an org. Name it acme: an org's name is its address (http://127.0.0.1:4700/acme) and the first part of its Worlds' names.

Under Members, add a teammate by email. Someone who has signed in here before is added at once; anyone else is invited, and joins the moment they sign in with that address, once your provider has verified it. The platform emails the invitation only when it can send mail: a Resend key in RESEND_API_KEY and a sender that account has verified in --mail-from. Without them, as on this page, the invitation is written to the platform's log instead, and you tell your teammate yourself.

Make a World and open it

On the org's Worlds, choose Create World, name it staging with the vendors your app uses, and the platform makes it on the host. Open sends you to the World's own address, http://staging--acme.localhost:4701, signed in as yourself with a two-minute pass: its vendors' screens, its branches, what your app did. Its one link out, ← acme's Worlds, comes back here.

The World acme/team the host already served has no org on the platform yet. Once the acme org exists, its operator claims it, having checked that the org owns it:

curl -s -X POST http://127.0.0.1:4700/-/operator/claim -H "x-volter-token: $(cat state/admin)" -H 'content-type: application/json' -d '{"world":"acme/team","org":"acme","confirm":true}'

Its apps keep the token they already hold.

On a real address

Behind a proxy, give each process the address people reach it at: volter-platform serve --url https://app.example.com, and volter-host serve --url https://worlds.example-worlds.com --world-origins worlds.example-worlds.com --trust https://app.example.com, with a wildcard DNS record and certificate for *.worlds.example-worlds.com so each World has an origin of its own. Give the Worlds a registrable domain of their own, as github.com gives its users' content githubusercontent.com: a World's page runs the vendors' screens and whatever the app stored, and on the platform's own domain it would be same-site with the platform. The host says so when they share one, and refuses World origins over plain http except on this machine. Register the provider's callback at the platform's real address. The CLI reference has every flag and environment name of both commands (mail, backups, a Help form's address); the platform API has the platform's endpoints.

With Docker Compose

deploy/self-hosted runs the same two processes from one image built from the repository: the platform at localhost:4000, which enrolls the host on its first start, and the host at localhost:4001, each World on <world>--<org>.localhost:4001. Copy its .env.example to .env, fill in your provider's three values, register http://localhost:4000/-/sign-in/callback with the provider, and from that directory:

docker compose up --build

The platform's log prints its operator token on the first start. State lives in two named volumes: the platform's (its database, signing key and operator token) and the host's (every World, and the admin token the platform enrolled it with).

To upgrade, pull the repository and run docker compose up -d --build again: both volumes stay, the platform applies its database's new migrations as it starts, and the host keeps its token, so the platform still reaches it and enrolls nothing twice. Back up first, with the platform stopped so its database is consistent (and the host's volume the same way, from host and /app/worlds):

docker compose stop platform
docker compose run --rm --no-deps -T --entrypoint tar platform czf - -C /app/state . > platform-state.tgz
docker compose start platform

On a real address the platform also backs itself up nightly to an S3 bucket (--backup-bucket, in the compose file's command).

Stop both

Both were started from their package's own command rather than through npx, so stopping the job stops the server itself (on Windows, npx leaves the server running when its job ends). Their state stays in ../worlds and ./state, and starting them again brings everything back.

kill %2 %1
View Markdown source

On this page