@air-jam/server 0.1.2 → 0.1.4

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.
@@ -6,7 +6,9 @@ import {
6
6
  controllerStateSchema,
7
7
  controllerSystemSchema,
8
8
  ErrorCode,
9
+ hostCreateRoomSchema,
9
10
  hostJoinAsChildSchema,
11
+ hostReconnectSchema,
10
12
  hostRegisterSystemSchema,
11
13
  hostRegistrationSchema,
12
14
  systemLaunchGameSchema
@@ -44,12 +46,12 @@ var db = client ? drizzle(client) : null;
44
46
  var AuthService = class {
45
47
  masterKey;
46
48
  databaseUrl;
47
- isDevMode;
49
+ authMode;
48
50
  constructor() {
49
51
  this.masterKey = process.env.AIR_JAM_MASTER_KEY;
50
52
  this.databaseUrl = process.env.DATABASE_URL;
51
- this.isDevMode = !this.masterKey && !this.databaseUrl;
52
- if (this.isDevMode) {
53
+ this.authMode = this.resolveAuthMode();
54
+ if (this.authMode === "disabled") {
53
55
  console.log(
54
56
  "[server] Running in development mode - authentication disabled"
55
57
  );
@@ -59,15 +61,19 @@ var AuthService = class {
59
61
  );
60
62
  } else if (this.databaseUrl) {
61
63
  console.log("[server] Running with database authentication");
64
+ } else {
65
+ console.log(
66
+ "[server] Authentication required, but no auth backend is configured (set AIR_JAM_MASTER_KEY or DATABASE_URL)"
67
+ );
62
68
  }
63
69
  }
64
70
  /**
65
71
  * Verify an API key
66
72
  * Returns verification result with optional error message
67
- * In dev mode, always returns success
73
+ * In local/dev mode, always returns success
68
74
  */
69
75
  async verifyApiKey(apiKey) {
70
- if (this.isDevMode) {
76
+ if (this.authMode === "disabled") {
71
77
  return { isVerified: true };
72
78
  }
73
79
  if (!apiKey) {
@@ -105,9 +111,66 @@ var AuthService = class {
105
111
  };
106
112
  }
107
113
  }
114
+ resolveAuthMode() {
115
+ const configuredMode = process.env.AIR_JAM_AUTH_MODE?.toLowerCase();
116
+ if (configuredMode === "disabled") {
117
+ return "disabled";
118
+ }
119
+ if (configuredMode === "required") {
120
+ return "required";
121
+ }
122
+ if (this.masterKey || this.databaseUrl) {
123
+ return "required";
124
+ }
125
+ if (process.env.NODE_ENV === "production") {
126
+ return "required";
127
+ }
128
+ return "disabled";
129
+ }
108
130
  };
109
131
  var authService = new AuthService();
110
132
 
133
+ // src/services/rate-limit-service.ts
134
+ var RateLimitService = class {
135
+ entries = /* @__PURE__ */ new Map();
136
+ checks = 0;
137
+ check(key, limit, windowMs) {
138
+ if (limit <= 0 || windowMs <= 0) {
139
+ return { allowed: true, retryAfterMs: 0 };
140
+ }
141
+ const now = Date.now();
142
+ const existing = this.entries.get(key);
143
+ if (!existing || now >= existing.resetAt) {
144
+ this.entries.set(key, { count: 1, resetAt: now + windowMs });
145
+ this.maybeCleanup(now);
146
+ return { allowed: true, retryAfterMs: 0 };
147
+ }
148
+ if (existing.count >= limit) {
149
+ this.maybeCleanup(now);
150
+ return {
151
+ allowed: false,
152
+ retryAfterMs: Math.max(existing.resetAt - now, 0)
153
+ };
154
+ }
155
+ existing.count += 1;
156
+ this.entries.set(key, existing);
157
+ this.maybeCleanup(now);
158
+ return { allowed: true, retryAfterMs: 0 };
159
+ }
160
+ maybeCleanup(now) {
161
+ this.checks += 1;
162
+ if (this.checks % 200 !== 0) {
163
+ return;
164
+ }
165
+ for (const [key, entry] of this.entries) {
166
+ if (now >= entry.resetAt) {
167
+ this.entries.delete(key);
168
+ }
169
+ }
170
+ }
171
+ };
172
+ var rateLimitService = new RateLimitService();
173
+
111
174
  // src/services/room-manager.ts
112
175
  var RoomManager = class {
113
176
  rooms = /* @__PURE__ */ new Map();
@@ -198,10 +261,42 @@ var RoomManager = class {
198
261
  };
199
262
  var roomManager = new RoomManager();
200
263
 
264
+ // src/utils/ids.ts
265
+ import { roomCodeSchema } from "@air-jam/sdk/protocol";
266
+ import { randomInt } from "crypto";
267
+ var alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
268
+ var generateRoomCode = () => {
269
+ const code = Array.from(
270
+ { length: 4 },
271
+ () => alphabet[randomInt(0, alphabet.length)]
272
+ ).join("");
273
+ return roomCodeSchema.parse(code);
274
+ };
275
+
201
276
  // src/index.ts
277
+ var lastServerInputLogTime = 0;
278
+ var lastServerInputFailLogTime = 0;
279
+ var parsePositiveInt = (value, fallback) => {
280
+ const parsed = Number(value);
281
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
282
+ };
202
283
  var PORT = Number(process.env.PORT ?? 4e3);
284
+ var RATE_LIMIT_WINDOW_MS = parsePositiveInt(
285
+ process.env.AIR_JAM_RATE_LIMIT_WINDOW_MS,
286
+ 6e4
287
+ );
288
+ var HOST_REGISTRATION_RATE_LIMIT_MAX = parsePositiveInt(
289
+ process.env.AIR_JAM_HOST_REGISTRATION_RATE_LIMIT_MAX,
290
+ 30
291
+ );
292
+ var CONTROLLER_JOIN_RATE_LIMIT_MAX = parsePositiveInt(
293
+ process.env.AIR_JAM_CONTROLLER_JOIN_RATE_LIMIT_MAX,
294
+ 120
295
+ );
296
+ var allowedOrigins = process.env.AIR_JAM_ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()).filter(Boolean);
297
+ var corsOrigin = allowedOrigins && allowedOrigins.length > 0 ? allowedOrigins : "*";
203
298
  var app = express();
204
- app.use(cors());
299
+ app.use(cors({ origin: corsOrigin }));
205
300
  app.use(express.json());
206
301
  app.get("/health", (_, res) => {
207
302
  res.json({ ok: true });
@@ -209,7 +304,7 @@ app.get("/health", (_, res) => {
209
304
  var httpServer = createServer(app);
210
305
  var io = new Server(httpServer, {
211
306
  cors: {
212
- origin: "*"
307
+ origin: corsOrigin
213
308
  },
214
309
  pingInterval: 2e3,
215
310
  pingTimeout: 5e3
@@ -220,9 +315,40 @@ var emitError = (socketId, payload) => {
220
315
  io.on(
221
316
  "connection",
222
317
  (socket) => {
318
+ const isHostAuthorizedForRoom = (roomId) => {
319
+ return roomManager.getRoomByHostId(socket.id) === roomId;
320
+ };
321
+ const isControllerAuthorizedForRoom = (roomId, controllerId) => {
322
+ const controllerInfo = roomManager.getControllerInfo(socket.id);
323
+ if (!controllerInfo || controllerInfo.roomId !== roomId) {
324
+ return false;
325
+ }
326
+ if (controllerId && controllerInfo.controllerId !== controllerId) {
327
+ return false;
328
+ }
329
+ return true;
330
+ };
331
+ const forwardedFor = socket.handshake.headers["x-forwarded-for"];
332
+ const socketIdentifier = typeof forwardedFor === "string" && forwardedFor.split(",")[0]?.trim() || Array.isArray(forwardedFor) && forwardedFor[0]?.split(",")[0]?.trim() || socket.handshake.address || socket.id;
333
+ const isRateLimited = (bucket, limit) => {
334
+ const result = rateLimitService.check(
335
+ `${bucket}:${socketIdentifier}`,
336
+ limit,
337
+ RATE_LIMIT_WINDOW_MS
338
+ );
339
+ return !result.allowed;
340
+ };
223
341
  socket.on(
224
342
  "host:registerSystem",
225
343
  async (payload, callback) => {
344
+ if (isRateLimited("host-registration", HOST_REGISTRATION_RATE_LIMIT_MAX)) {
345
+ callback({
346
+ ok: false,
347
+ message: "Too many host registration attempts. Please try again.",
348
+ code: ErrorCode.SERVICE_UNAVAILABLE
349
+ });
350
+ return;
351
+ }
226
352
  const parsed = hostRegisterSystemSchema.safeParse(payload);
227
353
  if (!parsed.success) {
228
354
  callback({
@@ -268,6 +394,139 @@ io.on(
268
394
  io.to(roomId).emit("server:roomReady", { roomId });
269
395
  }
270
396
  );
397
+ socket.on(
398
+ "host:createRoom",
399
+ async (payload, callback) => {
400
+ if (isRateLimited("host-registration", HOST_REGISTRATION_RATE_LIMIT_MAX)) {
401
+ callback({
402
+ ok: false,
403
+ message: "Too many host registration attempts. Please try again.",
404
+ code: ErrorCode.SERVICE_UNAVAILABLE
405
+ });
406
+ return;
407
+ }
408
+ const parsed = hostCreateRoomSchema.safeParse(payload);
409
+ if (!parsed.success) {
410
+ callback({
411
+ ok: false,
412
+ message: parsed.error.message,
413
+ code: ErrorCode.INVALID_PAYLOAD
414
+ });
415
+ return;
416
+ }
417
+ const { maxPlayers, apiKey } = parsed.data;
418
+ const verification = await authService.verifyApiKey(apiKey);
419
+ if (!verification.isVerified) {
420
+ callback({
421
+ ok: false,
422
+ message: verification.error,
423
+ code: ErrorCode.INVALID_API_KEY
424
+ });
425
+ return;
426
+ }
427
+ const existingRoomId = roomManager.getRoomByHostId(socket.id);
428
+ if (existingRoomId) {
429
+ const existingSession = roomManager.getRoom(existingRoomId);
430
+ if (existingSession && existingSession.masterHostSocketId === socket.id) {
431
+ console.log(
432
+ `[server] Host ${socket.id} already has room ${existingRoomId}, returning existing.`
433
+ );
434
+ callback({ ok: true, roomId: existingRoomId });
435
+ return;
436
+ }
437
+ }
438
+ let roomId;
439
+ let attempts = 0;
440
+ do {
441
+ roomId = generateRoomCode();
442
+ attempts++;
443
+ if (attempts > 10) {
444
+ callback({
445
+ ok: false,
446
+ message: "Failed to generate unique room ID",
447
+ code: ErrorCode.CONNECTION_FAILED
448
+ });
449
+ return;
450
+ }
451
+ } while (roomManager.getRoom(roomId));
452
+ const session = {
453
+ roomId,
454
+ masterHostSocketId: socket.id,
455
+ focus: "SYSTEM",
456
+ controllers: /* @__PURE__ */ new Map(),
457
+ maxPlayers: maxPlayers ?? 8,
458
+ gameState: "paused"
459
+ };
460
+ roomManager.setRoom(roomId, session);
461
+ roomManager.setHostRoom(socket.id, roomId);
462
+ socket.join(roomId);
463
+ console.log(`[server] Created room ${roomId} for host ${socket.id}`);
464
+ callback({ ok: true, roomId });
465
+ io.to(roomId).emit("server:roomReady", { roomId });
466
+ }
467
+ );
468
+ socket.on(
469
+ "host:reconnect",
470
+ async (payload, callback) => {
471
+ if (isRateLimited("host-registration", HOST_REGISTRATION_RATE_LIMIT_MAX)) {
472
+ callback({
473
+ ok: false,
474
+ message: "Too many host registration attempts. Please try again.",
475
+ code: ErrorCode.SERVICE_UNAVAILABLE
476
+ });
477
+ return;
478
+ }
479
+ const parsed = hostReconnectSchema.safeParse(payload);
480
+ if (!parsed.success) {
481
+ callback({
482
+ ok: false,
483
+ message: parsed.error.message,
484
+ code: ErrorCode.INVALID_PAYLOAD
485
+ });
486
+ return;
487
+ }
488
+ const { roomId, apiKey } = parsed.data;
489
+ const verification = await authService.verifyApiKey(apiKey);
490
+ if (!verification.isVerified) {
491
+ callback({
492
+ ok: false,
493
+ message: verification.error,
494
+ code: ErrorCode.INVALID_API_KEY
495
+ });
496
+ return;
497
+ }
498
+ const session = roomManager.getRoom(roomId);
499
+ if (!session) {
500
+ callback({
501
+ ok: false,
502
+ message: "Room not found",
503
+ code: ErrorCode.ROOM_NOT_FOUND
504
+ });
505
+ return;
506
+ }
507
+ const previousMasterSocket = io.sockets.sockets.get(
508
+ session.masterHostSocketId
509
+ );
510
+ const isPreviousHostConnected = previousMasterSocket?.connected ?? false;
511
+ if (!isPreviousHostConnected || session.masterHostSocketId === socket.id) {
512
+ session.masterHostSocketId = socket.id;
513
+ roomManager.setRoom(roomId, session);
514
+ roomManager.setHostRoom(socket.id, roomId);
515
+ socket.join(roomId);
516
+ console.log(
517
+ `[server] Host ${socket.id} reconnected to room ${roomId}`
518
+ );
519
+ callback({ ok: true, roomId });
520
+ io.to(roomId).emit("server:roomReady", { roomId });
521
+ } else {
522
+ callback({
523
+ ok: false,
524
+ message: "Room already has an active host",
525
+ code: ErrorCode.ALREADY_CONNECTED
526
+ });
527
+ }
528
+ }
529
+ );
271
530
  socket.on(
272
531
  "system:launchGame",
273
532
  (payload, callback) => {
@@ -397,10 +656,33 @@ io.on(
397
656
  session.joinToken = void 0;
398
657
  session.activeControllerUrl = void 0;
399
658
  io.to(roomId).emit("client:unloadUi");
659
+ if (session.masterHostSocketId) {
660
+ const masterSocket = io.sockets.sockets.get(session.masterHostSocketId);
661
+ if (masterSocket) {
662
+ setTimeout(() => {
663
+ session.controllers.forEach((c) => {
664
+ const notice = {
665
+ controllerId: c.controllerId,
666
+ nickname: c.nickname,
667
+ player: c.playerProfile
668
+ };
669
+ masterSocket.emit("server:controllerJoined", notice);
670
+ });
671
+ }, 100);
672
+ }
673
+ }
400
674
  });
401
675
  socket.on(
402
676
  "host:register",
403
677
  async (payload, callback) => {
678
+ if (isRateLimited("host-registration", HOST_REGISTRATION_RATE_LIMIT_MAX)) {
679
+ callback({
680
+ ok: false,
681
+ message: "Too many host registration attempts. Please try again.",
682
+ code: ErrorCode.SERVICE_UNAVAILABLE
683
+ });
684
+ return;
685
+ }
404
686
  const parsed = hostRegistrationSchema.safeParse(payload);
405
687
  if (!parsed.success) {
406
688
  callback({
@@ -410,7 +692,16 @@ io.on(
410
692
  });
411
693
  return;
412
694
  }
413
- const { roomId, maxPlayers } = parsed.data;
695
+ const { roomId, maxPlayers, apiKey } = parsed.data;
696
+ const verification = await authService.verifyApiKey(apiKey);
697
+ if (!verification.isVerified) {
698
+ callback({
699
+ ok: false,
700
+ message: verification.error,
701
+ code: ErrorCode.INVALID_API_KEY
702
+ });
703
+ return;
704
+ }
414
705
  let session = roomManager.getRoom(roomId);
415
706
  if (session) {
416
707
  session.masterHostSocketId = socket.id;
@@ -434,6 +725,14 @@ io.on(
434
725
  }
435
726
  );
436
727
  socket.on("controller:join", (payload, callback) => {
728
+ if (isRateLimited("controller-join", CONTROLLER_JOIN_RATE_LIMIT_MAX)) {
729
+ callback({
730
+ ok: false,
731
+ message: "Too many join attempts. Please try again.",
732
+ code: ErrorCode.SERVICE_UNAVAILABLE
733
+ });
734
+ return;
735
+ }
437
736
  const parsed = controllerJoinSchema.safeParse(payload);
438
737
  if (!parsed.success) {
439
738
  callback({
@@ -552,6 +851,9 @@ io.on(
552
851
  return;
553
852
  }
554
853
  const { roomId, controllerId } = parsed.data;
854
+ if (!isControllerAuthorizedForRoom(roomId, controllerId)) {
855
+ return;
856
+ }
555
857
  const session = roomManager.getRoom(roomId);
556
858
  if (!session) {
557
859
  return;
@@ -566,18 +868,40 @@ io.on(
566
868
  socket.leave(roomId);
567
869
  });
568
870
  socket.on("controller:input", (payload) => {
871
+ const now = Date.now();
872
+ const input = payload?.input;
873
+ const hasActiveInput = input && (input.action === true || typeof input.vector === "object" && input.vector !== null && (Math.abs(input.vector.x ?? 0) > 0.01 || Math.abs(input.vector.y ?? 0) > 0.01));
874
+ if (hasActiveInput && (!lastServerInputLogTime || now - lastServerInputLogTime > 1e3)) {
875
+ lastServerInputLogTime = now;
876
+ }
569
877
  const result = controllerInputSchema.safeParse(payload);
570
878
  if (!result.success) {
879
+ if (!lastServerInputFailLogTime || now - lastServerInputFailLogTime > 1e3) {
880
+ lastServerInputFailLogTime = now;
881
+ }
882
+ return;
883
+ }
884
+ const { roomId, controllerId } = result.data;
885
+ if (!isControllerAuthorizedForRoom(roomId, controllerId)) {
571
886
  return;
572
887
  }
573
- const { roomId } = result.data;
574
888
  const session = roomManager.getRoom(roomId);
575
889
  if (!session) {
890
+ if (!lastServerInputFailLogTime || now - lastServerInputFailLogTime > 1e3) {
891
+ lastServerInputFailLogTime = now;
892
+ }
576
893
  return;
577
894
  }
578
895
  const targetHostId = roomManager.getActiveHostId(session);
579
896
  if (targetHostId) {
897
+ if (!lastServerInputLogTime || now - lastServerInputLogTime > 1e3) {
898
+ lastServerInputLogTime = now;
899
+ }
580
900
  io.to(targetHostId).emit("server:input", result.data);
901
+ } else {
902
+ if (!lastServerInputFailLogTime || now - lastServerInputFailLogTime > 1e3) {
903
+ lastServerInputFailLogTime = now;
904
+ }
581
905
  }
582
906
  });
583
907
  socket.on("controller:system", (payload) => {
@@ -586,6 +910,9 @@ io.on(
586
910
  return;
587
911
  }
588
912
  const { roomId, command } = parsed.data;
913
+ if (!isControllerAuthorizedForRoom(roomId)) {
914
+ return;
915
+ }
589
916
  const session = roomManager.getRoom(roomId);
590
917
  if (!session) {
591
918
  return;
@@ -624,6 +951,9 @@ io.on(
624
951
  return;
625
952
  }
626
953
  const { roomId, command } = parsed.data;
954
+ if (!isHostAuthorizedForRoom(roomId)) {
955
+ return;
956
+ }
627
957
  const session = roomManager.getRoom(roomId);
628
958
  if (!session) {
629
959
  return;
@@ -643,6 +973,9 @@ io.on(
643
973
  const result = controllerStateSchema.safeParse(payload);
644
974
  if (!result.success) return;
645
975
  const { roomId, state } = result.data;
976
+ if (!isHostAuthorizedForRoom(roomId)) {
977
+ return;
978
+ }
646
979
  const session = roomManager.getRoom(roomId);
647
980
  if (session) {
648
981
  if (state.gameState) {
@@ -689,6 +1022,9 @@ io.on(
689
1022
  });
690
1023
  socket.on("controller:play_sound", (payload) => {
691
1024
  const { roomId, soundId, volume, loop } = payload;
1025
+ if (!isControllerAuthorizedForRoom(roomId)) {
1026
+ return;
1027
+ }
692
1028
  const session = roomManager.getRoom(roomId);
693
1029
  if (!session) return;
694
1030
  io.to(roomManager.getActiveHostId(session)).emit("server:playSound", {
@@ -723,9 +1059,6 @@ io.on(
723
1059
  return;
724
1060
  }
725
1061
  if (controllerSession.socketId !== socket.id) {
726
- roomManager.deleteController(controllerSession.socketId);
727
- controllerSession.socketId = socket.id;
728
- roomManager.setController(socket.id, { roomId, controllerId });
729
1062
  socket.join(roomId);
730
1063
  }
731
1064
  const hostId = roomManager.getActiveHostId(session);
@@ -754,6 +1087,23 @@ io.on(
754
1087
  session.joinToken = void 0;
755
1088
  session.activeControllerUrl = void 0;
756
1089
  io.to(roomId).emit("client:unloadUi");
1090
+ if (session.masterHostSocketId) {
1091
+ const masterSocket = io.sockets.sockets.get(
1092
+ session.masterHostSocketId
1093
+ );
1094
+ if (masterSocket) {
1095
+ setTimeout(() => {
1096
+ session.controllers.forEach((c) => {
1097
+ const notice = {
1098
+ controllerId: c.controllerId,
1099
+ nickname: c.nickname,
1100
+ player: c.playerProfile
1101
+ };
1102
+ masterSocket.emit("server:controllerJoined", notice);
1103
+ });
1104
+ }, 100);
1105
+ }
1106
+ }
757
1107
  } else if (socket.id === session.masterHostSocketId) {
758
1108
  console.log(`[server] Host disconnected from room ${roomId}`);
759
1109
  setTimeout(() => {
@@ -788,4 +1138,4 @@ io.on(
788
1138
  httpServer.listen(PORT, () => {
789
1139
  console.log(`[air-jam] server listening on http://localhost:${PORT}`);
790
1140
  });
791
- //# sourceMappingURL=chunk-KMAEESOE.js.map
1141
+ //# sourceMappingURL=chunk-CGONPUNG.js.map