freehop 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Peer gateway: a TURN server that a desktop participant (or any peer with Node) runs on its own
3
+ // machine, made reachable through the participant's router via PCP / NAT-PMP / UPnP. It is not a
4
+ // third-party relay: it is the participant's own front door. Peers in the same room can reach the
5
+ // participant through it even from symmetric NATs or UDP-blocking networks, and the participant may let
6
+ // other members of its own session use it when they cannot reach each other.
7
+ import { networkInterfaces } from 'node:os';
8
+ import { randomBytes, createHmac } from 'node:crypto';
9
+ import { createTurnServer, classifyPeerAddress } from './turn-server.mjs';
10
+
11
+ const TAG = /^[A-Za-z0-9_-]{8,64}$/, LABEL = /^[A-Za-z0-9_-]{1,64}$/;
12
+ // Peer revocations per room, rooms with a credential floor; relay-port router exchanges at once, started per second, burst.
13
+ const ROOM_REVOCATIONS = 256, MAX_FLOORS = 4096, ROUTER_SLOTS = 4, ROUTER_RATE = 8, ROUTER_BURST = 16;
14
+
15
+ // The TURN destination classification: only globally routable unicast counts as public.
16
+ export function isPublicAddress(address) { return classifyPeerAddress(address) === 'public'; }
17
+
18
+ function pickLanAddress() {
19
+ return Object.values(networkInterfaces()).flat().find(i => i.family === 'IPv4' && !i.internal)?.address ?? null;
20
+ }
21
+
22
+ /**
23
+ * startGateway({ host, port, relayPortRange, portMapping, mapper, externalAddress, rooms, credentialTtlSeconds, relayScope, limits, log })
24
+ * Resolves with { info(), credentialsFor(), allowRoom(), revokeRoom(), revokePeer(), rotate(), stats(), close() }.
25
+ * info() returns public gateway metadata, or null when unreachable. Keep credentialsFor()
26
+ * in the privileged process and expose a bounded, authenticated broker to renderers.
27
+ *
28
+ * Credentials are TURN REST style and scoped: username "<expiry>:<room tag 8>:<peer id|self>",
29
+ * password = base64(HMAC-SHA1(secret, username)). Only rooms passed to allowRoom() (or the
30
+ * `rooms` option) are accepted, and individual peers can be revoked (kick, leave). revokeRoom()
31
+ * voids every credential issued for that room so far, also after the tag is allowed again.
32
+ *
33
+ * relayScope 'internal' (default) relays only between allocations on this gateway: a remote peer
34
+ * and the owner's own `self` allocation, or two members. 'public' also lets every member relay
35
+ * UDP to any public host from this machine's address; not recommended when members are untrusted.
36
+ */
37
+ export async function startGateway(options = {}) {
38
+ const ttlSeconds = options.credentialTtlSeconds ?? 7200;
39
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 86400) throw new TypeError('credentialTtlSeconds must be 1..86400');
40
+ const relayScope = options.relayScope ?? 'internal';
41
+ if (relayScope !== 'internal' && relayScope !== 'public') throw new TypeError("relayScope must be 'internal' or 'public'");
42
+ const log = options.log ?? (() => {});
43
+ const host = options.host ?? pickLanAddress();
44
+ if (!host) throw new Error('No usable local IPv4 address for the gateway.');
45
+ const port = options.port ?? 3478;
46
+ // A machine with a public address (VPS, community server, IPv4 without NAT) needs no router
47
+ // mapping: its listener and relay ports are reachable as they are.
48
+ const publicHost = isPublicAddress(host);
49
+ let mapper = options.mapper ?? null;
50
+ if (!mapper && !publicHost && options.portMapping !== false) {
51
+ try {
52
+ const { createPortMapper } = await import('./port-mapper.mjs');
53
+ mapper = await createPortMapper({ localAddress: host, ...(typeof options.portMapping === 'object' ? options.portMapping : {}), log });
54
+ } catch (error) { log('port-mapper-unavailable', { message: error.message }); mapper = null; }
55
+ }
56
+ const mappings = [];
57
+ let external = options.externalAddress ?? (publicHost ? host : null);
58
+ const listenerPorts = { udp: port, tcp: port };
59
+ // An operator who forwards ports by hand passes externalAddress with portMapping:false.
60
+ const manual = !!options.externalAddress && options.portMapping === false;
61
+ const mapPort = async (protocol, internalPort) => {
62
+ if (publicHost || manual) return internalPort;
63
+ if (!mapper) return null;
64
+ try {
65
+ const m = await mapper.map({ protocol, internalPort, suggestedExternalPort: internalPort, lifetimeSeconds: 7200, description: 'peerlane gateway' });
66
+ mappings.push(m);
67
+ if (!m.externalAddressIsPrivate && isPublicAddress(m.externalAddress)) external ??= m.externalAddress;
68
+ else log('mapping-not-public', { externalAddress: m.externalAddress });
69
+ return m.externalPort;
70
+ } catch (error) { log('mapping-failed', { protocol, internalPort, message: error.message }); return null; }
71
+ };
72
+ // Map the listener ports first: the router's answer tells us the external address that
73
+ // every relayed transport address must advertise.
74
+ listenerPorts.udp = await mapPort('udp', port);
75
+ listenerPorts.tcp = await mapPort('tcp', port);
76
+
77
+ // Relay ports need router mappings only when relaying leaves this machine ('public' scope);
78
+ // internal delivery never touches them. Router exchanges are bounded (ROUTER_SLOTS at once,
79
+ // ROUTER_RATE starts per second); later Allocates wait, within the TURN hook timeout. A mapping
80
+ // lives exactly while an allocation still uses it: the TURN server aborts `signal` when it stops.
81
+ const relayUsers = new Map(), relayMapped = new Map(), routerQueue = []; // internal port -> users / mapping
82
+ let routerBusy = 0, routerTokens = ROUTER_BURST, routerAt = Date.now(), routerTimer = null;
83
+ const dropMapping = m => { const i = mappings.indexOf(m); if (i >= 0) mappings.splice(i, 1); try { mapper.unmap(m)?.catch?.(() => {}); } catch {} };
84
+ const releaseRelay = localPort => {
85
+ const users = (relayUsers.get(localPort) ?? 1) - 1;
86
+ if (users > 0) { relayUsers.set(localPort, users); return; }
87
+ relayUsers.delete(localPort);
88
+ const m = relayMapped.get(localPort);
89
+ if (m) { relayMapped.delete(localPort); dropMapping(m); }
90
+ };
91
+ const pumpRouter = () => {
92
+ const now = Date.now();
93
+ routerTokens = Math.min(ROUTER_BURST, routerTokens + (now - routerAt) * ROUTER_RATE / 1000); routerAt = now;
94
+ while (routerQueue.length && routerBusy < ROUTER_SLOTS && routerTokens >= 1) { routerTokens--; routerBusy++; routerQueue.shift()(); }
95
+ if (routerQueue.length && routerBusy < ROUTER_SLOTS && !routerTimer) {
96
+ routerTimer = setTimeout(() => { routerTimer = null; pumpRouter(); }, Math.ceil((1 - routerTokens) * 1000 / ROUTER_RATE));
97
+ routerTimer.unref?.();
98
+ }
99
+ };
100
+ const routerSlot = signal => new Promise((resolve, reject) => {
101
+ const start = () => { signal.removeEventListener('abort', leave); resolve(); };
102
+ const leave = () => { routerQueue.splice(routerQueue.indexOf(start), 1); reject(new Error('allocation ended')); };
103
+ routerQueue.push(start); signal.addEventListener('abort', leave, { once: true });
104
+ pumpRouter();
105
+ });
106
+ const routerDone = () => { routerBusy--; pumpRouter(); };
107
+ async function mapRelayPort(localPort, family, signal) {
108
+ if (signal.aborted) return null;
109
+ relayUsers.set(localPort, (relayUsers.get(localPort) ?? 0) + 1);
110
+ signal.addEventListener('abort', () => releaseRelay(localPort), { once: true });
111
+ await routerSlot(signal);
112
+ let m;
113
+ try { m = await mapper.map({ protocol: 'udp', internalPort: localPort, suggestedExternalPort: localPort, lifetimeSeconds: 7200, description: 'peerlane gateway' }); }
114
+ catch (error) { log('mapping-failed', { protocol: 'udp', internalPort: localPort, message: error.message }); return null; }
115
+ finally { routerDone(); }
116
+ // The mapper answers one (protocol, port) request for every caller: a newer allocation on this
117
+ // port may share the mapping, so one that ended meanwhile releases it only when nobody waits.
118
+ if (signal.aborted) { if (!relayUsers.has(localPort)) dropMapping(m); return null; }
119
+ if (!relayMapped.has(localPort)) { relayMapped.set(localPort, m); mappings.push(m); }
120
+ return m.externalPort;
121
+ }
122
+
123
+ let secret = randomBytes(32).toString('base64url');
124
+ // Allowed room (tag prefix) -> { revoked: label -> ms until every credential issued so far has expired,
125
+ // issued: newest issue second }. Revocation state is per room: a room can only exhaust its own table.
126
+ const rooms = new Map(), floors = new Map(); // floors: room -> { at, until }; credentials issued at or before `at` are void
127
+ const admit = room => rooms.set(room, { revoked: new Map(), issued: 0 });
128
+ for (const tag of options.rooms ?? []) admit(String(tag).slice(0, 8));
129
+ const sign = name => createHmac('sha1', secret).update(name).digest('base64');
130
+ const floorOf = room => floors.get(room)?.at ?? -Infinity;
131
+ // Issue time = expiry - ttl. A revoked room keeps its floor until all older credentials have expired, so
132
+ // re-allowing the tag never revives them; at the table's bound the secret rotates instead of forgetting one.
133
+ function retire(room, state) {
134
+ const now = Date.now(), at = Math.max(Math.floor(now / 1000), state.issued, floorOf(room));
135
+ if (!floors.has(room)) {
136
+ for (const [r, f] of floors) if (f.until <= now) floors.delete(r);
137
+ if (floors.size >= MAX_FLOORS) {
138
+ secret = randomBytes(32).toString('base64url'); floors.clear();
139
+ log('secret-rotated', { reason: 'revocation-capacity' });
140
+ return;
141
+ }
142
+ }
143
+ floors.set(room, { at, until: (at + ttlSeconds + 1) * 1000 });
144
+ }
145
+ const turn = await createTurnServer({
146
+ listen: [{ transport: 'udp', host, port }, { transport: 'tcp', host, port }],
147
+ relayHost: host,
148
+ relayPortRange: options.relayPortRange ?? [49160, 49359],
149
+ realm: 'peerlane',
150
+ authenticate: name => {
151
+ const [expiry, room, label, extra] = name.split(':'), state = rooms.get(room);
152
+ if (extra !== undefined || name.length > 128 || !label || !state) return null;
153
+ const seconds = Number(expiry), now = Date.now(), floor = floorOf(room);
154
+ // One extra second of horizon: credentials minted right after a revocation carry issue time floor + 1.
155
+ if (!Number.isSafeInteger(seconds) || seconds * 1000 <= now || seconds > Math.max(Math.floor(now / 1000), floor) + 1 + ttlSeconds) return null;
156
+ if (seconds - ttlSeconds <= floor || state.revoked.get(label) > now) return null;
157
+ return sign(name);
158
+ },
159
+ // No custom peer policy: loopback, link-local and private peers are refused and this machine's own
160
+ // addresses are relay<->relay only; the 'internal' scope refuses every other destination as well.
161
+ peerScope: relayScope,
162
+ externalAddress: external ?? undefined,
163
+ mapRelayPort: relayScope === 'public' && !publicHost && !manual && external && mapper ? mapRelayPort : undefined,
164
+ allocationScope: username => username.split(':')[1],
165
+ limits: { maxAllocationsPerUsername: 6, maxAllocationsPerScope: 32, scopeBitrate: 20_000_000, ...options.limits },
166
+ log
167
+ });
168
+ // Ports actually bound (port 0 = ephemeral, e.g. tests); directly reachable hosts advertise them.
169
+ const bound = { udp: turn.addresses().find(a => a.transport === 'udp')?.port, tcp: turn.addresses().find(a => a.transport === 'tcp')?.port };
170
+ if (publicHost || manual) { listenerPorts.udp = bound.udp; listenerPorts.tcp = bound.tcp; }
171
+ const bracket = a => a.includes(':') ? `[${a}]` : a;
172
+ const matches = (room, label) => name => { const [, r, l] = name.split(':'); return r === room && (label === undefined || l === label); };
173
+ function info() {
174
+ if (!external || !listenerPorts.udp && !listenerPorts.tcp) return null;
175
+ const urls = [];
176
+ if (listenerPorts.udp) urls.push(`turn:${bracket(external)}:${listenerPorts.udp}?transport=udp`);
177
+ if (listenerPorts.tcp) urls.push(`turn:${bracket(external)}:${listenerPorts.tcp}?transport=tcp`);
178
+ return { urls, internalUrls: [`turn:${bracket(host)}:${bound.udp}?transport=udp`, `turn:${bracket(host)}:${bound.tcp}?transport=tcp`],
179
+ ttlSeconds, external: [external], internal: host };
180
+ }
181
+ function revokeRoom(roomTag) {
182
+ const room = String(roomTag).slice(0, 8), state = rooms.get(room);
183
+ if (state) { retire(room, state); rooms.delete(room); }
184
+ return turn.revoke(matches(room));
185
+ }
186
+
187
+ return {
188
+ turn, mapper, info,
189
+ credentialsFor(roomTag, label) {
190
+ if (typeof roomTag !== 'string' || !TAG.test(roomTag) || typeof label !== 'string' || !LABEL.test(label)) return null;
191
+ const room = roomTag.slice(0, 8), state = rooms.get(room), now = Date.now();
192
+ if (!state || state.revoked.get(label) > now) return null;
193
+ const issued = Math.max(Math.floor(now / 1000), floorOf(room) + 1);
194
+ state.issued = Math.max(state.issued, issued);
195
+ const username = `${issued + ttlSeconds}:${room}:${label}`;
196
+ return { username, credential: sign(username) };
197
+ },
198
+ allowRoom(roomTag) {
199
+ if (typeof roomTag !== 'string' || !TAG.test(roomTag)) throw new TypeError('Invalid room tag');
200
+ const room = roomTag.slice(0, 8);
201
+ if (rooms.has(room)) return;
202
+ if (rooms.size >= 64) throw new Error('Gateway room capacity reached');
203
+ admit(room);
204
+ },
205
+ revokeRoom,
206
+ revokePeer(roomTag, peer) {
207
+ const room = String(roomTag).slice(0, 8), state = rooms.get(room), now = Date.now();
208
+ // Only an allowed room has valid credentials (a revoked one is covered by its floor), and only valid labels get any.
209
+ if (state && typeof peer === 'string' && LABEL.test(peer) && !(state.revoked.get(peer) > now)) {
210
+ if (!state.revoked.has(peer) && state.revoked.size >= ROOM_REVOCATIONS) {
211
+ for (const [label, until] of state.revoked) if (until <= now) state.revoked.delete(label);
212
+ // At this room's bound revoke the room itself rather than forget a denial: its floor voids every older credential.
213
+ if (state.revoked.size >= ROOM_REVOCATIONS) { log('room-revocations-full', { room }); return revokeRoom(roomTag); }
214
+ }
215
+ state.revoked.set(peer, (Math.max(Math.floor(now / 1000), state.issued) + ttlSeconds + 1) * 1000);
216
+ }
217
+ return turn.revoke(matches(room, peer));
218
+ },
219
+ rotate() { secret = randomBytes(32).toString('base64url'); floors.clear(); return info(); },
220
+ stats() {
221
+ let revocations = 0;
222
+ for (const state of rooms.values()) revocations += state.revoked.size;
223
+ return { external, relayScope, rooms: rooms.size, revocations, floors: floors.size,
224
+ mappings: mappings.map(m => ({ method: m.method, protocol: m.protocol, internalPort: m.internalPort, externalPort: m.externalPort })), turn: turn.stats() };
225
+ },
226
+ async close() {
227
+ try { await turn.close(); } finally { clearTimeout(routerTimer); if (mapper) await mapper.close(); }
228
+ }
229
+ };
230
+ }
@@ -0,0 +1,169 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Gateway member: the session's own host node (a participant's desktop app or a community server
3
+ // that hosts the session) joins the room through the same gates, never sends media and offers
4
+ // its gateway to the session's peers, each with its own short-lived, room-scoped TURN
5
+ // credentials. The relay cost therefore stays inside the session that uses it.
6
+ import { deriveRoom, seal, open, randomId } from '../client/crypto.mjs';
7
+ import { GateClient } from '../client/gate-client.mjs';
8
+
9
+ export const GATEWAY_ID_PREFIX = 'gw_';
10
+ const PEER_ID = /^[A-Za-z0-9_-]{22}$/;
11
+
12
+ /**
13
+ * joinAsGateway({ gates, secret, app, gateway, auth })
14
+ * `gateway` is the object returned by startGateway(). Returns { id, stats(), drop(peer), rekey(secret, {auth}), close() }.
15
+ */
16
+ export async function joinAsGateway({ gates, secret, app = 'peerlane', gateway, auth, WebSocketImpl = globalThis.WebSocket, departGraceMs = 8000, log = () => {} }) {
17
+ if (!gateway?.info?.() || typeof gateway.credentialsFor !== 'function') throw new TypeError('A reachable gateway from startGateway() is required.');
18
+ // Host nodes join through WebSocket gates; tracker gates (bt+wss://) are used by clients only.
19
+ const socketGates = list => list.filter(url => !url.startsWith('bt+'));
20
+ let usable = socketGates(gates);
21
+ if (!usable.length) throw new TypeError('A host node needs at least one WebSocket gate (ws:// or wss://) in its ticket.');
22
+ if (typeof WebSocketImpl !== 'function') throw new TypeError('No WebSocket implementation: use Node.js 22 or newer, or pass WebSocketImpl.');
23
+ const id = GATEWAY_ID_PREFIX + randomId(16).slice(0, 19);
24
+ const stats = { peersServed: 0, envelopesSent: 0, envelopesReceived: 0, revoked: 0 };
25
+ let room, clients = [], n = 0, closed = false, currentAuth = auth;
26
+ const presence = new Map(); // peer -> Set(gate)
27
+ const lastContact = new Map();
28
+ const greeted = new Map(); // peer -> expiry of the credentials we sent
29
+ const sequences = new Map(); // peer -> highest envelope counter seen (replay guard)
30
+ const tags = new Set(); // every room tag this member served (kept until close)
31
+ const issued = new Set(); // peers that received credentials in this epoch: the only revocation targets
32
+ let rekeyTask = Promise.resolve();
33
+
34
+ const capsFor = peer => {
35
+ const info = gateway.info(), creds = gateway.credentialsFor(room.tag, peer);
36
+ if (!info || !creds) return null;
37
+ return { v: 1, role: 'gateway', forward: false, peers: [],
38
+ gateway: { urls: info.urls, username: creds.username, credential: creds.credential, external: info.external, internal: info.internal } };
39
+ };
40
+ async function greet(peer) {
41
+ if (closed || peer === id || !PEER_ID.test(peer) || peer.startsWith(GATEWAY_ID_PREFIX)) return;
42
+ const epoch = room;
43
+ const caps = capsFor(peer); if (!caps) return;
44
+ const box = await seal(epoch, id, peer, { kind: 'caps', caps, n: ++n });
45
+ if (closed || epoch !== room) return;
46
+ let sent = false;
47
+ for (const gate of presence.get(peer) ?? []) sent = gate.send(peer, box) || sent;
48
+ if (sent) {
49
+ if (!greeted.has(peer)) stats.peersServed++;
50
+ greeted.set(peer, Number(caps.gateway.username.split(':')[0])); stats.envelopesSent++;
51
+ issued.add(peer); if (issued.size > 1024) issued.delete(issued.values().next().value);
52
+ }
53
+ }
54
+ function openGates() {
55
+ clients = usable.map(url => {
56
+ // Auth is read at every (re)connect, so refreshed tokens apply without a rotation.
57
+ const gate = new GateClient(url, { room: room.tag, peer: id, auth: gateUrl => currentAuth && typeof currentAuth === 'object' ? currentAuth[gateUrl] : currentAuth, WebSocketImpl });
58
+ const seen = peer => {
59
+ if (!presence.has(peer) && presence.size >= 64) return false;
60
+ const set = presence.get(peer) ?? new Set(); set.add(gate); presence.set(peer, set); return true;
61
+ };
62
+ // Roster entries consume no state for strangers: clients must first prove membership. A
63
+ // peer that already authenticated in this epoch is restored after our own gate reconnects,
64
+ // and greeted again if the sweep forgot it, so credential renewal continues.
65
+ const restore = peer => {
66
+ if (closed || typeof peer !== 'string' || !sequences.has(peer) || !seen(peer)) return;
67
+ lastContact.set(peer, Date.now());
68
+ if (!greeted.has(peer)) greet(peer);
69
+ };
70
+ gate.on('joined', ({ peers } = {}) => { if (Array.isArray(peers)) for (const peer of peers.slice(0, 256)) restore(peer); });
71
+ gate.on('left', () => { for (const [peer, sources] of presence) { sources.delete(gate); if (!sources.size) lastContact.set(peer, Date.now()); } });
72
+ gate.on('peer', ({ peer, on }) => {
73
+ if (on) { restore(peer); return; }
74
+ presence.get(peer)?.delete(gate);
75
+ if (presence.has(peer) && !presence.get(peer).size) lastContact.set(peer, Date.now());
76
+ });
77
+ gate.on('recv', async ({ from, box }) => {
78
+ if (closed || !PEER_ID.test(from) || from.startsWith(GATEWAY_ID_PREFIX)) return;
79
+ const epoch = room;
80
+ const p = await open(epoch, from, id, box);
81
+ if (closed || epoch !== room || !p || !Number.isSafeInteger(p.n) || p.n < 1 || p.n <= (sequences.get(from) ?? 0)) return;
82
+ if (!seen(from)) return;
83
+ lastContact.set(from, Date.now());
84
+ sequences.delete(from); sequences.set(from, p.n);
85
+ // Keep recent departure replay guards without consuming active membership slots.
86
+ if (sequences.size > 256) { const stale = [...sequences.keys()].find(peer => !presence.has(peer)); if (stale) sequences.delete(stale); }
87
+ stats.envelopesReceived++;
88
+ if (p.kind === 'bye') { drop(from); return; }
89
+ // Credentials only for peers that proved room membership with a sealed envelope.
90
+ if (p.kind === 'caps' && !greeted.has(from)) greet(from);
91
+ });
92
+ gate.connect();
93
+ return gate;
94
+ });
95
+ }
96
+ // Only peers we issued credentials to are revoked: a member cannot fill the gateway's
97
+ // revocation table with made-up ids (a departure of an unknown id has nothing to revoke).
98
+ function drop(peer) {
99
+ if (issued.delete(peer)) for (const tag of tags) stats.revoked += gateway.revokePeer(tag, peer) ?? 0;
100
+ greeted.delete(peer); presence.delete(peer); lastContact.delete(peer);
101
+ }
102
+ async function enter(newSecret) {
103
+ const next = await deriveRoom(newSecret, app);
104
+ if (closed) return;
105
+ room = next;
106
+ tags.add(room.tag); gateway.allowRoom(room.tag);
107
+ openGates();
108
+ }
109
+ await enter(secret);
110
+ const sweep = () => {
111
+ for (const [peer, sources] of presence) if (!sources.size && Date.now() - (lastContact.get(peer) ?? 0) >= departGraceMs) {
112
+ // A mailbox outage is not authenticated revocation: existing TURN media must survive.
113
+ presence.delete(peer); greeted.delete(peer); lastContact.delete(peer);
114
+ }
115
+ };
116
+ const sweeper = setInterval(sweep, Math.max(10, Math.min(departGraceMs, 1000))); sweeper.unref?.();
117
+ // Renew credentials well before they expire.
118
+ const ttl = gateway.info()?.ttlSeconds ?? 7200;
119
+ const renew = setInterval(() => {
120
+ const soon = Math.floor(Date.now() / 1000) + ttl / 2;
121
+ for (const [peer, expiry] of greeted) if (expiry < soon) greet(peer);
122
+ }, Math.min(ttl * 250, 60000));
123
+ renew.unref?.();
124
+ return {
125
+ id,
126
+ stats: () => ({ ...stats, peers: presence.size }),
127
+ drop,
128
+ // Every old-epoch allocation is revoked, including aliases the backend never learned. The new
129
+ // room is admitted first: if the gateway refuses it, the old room keeps working and a retry of
130
+ // the same rotation runs again (state only advances after success).
131
+ rekey(newSecret, { auth: nextAuth, dropped = [], gates: nextGates } = {}) {
132
+ const task = rekeyTask.then(async () => {
133
+ if (closed) return;
134
+ const next = await deriveRoom(newSecret, app);
135
+ if (closed || next.tag === room.tag) return;
136
+ const nextUsable = nextGates === undefined ? usable : Array.isArray(nextGates) ? socketGates(nextGates) : [];
137
+ if (!nextUsable.length) throw new TypeError('A host node needs at least one WebSocket gate (ws:// or wss://) in its ticket.');
138
+ try { gateway.allowRoom(next.tag); }
139
+ catch (error) {
140
+ // At room capacity the old epoch's slots are what the new room needs: retire them first.
141
+ // Any other refusal leaves the old room untouched, so the rotation can simply be retried.
142
+ if (!/capacity/i.test(String(error?.message))) throw error;
143
+ for (const tag of tags) gateway.revokeRoom(tag);
144
+ gateway.allowRoom(next.tag);
145
+ }
146
+ for (const peer of dropped) drop(peer);
147
+ currentAuth = nextAuth; usable = nextUsable;
148
+ for (const gate of clients) gate.close();
149
+ for (const tag of tags) if (tag !== next.tag) gateway.revokeRoom(tag);
150
+ tags.clear(); presence.clear(); lastContact.clear(); greeted.clear(); sequences.clear(); issued.clear();
151
+ room = next; tags.add(room.tag);
152
+ openGates();
153
+ });
154
+ rekeyTask = task.catch(() => {});
155
+ return task;
156
+ },
157
+ /** Use refreshed gate tokens for the same epoch at the next (re)connect. */
158
+ setAuth(nextAuth) { if (!closed) currentAuth = nextAuth; },
159
+ async close() {
160
+ closed = true; clearInterval(renew); clearInterval(sweeper);
161
+ for (const peer of greeted.keys()) {
162
+ const box = await seal(room, id, peer, { kind: 'bye', n: ++n });
163
+ for (const gate of presence.get(peer) ?? []) gate.send(peer, box);
164
+ }
165
+ for (const gate of clients) gate.close();
166
+ for (const tag of tags) gateway.revokeRoom(tag);
167
+ }
168
+ };
169
+ }