Preview relay
The preview relay lets you open a dev server that runs on one of your machines
in a browser anywhere, without a VPN and without opening a port. Your helper
dials out to an aya-relay, the hub decides who may open what, and the relay
serves the dev server at an address of its own:
https://5173-kg724cafc4b2t3hz.preview.example.test
5173 is the port the dev server listens on, and kg724cafc4b2t3hz is a label
derived from the worker’s identity. The relay needs its own domain, the hub
mints short-lived tickets, and nothing about your machine is reachable except
the ports the helper agrees to forward.
Examples use hub.example.test, relay.example.test and
preview.example.test. The wire protocol is docs/relay/protocol.md in the
repository, and the security design is docs/relay/threat-model.md.
Use it
-
Your operator runs a relay and turns it on in the hub (Run your own: the preview relay).
-
Start the helper from a build with the
relaycargo feature and add a[relay]section to its config:[relay] url = "wss://relay.example.test" resource = "https://relay.example.test"The helper reuses its OIDC client credentials to request the
relay:connectscope on that resource. Use one OIDC client per worker: two helpers sharing a client share a label and would keep replacing each other. The helper keeps its relay instance id in its data directory (relay-instance-id), so a restarted helper is the same instance to the relay. -
Declare the dev server in the helper config with
[[relay.services]](port = 5173) on an allowed port (default3000-3999,4173,5173-5199and8000-8099), and start it. Setdiscover = trueunder[relay]to offer every listening allowed port instead. Harness sessions on that helper getAYA_PREVIEW_URL,AYA_PREVIEW_HOST,AYA_PREVIEW_DOMAINand__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTSin their environment, so a tool can print the right address. -
Open
https://hub.example.test/api/relay/open?worker=<label>&service=http:5173while signed in to the hub. The hub checks that you may open it and hands your browser to the preview, which sets its own session cookie and sends you to/. Visiting the preview address directly sends you to the same hub route. If you are not signed in, the hub asks you to sign in first and does not remember where you were going, so open the preview address again after signing in.
Most dev servers need one setting to accept the preview host; see the dev server notes below.
Share a preview
POST /api/relay/shares (signed in) creates a link to one worker’s service
that someone else can open:
curl -sS -X POST https://hub.example.test/api/relay/shares \
-H 'Content-Type: application/json' -b cookies.txt \
-d '{"worker":"kg724cafc4b2t3hz","service":"http:5173","ttl_secs":3600,"audience":"org"}'
The response carries url (https://hub.example.test/r/s/<token>), id and
expires_at (unix seconds). audience is org (the visitor signs in to the
hub first) or anyone. anyone is refused unless the operator set
RELAY_ANONYMOUS_SHARES=1. Visitors get a session of at most 12 hours, and
never longer than the share. GET /api/relay/shares lists shares (it never
shows the token again) and DELETE /api/relay/shares/{id} revokes one; the
relay applies a revocation within about 30 seconds. Both only see shares the
caller created; revoking someone else’s share answers 404. A hub operator
(see RelayPolicy::is_operator; on the single-owner hub every user is one) can
add ?all=true to the listing and can revoke any share.
Open a preview from a script
A machine caller (for example a cloud agent that has to look at the page) uses
the hub’s bearer route, with the same client-… credentials the
Remote API uses:
curl -sS -X POST https://hub.example.test/api/remote/v1/relay/tickets \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"worker":"kg724cafc4b2t3hz","service":"http:5173","cap":["open"]}'
The route is a remote API scope: the token needs relay:open on the remote
API resource, its subject must pass the remote API allowlist and must not be
deprovisioned, and each ticket costs one remote read from the same
per-principal budget as the remote API’s reads (429 with Retry-After when
it is spent). The subject must also be in RELAY_API_ALLOWED_SUBJECTS (the
route is closed until that is set). The answer is {ticket, url, expires_at}:
send the ticket to the preview host as Aya-Relay-Authorization: Bearer <ticket> on each request. A bearer ticket lives 5 minutes. The operation is in
the OpenAPI document under the relay tag.
A ticket with control on a stream: service carries a control lease that
the hub mints and returns as lease. Send that value as lease on the next
ticket request to keep the same lease. The hub refuses a lease it did not mint,
a lease minted for another subject or worker, and a revoked lease. After an
operator revokes a control lease, the hub also refuses that subject a new
control lease on that worker for one hour; the subject can still view, and
keeps control of its other workers. To keep a subject away for longer, revoke
the subject or deprovision the principal.
Reservation workers
A worker the hub enrolled for a reservation (its subject starts with
client-w-) authenticates to the relay with tokens from the hub’s own workload
issuer, and its label is derived from that issuer. Only the principal that
requested the reservation may open it, and only through the bearer route
above: a browser session gets 403, whoever is signed in. When the
reservation ends, nobody can open the worker.
An enrolled helper mints its relay token from its own workload key for the
relay’s audience and never uses the machine’s OIDC client. A broken [relay]
section disables the relay on that worker and is logged; it never stops the
worker.
Revocation
Revoking a share, session, worker, subject or control lease reaches every relay replica through the hub’s revocation feed. The hub also adds entries itself: disabling a worker client revokes that worker, and deprovisioning a principal revokes that subject. A subject the hub deprovisioned gets no new ticket on any route, and for one hour after a subject revocation the hub mints that subject nothing. When a viewer logs out of a preview, the relay reports it and the session ends on every replica.
Dev servers
The relay forwards plain HTTP and WebSocket traffic and never rewrites bodies.
The dev server sees a request for <port>-<label>.<preview-domain> and must be
willing to answer it:
- Vite:
server.allowedHosts(the helper already sets__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTSto.preview.example.test) andserver.hmr = { clientPort: 443, protocol: 'wss' }. - Next.js: list the host in
allowedDevOrigins. - Create React App and webpack-dev-server:
allowedHosts: 'all'(or the host) andclient.webSocketURLpointing at port 443 overwss. - If a server insists on
localhost, sethost_mode = "loopback"for its port under[[relay.services]]. The worker then rewritesHostandOrigintolocalhost:<port>. - Absolute
http://localhost:…URLs inside pages and bundles are not rewritten and will fail in the visitor’s browser. Keep them relative.
docs/relay/dev-servers.md has the full recipes and a troubleshooting table.
Limits worth knowing
- Only
httpservices open in a browser in v1; shares are HTTP only. - A visitor session ends after at most 12 hours. The relay checks the hub for revocations every 30 seconds, so revoking is not instant. The hub drops feed entries the relay no longer needs (sessions, leases and shares after 13 hours, workers after 2 hours).
- With a plain-
httphub URL on another machine the helper cannot fetch ticket keys from the hub, so it refusesstream:services; setticket_jwks_urlunder[relay]to anhttpskey URL to enable them. A hub on the same machine (http://127.0.0.1,http://localhost) keeps working over plainhttp. - The preview domain should be on the Public Suffix List, or dedicated to one
organization. Otherwise sibling previews share cookies. See
docs/relay/threat-model.md. - Reservation workers open only for their requester. Every other worker is open to every signed-in hub user, so enable the relay for those only on a hub with one human user.