@zaisma/game-server 1.22.0 → 1.23.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://zaisma.com",
package/src/doors.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Door contracts (docs/DOORS.md). Every way into Zaisma, of every kind, states who may call it, what it may
3
+ * reach and what it trusts from other doors, and goes through checkDoor before its own code runs. Here, in the
4
+ * game server package's internal part, so a room on a developer's own server goes through the same check;
5
+ * never in the package's published index.
6
+ */
7
+ /** Callers the door's guard proves itself, from what it read of the request or the room. */
8
+ export type DoorCallerProvenByGuard =
9
+ /** Guests too. A door that shows a signed-in person more trusts `session` as well. */
10
+ "anyone"
11
+ /** A session that works. */
12
+ | "signed_in"
13
+ /** A session with a finished account, and no developer ban unless the door keeps it open to them. */
14
+ | "developer"
15
+ /** Cloudflare Access to /admin. */
16
+ | "admin"
17
+ /** Anyone in the room, spectators too. */
18
+ | "room_member"
19
+ /** A player in the room, not a spectator. */
20
+ | "room_player"
21
+ /** The room's host. */
22
+ | "room_host";
23
+ /** Callers a named function proves, because the proof takes the door's own data (a body's signature, a token's row). */
24
+ export type DoorCallerProvenByFunction =
25
+ /** The API router: an API key's access, or a session from Zaisma's own pages. */
26
+ "api_caller"
27
+ /** A tester or preview link's token in the address. */
28
+ | "link_holder"
29
+ /** A Sign in with Zaisma code or access token. */
30
+ | "token_holder"
31
+ /** Stripe or GitHub, by the signature on the body. */
32
+ | "signed_sender"
33
+ /** A game's own page, by the request's Origin. */
34
+ | "game_page"
35
+ /** A signed player pass. */
36
+ | "pass_holder";
37
+ export type DoorCaller = DoorCallerProvenByGuard | DoorCallerProvenByFunction;
38
+ /**
39
+ * What a door may touch. The first seven are the API's own words (ApiRoute's reaches, apps/site/src/developer.ts):
40
+ * `account`, `player_save`, `game`, `webhook`, `sign_in_app` and `forum_topic` are the caller's own or what the caller may read,
41
+ * and `nothing_private` is what anyone may see. The `any_` ones and `zaisma_settings` are only ever an admin's.
42
+ */
43
+ export type DoorReach = "account" | "player_save" | "game" | "webhook" | "sign_in_app" | "forum_topic" | "nothing_private" | "any_game" | "any_account" | "zaisma_settings" | "room" | "purchase";
44
+ /** What a door takes from another door, as the security map names them (scripts/security-map/doors.mjs). */
45
+ export type DoorTrust = "session" | "api_key" | "oauth_code" | "oauth_access" | "tester_link" | "preview_link" | "player_pass" | "admin_access" | "player_id" | "stripe_signature" | "github_signature" | "game_origin" | "purchase_receipt" | "passkey" | "email_code";
46
+ /** The function that proves a caller; a test checks it exists. */
47
+ export interface DoorProof {
48
+ source_path: string;
49
+ symbol_name: string;
50
+ }
51
+ export interface DoorContractFields {
52
+ reaches: DoorReach;
53
+ trusts: readonly DoorTrust[];
54
+ }
55
+ /** A caller a function proves can't be stated without naming the function, so it doesn't build. */
56
+ export type DoorContract = DoorContractFields & ({
57
+ whoMayCall: DoorCallerProvenByGuard;
58
+ } | {
59
+ whoMayCall: DoorCallerProvenByFunction;
60
+ provenBy: DoorProof;
61
+ });
62
+ /**
63
+ * The one check every door goes through. `proven` is who the door's guard found the caller to be; a caller a
64
+ * named function proves passes here, and that function refuses them, before anything else of the door runs.
65
+ */
66
+ export declare function checkDoor(contract: DoorContract, proven: readonly DoorCaller[]): boolean;
67
+ /** Where a door's path parts come from: `:name` parts, each a [^/]+ unless named here, and a final `*` for the rest. */
68
+ export declare function doorPathPattern(path: string, partPatterns?: Readonly<Record<string, string>>): RegExp;
package/src/doors.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Door contracts (docs/DOORS.md). Every way into Zaisma, of every kind, states who may call it, what it may
3
+ * reach and what it trusts from other doors, and goes through checkDoor before its own code runs. Here, in the
4
+ * game server package's internal part, so a room on a developer's own server goes through the same check;
5
+ * never in the package's published index.
6
+ */
7
+ /**
8
+ * The one check every door goes through. `proven` is who the door's guard found the caller to be; a caller a
9
+ * named function proves passes here, and that function refuses them, before anything else of the door runs.
10
+ */
11
+ export function checkDoor(contract, proven) {
12
+ return contract.whoMayCall === "anyone" || "provenBy" in contract || proven.includes(contract.whoMayCall);
13
+ }
14
+ /** Where a door's path parts come from: `:name` parts, each a [^/]+ unless named here, and a final `*` for the rest. */
15
+ export function doorPathPattern(path, partPatterns = {}) {
16
+ const parts = path.split("/").map((part) => {
17
+ if (part === "*")
18
+ return ".*";
19
+ if (part.startsWith(":"))
20
+ return `(?<${part.slice(1)}>${partPatterns[part.slice(1)] ?? "[^/]+"})`;
21
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
+ });
23
+ return new RegExp(`^${parts.join("/")}$`);
24
+ }
package/src/internal.d.ts CHANGED
@@ -12,3 +12,4 @@ export * from "./pass.ts";
12
12
  export * from "./names.ts";
13
13
  export * from "./chat.ts";
14
14
  export * from "./relay.ts";
15
+ export * from "./doors.ts";
package/src/internal.js CHANGED
@@ -14,3 +14,4 @@ export * from "./pass.js";
14
14
  export * from "./names.js";
15
15
  export * from "./chat.js";
16
16
  export * from "./relay.js";
17
+ export * from "./doors.js";
package/src/room.d.ts CHANGED
@@ -1334,6 +1334,70 @@ export interface StateSyncHooks {
1334
1334
  export declare const NO_MESSAGE_TYPE = "(none)";
1335
1335
  export declare const BYTES_MESSAGE_TYPE = "(bytes)";
1336
1336
  export declare const ZAISMA_MESSAGE_TYPE = "(zaisma)";
1337
+ /**
1338
+ * Every message a room takes (docs/DOORS.md). receive's switch is over these names, so a new one without a row
1339
+ * doesn't build. A spectator may send only what's for room members. hello comes first and proves the pass; chat is the host's to take before receive (readChatMessage);
1340
+ * bytes is every binary message (receiveBytes). A type with no row, maybe from a newer SDK, is counted and ignored.
1341
+ */
1342
+ export declare const ROOM_MESSAGE_DOORS: {
1343
+ readonly hello: {
1344
+ readonly whoMayCall: "pass_holder";
1345
+ readonly reaches: "room";
1346
+ readonly trusts: readonly ["player_pass"];
1347
+ readonly provenBy: {
1348
+ readonly source_path: "packages/game-server/src/pass.ts";
1349
+ readonly symbol_name: "verifyPass";
1350
+ };
1351
+ };
1352
+ readonly probe_answer: import("./doors.ts").DoorContractFields & {
1353
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1354
+ };
1355
+ readonly room: import("./doors.ts").DoorContractFields & {
1356
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1357
+ };
1358
+ readonly kick: import("./doors.ts").DoorContractFields & {
1359
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1360
+ };
1361
+ readonly here: import("./doors.ts").DoorContractFields & {
1362
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1363
+ };
1364
+ readonly idle: import("./doors.ts").DoorContractFields & {
1365
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1366
+ };
1367
+ readonly hidden: import("./doors.ts").DoorContractFields & {
1368
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1369
+ };
1370
+ readonly shown: import("./doors.ts").DoorContractFields & {
1371
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1372
+ };
1373
+ readonly back: import("./doors.ts").DoorContractFields & {
1374
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1375
+ };
1376
+ readonly message: import("./doors.ts").DoorContractFields & {
1377
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1378
+ };
1379
+ readonly bytes: import("./doors.ts").DoorContractFields & {
1380
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1381
+ };
1382
+ readonly chat: import("./doors.ts").DoorContractFields & {
1383
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1384
+ };
1385
+ readonly block: import("./doors.ts").DoorContractFields & {
1386
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1387
+ };
1388
+ readonly unblock: import("./doors.ts").DoorContractFields & {
1389
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1390
+ };
1391
+ readonly friend_request: import("./doors.ts").DoorContractFields & {
1392
+ whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1393
+ };
1394
+ readonly "zaisma:purchase": {
1395
+ readonly whoMayCall: "room_player";
1396
+ readonly reaches: "purchase";
1397
+ readonly trusts: readonly ["player_pass", "purchase_receipt"];
1398
+ };
1399
+ };
1400
+ export type RoomMessageType = keyof typeof ROOM_MESSAGE_DOORS;
1337
1401
  export declare const OTHER_MESSAGE_TYPE = "(other)";
1338
1402
  /** State sync's updates (stateSyncEnabled). */
1339
1403
  export declare const STATE_MESSAGE_TYPE = "(state)";
package/src/room.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { CHAT_CHECK_LIMITS, CHAT_ROOM_LIMITS } from "./chat.js";
10
10
  import { DatabaseError } from "./database.js";
11
+ import { checkDoor } from "./doors.js";
11
12
  import { isRoomCode, roomCode } from "./names.js";
12
13
  import { readSchedules } from "./schedules.js";
13
14
  import { roomCallChecks } from "./room-calls.js";
@@ -502,6 +503,35 @@ export const STATE_SYNC = Symbol("stateSync");
502
503
  export const NO_MESSAGE_TYPE = "(none)";
503
504
  export const BYTES_MESSAGE_TYPE = "(bytes)";
504
505
  export const ZAISMA_MESSAGE_TYPE = "(zaisma)";
506
+ const ROOM_MEMBER = { whoMayCall: "room_member", reaches: "room", trusts: ["player_pass"] };
507
+ const ROOM_PLAYER = { whoMayCall: "room_player", reaches: "room", trusts: ["player_pass"] };
508
+ const ROOM_HOST = { whoMayCall: "room_host", reaches: "room", trusts: ["player_pass"] };
509
+ /**
510
+ * Every message a room takes (docs/DOORS.md). receive's switch is over these names, so a new one without a row
511
+ * doesn't build. A spectator may send only what's for room members. hello comes first and proves the pass; chat is the host's to take before receive (readChatMessage);
512
+ * bytes is every binary message (receiveBytes). A type with no row, maybe from a newer SDK, is counted and ignored.
513
+ */
514
+ export const ROOM_MESSAGE_DOORS = {
515
+ hello: { whoMayCall: "pass_holder", reaches: "room", trusts: ["player_pass"], provenBy: { source_path: "packages/game-server/src/pass.ts", symbol_name: "verifyPass" } },
516
+ probe_answer: ROOM_MEMBER,
517
+ room: ROOM_HOST,
518
+ kick: ROOM_HOST,
519
+ here: ROOM_PLAYER,
520
+ idle: ROOM_PLAYER,
521
+ hidden: ROOM_PLAYER,
522
+ shown: ROOM_PLAYER,
523
+ back: ROOM_PLAYER,
524
+ message: ROOM_PLAYER,
525
+ bytes: ROOM_PLAYER,
526
+ chat: ROOM_PLAYER,
527
+ block: ROOM_PLAYER,
528
+ unblock: ROOM_PLAYER,
529
+ friend_request: ROOM_PLAYER,
530
+ "zaisma:purchase": { whoMayCall: "room_player", reaches: "purchase", trusts: ["player_pass", "purchase_receipt"] },
531
+ };
532
+ const isRoomMessageType = (type) => typeof type === "string" && Object.hasOwn(ROOM_MESSAGE_DOORS, type);
533
+ const PLAYER_PROVEN = ["room_member", "room_player"];
534
+ const HOST_PROVEN = ["room_member", "room_player", "room_host"];
505
535
  export const OTHER_MESSAGE_TYPE = "(other)";
506
536
  /** State sync's updates (stateSyncEnabled). */
507
537
  export const STATE_MESSAGE_TYPE = "(state)";
@@ -1759,7 +1789,10 @@ export class RoomCore {
1759
1789
  const presence = this.presence.get(playerId);
1760
1790
  if (presence)
1761
1791
  presence.seenAtMs = this.now();
1762
- switch (message.type) {
1792
+ const type = isRoomMessageType(message.type) ? message.type : null;
1793
+ if (type && !checkDoor(ROOM_MESSAGE_DOORS[type], this.hostPlayerId === playerId ? HOST_PROVEN : PLAYER_PROVEN))
1794
+ return "not_allowed";
1795
+ switch (type) {
1763
1796
  case "room":
1764
1797
  return this.hostChange(playerId, message);
1765
1798
  case "kick":
@@ -1838,7 +1871,7 @@ export class RoomCore {
1838
1871
  if (text.length <= ROOM_LIMITS.maximumGameMessageSizeBytes) {
1839
1872
  try {
1840
1873
  const message = JSON.parse(text);
1841
- if (message?.type === "probe_answer" && this.answer(spectatorId, message.probeNumber))
1874
+ if (isRoomMessageType(message?.type) && ROOM_MESSAGE_DOORS[message.type].whoMayCall === "room_member" && this.answer(spectatorId, message.probeNumber))
1842
1875
  return null;
1843
1876
  }
1844
1877
  catch {
@@ -1910,8 +1943,6 @@ export class RoomCore {
1910
1943
  * text between strangers), so the kicked player is told the host removed them.
1911
1944
  */
1912
1945
  hostKick(playerId, message) {
1913
- if (this.hostPlayerId !== playerId)
1914
- return "not_allowed";
1915
1946
  const kickedId = message.playerId;
1916
1947
  if (typeof kickedId !== "string" || kickedId === playerId || !this.players.has(kickedId))
1917
1948
  return "bad_message";
@@ -1920,8 +1951,6 @@ export class RoomCore {
1920
1951
  }
1921
1952
  /** The host hides, shows, opens, closes, turns friends' Join on or off, names or labels the room, when the game server allows it. */
1922
1953
  hostChange(playerId, message) {
1923
- if (this.hostPlayerId !== playerId)
1924
- return "not_allowed";
1925
1954
  const asked = message.room;
1926
1955
  if (!asked || typeof asked !== "object" || Array.isArray(asked))
1927
1956
  return "bad_message";