Authority and tickets
The authority is the part of Freehop that your backend embeds. It owns each room's secret, issues a ticket to every member your application admits, and rotates the secret when a member is removed. It never sees media and never needs to: it only hands out keys.
import { createAuthority } from 'freehop/authority';
const authority = createAuthority({
app: 'my-app',
gates: ['wss://gate-1.example.com/freehop', 'wss://gate-2.example.com/freehop'],
gateTokenSecrets: {
'wss://gate-1.example.com/freehop': process.env.FREEHOP_GATE_1_TOKEN_SECRET,
'wss://gate-2.example.com/freehop': process.env.FREEHOP_GATE_2_TOKEN_SECRET
},
stun: ['stun:gate-1.example.com:3478']
});
createAuthority(options)
| Option | Default | Meaning |
|---|---|---|
app | required | Your application's name (at most 64 characters). It namespaces room tags and keys, so two applications never collide. |
gates | required | One to eight gate URLs: wss:// (or ws:// for local tests) for Freehop gates, bt+wss:// for public WebTorrent trackers. |
gateTokenSecrets | {} | Map from exact gate URL to its signing key. Only configured gates receive a token, bound to both room and audience. |
gateTokenSecret | none | Fallback signing key for all WebSocket gates; use the per-URL map when mixing private and community gates. Trackers never receive gate tokens. |
stun | [] | Up to four application-approved STUN URLs. Clients ignore STUN URLs supplied by gates. |
turn | none | Optional TURN servers [{ urls, username, credential }] for the opt-in relay rung, or a function ({ roomId, member, expires }) => servers (sync or async) that mints short-lived credentials per ticket. Up to two servers with one to eight turn:/turns: URLs each. |
ticketTtlSeconds | 21600 (6 h) | Lifetime written into each ticket and its gate token. |
maxRooms | 10000 | Upper bound on open rooms. |
The authority keeps its rooms in memory, in the process that created it. Creation, ticket issuance, rotation and closure run in order for each room; await these methods.
Methods
| Method | Returns | What it does |
|---|---|---|
openRoom(roomId) | Promise of { roomId, tag, epoch, members } | Creates the room and its secret (32 random bytes). Idempotent; members is a count. Never returns the secret. |
ticket(roomId, member?) | Promise of a ticket | Issues a ticket. With a member, that member is recorded as part of the room. Without one (for a host node), nobody is added. |
kick(roomId, member) | Promise of { epoch, previousTags, tickets, hostTicket } | Removes the member, rotates the secret and increments epoch. tickets is a Map from each remaining member to its new ticket. |
leave(roomId, member) | Promise of the same result as kick | Rotates the key on voluntary departure too. Deliver the returned tickets to remaining members. |
closeRoom(roomId) | Promise<boolean> | Forgets the room after pending operations finish. Does not end active calls or erase tickets already issued; your app must tell clients to leave. |
describe(roomId) | { roomId, tag, epoch, members } or null | Public facts for bookkeeping; members is an array of member ids. |
The ticket
A ticket is everything a client needs to enter one room:
{
"v": 1,
"app": "my-app",
"roomId": "room-42",
"epoch": 1,
"gates": ["wss://gate-1.example.com/freehop", "wss://gate-2.example.com/freehop"],
"secret": "base64url, 32 random bytes",
"auth": {
"wss://gate-1.example.com/freehop": "token for gate-1",
"wss://gate-2.example.com/freehop": "token for gate-2"
},
"stun": ["stun:gate-1.example.com:3478"],
"expires": 1800000000,
"turn": [{ "urls": ["turn:turn.example.com:3478"], "username": "…", "credential": "…" }]
}
It carries the room secret, so it is as sensitive as room membership itself. Send it only to the member it was issued for, over your application's own authenticated channel: your API response, your websocket, the lobby join response. Never put it in a room listing or a link.
freehop/ticket turns tickets into compact strings and back:
import { encodeTicket, decodeTicket, validTicket } from 'freehop/ticket';
const text = encodeTicket(ticket); // base64url of the JSON
const again = decodeTicket(text); // throws TypeError on a malformed or invalid ticket
connect() and hostSession() accept either form. A ticket is valid when its version is 1, app has at most 64 characters, roomId at most 128, epoch is a positive integer, it lists one to eight gate URLs, the secret is 22 to 128 characters long, auth (if present) maps configured WebSocket gate URLs to tokens of at most 2048 characters each (a legacy string is allowed only with one WebSocket gate), turn (if present) holds at most two TURN servers, and expires is a safe integer Unix timestamp in the future. turn appears only when the authority is configured with it.
Kicking a member
A kick is a key rotation. Your backend needs to know which Freehop peer id belongs to which of your members, so have each client report session.id after it connects.
// When a client connects, it reports its Freehop peer id: peerIds.set(member, peerId)
const { tickets, hostTicket } = await authority.kick('room-42', 'user-3');
const dropped = [peerIds.get('user-3')];
for (const [member, ticket] of tickets) sendTo(member, { ticket, dropped });
await host?.update(hostTicket, { dropped }); // if this room has a host node
onMessage(async ({ ticket, dropped }) => {
await session.update(ticket, { dropped }); // false if the ticket is not newer
});
Each remaining client disconnects the removed peer, closes every old link, moves to the new room tag and reconnects using the new key. The removed peer still holds the old secret, but updated members reject it. Deliver replacement tickets to every remaining client and host; machines left on the old key can still communicate with each other. See the security model for the details of the switch.
Next
Client: connect is the other side of the ticket.