-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathindex.js
More file actions
261 lines (244 loc) · 12.3 KB
/
Copy pathindex.js
File metadata and controls
261 lines (244 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
// Entry point for the three.ws multiplayer server.
//
// This is a standalone Colyseus process — Vercel can't host long-lived
// WebSocket servers, so this runs separately (Fly.io, Railway, Render, or a
// $5 VPS — see ../README.md). The Vite app at three.ws/walk and three.ws/play
// connect to it over WebSocket and exchange state via the rooms defined below.
//
// We mount an Express app as the HTTP request handler. Colyseus 0.16 detects
// an existing Express app on the underlying http.Server and composes with it:
// matchmaking + seat-reservation routes go to Colyseus's own router, and
// everything else (/health, /colyseus monitor) falls through to Express. This
// is the supported way to expose custom HTTP routes alongside Colyseus on one
// port — a hand-rolled raw request listener double-responds and throws
// ERR_HTTP_HEADERS_SENT against the matchmaker's prepended listener.
import http from 'node:http';
import express from 'express';
import { Server } from '@colyseus/core';
import { WebSocketTransport } from '@colyseus/ws-transport';
import { monitor } from '@colyseus/monitor';
import { WalkRoom } from './rooms/WalkRoom.js';
import { IrlRoom } from './rooms/IrlRoom.js';
import { blockStore } from './block-store.js';
import { worldPersistence } from './persistence.js';
import { flushAllPlayers } from './playerStore.js';
import { socialHub } from './social-hub.js';
import { verifyNotifySignature } from './presence-token.js';
const PORT = Number(process.env.PORT || 2567);
const HOST = process.env.HOST || '0.0.0.0';
// Origins permitted to upgrade to WebSocket — comma-separated list. Default
// covers local dev + the production three.ws origin. Anything outside this
// set gets a 403 before the WS handshake completes.
const ALLOWED_ORIGINS = (
process.env.ALLOWED_ORIGINS ||
'http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,https://three.ws,https://www.three.ws'
)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
// Fail fast on an insecure production config. Without a real shared secret the
// holder gate is forgeable by anyone (both this process and the Vercel signer
// fall back to a public dev secret otherwise), so refuse to boot prod without it
// rather than silently shipping a bypassable gate.
if (process.env.NODE_ENV === 'production' && !process.env.HOLDER_PASS_SECRET) {
console.error(
'[multiplayer] FATAL: HOLDER_PASS_SECRET is required in production — the holder gate would be forgeable. Refusing to start.',
);
process.exit(1);
}
// Surface the platform token gate's state at boot so a misconfigured deploy is
// obvious in the logs. The gate itself is enforced in WalkRoom.onAuth; an unset
// mint leaves walk_world open (the default until $THREE is pinned).
const PLAY_GATE_MINT = (process.env.PLAY_GATE_MINT || process.env.THREE_MINT || '').trim();
if (PLAY_GATE_MINT) {
const min = Number(process.env.PLAY_GATE_MIN) > 0 ? Number(process.env.PLAY_GATE_MIN) : 1;
console.log(`[multiplayer] play gate ENABLED — require ≥ ${min} of ${PLAY_GATE_MINT} (wallet sign-in)`);
} else {
console.log('[multiplayer] play gate OFF (set PLAY_GATE_MINT or THREE_MINT to require wallet sign-in + token balance)');
}
const app = express();
// Liveness probes for the host platform (Fly/Railway/Render).
app.get(['/health', '/healthz'], (_req, res) => {
res.json({ ok: true, name: 'three.ws-multiplayer' });
});
// Internal friends delivery webhook (Task 15). The three.ws API calls this after
// persisting a DM or friend-graph change to push it live to every socket the
// recipient account has open here. HMAC-signed with the shared secret so only
// the API can inject events; returns whether the recipient was online (the API
// uses that to decide live vs. next-login delivery). Body is small JSON; an
// unsigned or malformed request is rejected before any work.
app.post('/internal/notify', express.json({ limit: '16kb' }), (req, res) => {
const { type, to, payload } = req.body || {};
const sig = req.headers['x-mp-signature'];
const ts = req.headers['x-mp-timestamp'];
if (typeof type !== 'string' || typeof to !== 'string' || !type || !to) {
return res.status(400).json({ error: 'bad_request' });
}
// The signature is bound to the exact payload and a fresh timestamp, so a leaked
// (to,type,sig) tuple can't be replayed with attacker-chosen content or after
// the freshness window. Verify against the body we're about to deliver.
if (!verifyNotifySignature(to, type, payload || {}, ts, sig)) {
return res.status(401).json({ error: 'bad_signature' });
}
const delivered = socialHub.deliver(to, type, payload || {});
res.json({ delivered });
});
// The IRL world (irl_world) is a presence + reaction room only — it is NOT a pin
// transport. Placed agents are private by location and reach a viewer solely via
// the per-viewer /api/irl/pins proximity read when they are physically near one,
// so there is no pin-publish webhook here: nothing fans pin coordinates into a
// room to be broadcast to every client. See rooms/IrlRoom.js.
// Admin monitor UI — exposes live room/client state, so it must NOT be open to
// the world in production. Mount it only when protected by basic-auth creds
// (MONITOR_USER + MONITOR_PASS), or, outside production, openly for local dev.
const MONITOR_USER = process.env.MONITOR_USER;
const MONITOR_PASS = process.env.MONITOR_PASS;
const IS_PROD = process.env.NODE_ENV === 'production';
function monitorBasicAuth(req, res, next) {
const hdr = req.headers.authorization || '';
const [scheme, encoded] = hdr.split(' ');
if (scheme === 'Basic' && encoded) {
const [user, pass] = Buffer.from(encoded, 'base64').toString().split(':');
if (user === MONITOR_USER && pass === MONITOR_PASS) return next();
}
res.set('WWW-Authenticate', 'Basic realm="colyseus-monitor"').status(401).send('auth required');
}
if (MONITOR_USER && MONITOR_PASS) {
app.use('/colyseus', monitorBasicAuth, monitor());
console.log('[multiplayer] monitor mounted at /colyseus (basic auth)');
} else if (!IS_PROD) {
app.use('/colyseus', monitor());
console.log('[multiplayer] monitor mounted at /colyseus (open — dev only)');
} else {
console.log('[multiplayer] monitor disabled (set MONITOR_USER/MONITOR_PASS to enable in prod)');
}
const httpServer = http.createServer(app);
const transport = new WebSocketTransport({
server: httpServer,
verifyClient(info, next) {
const origin = info.req.headers.origin;
// Origin-less upgrades (native clients / scripted probes) must NOT be a free
// pass: a browser always sends Origin, so omitting it was a trivial way to skip
// the allowlist entirely. The real access boundary is each room's onAuth (a
// signed play pass / presence ticket, or a server-issued guest token), so the
// origin allowlist is only a browser-facing CSRF-style filter. We still reject
// origin-less handshakes in production — there is no legitimate origin-less
// browser client, and the signed-token gate, not a spoofable header, is what
// protects the rooms. Non-prod keeps them open for local curl/native dev probes.
if (!origin) {
if (!IS_PROD) return next(true);
console.warn('[multiplayer] rejecting origin-less upgrade');
return next(false, 403, 'origin required');
}
if (ALLOWED_ORIGINS.includes(origin)) return next(true);
// Allow any Vercel preview deploy that targets the same project — these
// have origins like https://three-ws-<hash>-<team>.vercel.app. We match
// by hostname suffix so we don't have to maintain an allow-list per
// preview URL.
try {
const host = new URL(origin).hostname;
if (host.endsWith('.vercel.app') || host.endsWith('.three.ws')) {
return next(true);
}
if (!IS_PROD && (host.endsWith('.app.github.dev') || host.endsWith('.githubpreview.dev') || host.endsWith('.gitpod.io'))) {
return next(true);
}
} catch {}
console.warn(`[multiplayer] rejecting origin ${origin}`);
return next(false, 403, 'origin not allowed');
},
});
// Horizontal scaling across Cloud Run instances. Colyseus rooms live in one
// process, so to run more than one instance the room registry (driver) and
// pub/sub (presence) must be shared — otherwise matchmaking on instance A can't
// see a room hosted on instance B and players for the same coin split apart.
// Setting REDIS_URI (e.g. a Memorystore instance) wires both; without it the
// server runs single-instance exactly as before (zero new behaviour, the deps
// are only imported when REDIS_URI is present).
const REDIS_URI = process.env.REDIS_URI || process.env.REDIS_URL;
let driver, presence;
if (REDIS_URI) {
try {
const [{ RedisDriver }, { RedisPresence }] = await Promise.all([
import('@colyseus/redis-driver'),
import('@colyseus/redis-presence'),
]);
driver = new RedisDriver(REDIS_URI);
presence = new RedisPresence(REDIS_URI);
console.log('[multiplayer] horizontal scaling ENABLED (Redis driver + presence)');
} catch (err) {
console.error('[multiplayer] REDIS_URI set but Redis deps unavailable — staying single-instance:', err?.message);
}
} else {
console.log('[multiplayer] single-instance mode (set REDIS_URI to scale horizontally)');
}
const gameServer = new Server({ transport, ...(driver && { driver }), ...(presence && { presence }) });
// Each coin is its own world, split by access tier: filterBy(['coin','tier'])
// makes joinOrCreate match only rooms sharing the same community coin (mint) AND
// the same tier, so a coin's open General world and its gated Holders world are
// separate instances, and different coins stay isolated. A missing coin resolves
// to the shared mainland world; a missing tier is the open General world (see
// WalkRoom.onCreate / onAuth / schemas.js).
gameServer.define('walk_world', WalkRoom).filterBy(['coin', 'tier']);
// The IRL realtime world (D1): one room instance per precision-6 geocell, so
// every viewer standing in the same ~1 km cell shares a live mirror of the pins
// there. filterBy(['geocell']) makes joinOrCreate match only rooms for the same
// cell; the room itself mirrors a 3×3 window (centre + neighbours) so edge pins
// inside the nearby radius are never missed (see rooms/IrlRoom.js).
gameServer.define('irl_world', IrlRoom).filterBy(['geocell']);
gameServer
.listen(PORT, HOST)
.then(() => {
console.log(`[multiplayer] listening on ws://${HOST}:${PORT}`);
console.log(`[multiplayer] rooms: walk_world, irl_world`);
console.log(`[multiplayer] allowed origins: ${ALLOWED_ORIGINS.join(', ')}`);
})
.catch((err) => {
console.error('[multiplayer] failed to start:', err);
process.exit(1);
});
// Keep the single instance alive through an isolated fault. A throw inside one
// onMessage handler, or an unawaited rejection deep in a dependency, would
// otherwise take down the whole process (min=1/max=1) and every connected
// player with it. We log loudly and keep serving — the offending message/room
// is lost, the server is not. (A crash-loop on a corrupt state would still be
// caught by the host's health check.)
process.on('uncaughtException', (err) => {
console.error('[multiplayer] uncaughtException (kept alive):', err);
});
process.on('unhandledRejection', (reason) => {
console.error('[multiplayer] unhandledRejection (kept alive):', reason);
});
// Clean shutdown on SIGTERM/SIGINT so deploys don't drop sessions abruptly.
const shutdown = async (signal) => {
console.log(`[multiplayer] ${signal} received — shutting down`);
try {
await gameServer.gracefullyShutdown(true);
} catch (err) {
console.error('[multiplayer] shutdown error:', err);
}
// Belt-and-suspenders: persist any world whose debounced save hadn't fired
// before the room disposed, so a redeploy never drops the last few edits.
try {
await blockStore.flushAll();
} catch (err) {
console.error('[multiplayer] final block flush error:', err);
}
// Generic per-world docs (T3): flush any room whose debounced world save hadn't
// fired, so placed builds / gated-world state survive a redeploy.
try {
await worldPersistence.flushAll();
} catch (err) {
console.error('[multiplayer] final world flush error:', err);
}
// Same guarantee for player progression (Task 16): persist every account whose
// debounced profile save hadn't landed yet, so a redeploy never resets a player.
try {
await flushAllPlayers();
} catch (err) {
console.error('[multiplayer] final player flush error:', err);
}
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));