@zaisma/game-server 1.23.0 → 1.25.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/bin/node.js CHANGED
@@ -26,7 +26,7 @@ import { createServer } from "node:http";
26
26
  import { fileURLToPath, pathToFileURL } from "node:url";
27
27
  import { resolve } from "node:path";
28
28
  import { readFileSync, realpathSync } from "node:fs";
29
- import { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, ERROR_TEXT, NO_ANSWER_CLOSE_CODE, PASS_ISSUER_URL, OUTSIDE_ZAISMA_CHAT_SETTINGS, PassError, ReceiptError, REMOVED_CLOSE_CODE, REQUEST_ERROR_HTTP_STATUS, REQUEST_LIMITS, ROOM_LIMITS, ROOM_MESSAGE_LIMITS, ROOM_TUNING, WEBHOOK_LIMITS, GameServerError, RoomCore, chatSettingsOf, checkChat, readChatMessage, restoreInfoOf, passesAreFriends, passesHaveBlockBetween, answerRequest, startSchedules, checkRoomData, checkedOutsidePlayer, clientIdle, defineGameServer, errorMessage, guestPlayer, newServer, isPlayerToken, isRoomCode, readRequestBody, relay, requestError, runWebhook, tidyRoomInfo, verifyPass, verifyReceipt, verifyWebhook } from "../src/internal.js";
29
+ import { roomMessageProblem, BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, ERROR_TEXT, NO_ANSWER_CLOSE_CODE, PASS_ISSUER_URL, OUTSIDE_ZAISMA_CHAT_SETTINGS, PassError, ReceiptError, REMOVED_CLOSE_CODE, REQUEST_ERROR_HTTP_STATUS, REQUEST_LIMITS, ROOM_LIMITS, ROOM_MESSAGE_LIMITS, ROOM_TUNING, WEBHOOK_LIMITS, GameServerError, RoomCore, chatSettingsOf, checkChat, readChatMessage, restoreInfoOf, passesAreFriends, passesHaveBlockBetween, answerRequest, startSchedules, checkRoomData, checkedOutsidePlayer, clientIdle, defineGameServer, errorMessage, guestPlayer, newServer, isPlayerToken, isRoomCode, readRequestBody, relay, requestError, runWebhook, tidyRoomInfo, verifyPass, verifyReceipt, verifyWebhook } from "../src/internal.js";
30
30
  import { OutsideCallError } from "../src/outside-calls.js";
31
31
  import { startFastLane } from "./fast-lane.js";
32
32
  import { checkedStartServerOptions, localDatabase, localRoomStates, passKeysFrom, profilesFrom, receiptKeysFrom, secretsFromEnvironment } from "./node-parts.js";
@@ -666,7 +666,7 @@ export function startServer(options) {
666
666
  const hello = JSON.parse(text ?? "");
667
667
  // A Zaisma pass, or from a game played off Zaisma, its playerToken (null for a guest).
668
668
  const isOutside = hello.pass === undefined && isPlayerToken(hello.playerToken);
669
- if (hello.type !== "hello" || (typeof hello.pass !== "string" && !isOutside)) return refuse("no_hello");
669
+ if (hello.type !== "hello" || (typeof hello.pass !== "string" && !isOutside) || roomMessageProblem("hello", hello)) return refuse("no_hello");
670
670
  if (isOutside) playerToken = hello.playerToken;
671
671
  else pass = hello.pass;
672
672
  isSpectator = hello.isSpectator === true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.23.0",
3
+ "version": "1.25.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/sdk/zaisma.d.ts CHANGED
@@ -1653,6 +1653,7 @@ declare namespace Zaisma {
1653
1653
  * - ended: the match is over.
1654
1654
  * - kept_out: the player was removed for the rest of the match.
1655
1655
  * - refused: the game server's canJoin turned them away.
1656
+ * - rented_share_used: this player is on a rented-server network, and the share of the game's hosting such traffic gets is used. Zaisma's page tells them to turn off their VPN or sign in. Everyone else plays on.
1656
1657
  * - hosting_paused: the game's hosting on Zaisma is paused this month (its free allowance, or its developer's budget or credit, is used); new rooms open again when the month resets (UTC) or its developer adds credit, and a running match takes no new players and ends within the hour.
1657
1658
  * - account_needed: the player is a guest, and the game needs a Zaisma account for multiplayer or the whole game.
1658
1659
  * - game_not_open_to_player: the account is younger than the game's minimum age (set on the dashboard); the game never learns its age.
@@ -1684,6 +1685,7 @@ declare namespace Zaisma {
1684
1685
  | "kept_out"
1685
1686
  | "refused"
1686
1687
  | "hosting_paused"
1688
+ | "rented_share_used"
1687
1689
  | "account_needed"
1688
1690
  | "game_not_open_to_player"
1689
1691
  | "game_unavailable"
@@ -1720,6 +1722,7 @@ declare namespace Zaisma {
1720
1722
  * - game_unavailable: the game can't be played right now.
1721
1723
  * - no_request_handler: the game's server has no onRequest.
1722
1724
  * - game_server_error: onRequest threw, answered something that isn't plain JSON or over 1 MB, or took over 10 seconds.
1725
+ * - rented_share_used: this player is on a rented-server network, and the share of the game's hosting such traffic gets is used. Zaisma's page tells them to turn off their VPN or sign in. Everyone else plays on.
1723
1726
  * - hosting_paused: the game's hosting on Zaisma is paused this month; requests work again when the month resets (UTC) or its developer adds credit.
1724
1727
  * - slow_down: too many requests from this address; try again in a minute.
1725
1728
  * - bad_origin: the request didn't come from the game's own page.
@@ -1738,6 +1741,7 @@ declare namespace Zaisma {
1738
1741
  | "no_request_handler"
1739
1742
  | "game_server_error"
1740
1743
  | "hosting_paused"
1744
+ | "rented_share_used"
1741
1745
  | "slow_down"
1742
1746
  | "bad_origin"
1743
1747
  | "busy";
package/sdk/zaisma.js CHANGED
@@ -179,6 +179,7 @@
179
179
  return response.json().then(function (answer) {
180
180
  if (response.ok) return answer;
181
181
  var error = answer && answer.error;
182
+ if (error && error.code === "rented_share_used") tellRentedShareUsed();
182
183
  if (error && error.code) throw codedError(error.code, error.message);
183
184
  requestFailed();
184
185
  }, requestFailed);
@@ -218,10 +219,52 @@
218
219
  return keyPromise;
219
220
  }
220
221
 
222
+ /**
223
+ * What a Zaisma room or request hears about where this player plays from on the meter: the word /v1/pass gave beside the pass, which
224
+ * this SDK can't read, and two things an honest browser can say. Nothing that tells this person from anyone else. Only rooms read them.
225
+ */
226
+ var keptPlayShare = null;
227
+ var isInputSeen = false;
228
+ var graphicsChipAnswer = null;
229
+ function hasGraphicsChip() {
230
+ if (graphicsChipAnswer !== null) return graphicsChipAnswer;
231
+ graphicsChipAnswer = false;
232
+ try {
233
+ var webgl = document.createElement("canvas").getContext("webgl2") || document.createElement("canvas").getContext("webgl");
234
+ if (webgl) {
235
+ var rendererInfo = webgl.getExtension("WEBGL_debug_renderer_info");
236
+ var renderer = rendererInfo ? String(webgl.getParameter(rendererInfo.UNMASKED_RENDERER_WEBGL)) : "";
237
+ graphicsChipAnswer = !/swiftshader|llvmpipe|software|basic render/i.test(renderer);
238
+ var losing = webgl.getExtension("WEBGL_lose_context");
239
+ if (losing) losing.loseContext();
240
+ }
241
+ } catch (error) {
242
+ graphicsChipAnswer = true; // a browser that won't say counts as one that has one
243
+ }
244
+ return graphicsChipAnswer;
245
+ }
246
+ function playShareFacts() {
247
+ return { playShare: keptPlayShare || undefined, hasGraphicsChip: hasGraphicsChip(), isInputSeen: isInputSeen };
248
+ }
249
+ function firstInput() {
250
+ if (isInputSeen) return;
251
+ isInputSeen = true;
252
+ // A room told of no graphics chip and no input hears of the input now, without waiting for the game to send anything.
253
+ if (graphicsChipAnswer === false) joined.forEach(function (room) { if (room._ws && room._ws.readyState === 1) room._say({ type: "here" }); });
254
+ }
255
+ var FIRST_INPUTS = ["keydown", "pointerdown", "wheel", "touchstart"];
256
+ FIRST_INPUTS.forEach(function (type) { addEventListener(type, firstInput, { capture: true, passive: true }); });
257
+ addEventListener("gamepadconnected", firstInput);
258
+ /** Zaisma's game page says why a player on a rented network is stopped, with Sign in: only Zaisma can do that step. */
259
+ function tellRentedShareUsed() {
260
+ if (config.site && window.parent !== window) window.parent.postMessage({ type: "zaisma:rented-share-used" }, config.site);
261
+ }
262
+
221
263
  /** The pass this tab kept, while it has 10 minutes or more left, else null. */
222
264
  function keptPass() {
223
265
  try {
224
266
  var kept = JSON.parse(sessionStorage.getItem(passKey) || "null");
267
+ keptPlayShare = sessionStorage.getItem(passKey + ":share");
225
268
  return kept && Date.parse(kept.expiresAt) - Date.now() > 10 * 60 * 1000 ? kept : null;
226
269
  } catch (error) {
227
270
  return null; // storage off: fetch a new pass
@@ -243,7 +286,11 @@
243
286
  if (kept && kept.kind === "guest" && given.playerKind === "guest") return kept;
244
287
  return post("/v1/pass", { gameSlug: config.game, roomKey: given.key }).then(function (answer) {
245
288
  var mine = { id: answer.player.id, name: answer.player.name, kind: answer.player.kind, pass: answer.pass, expiresAt: answer.expiresAt };
246
- try { sessionStorage.setItem(passKey, JSON.stringify(mine)); } catch (error) { /* fine */ }
289
+ keptPlayShare = answer.playShare || null;
290
+ try {
291
+ sessionStorage.setItem(passKey, JSON.stringify(mine));
292
+ if (keptPlayShare) sessionStorage.setItem(passKey + ":share", keptPlayShare);
293
+ } catch (error) { /* fine */ }
247
294
  return mine;
248
295
  });
249
296
  }, function (error) {
@@ -696,7 +743,7 @@
696
743
  };
697
744
 
698
745
  // Codes that mean this room won't take the player, so joining fails instead of trying again.
699
- var REFUSED = ["room_full", "room_closed", "ended", "bad_pass", "bad_player_token", "too_big", "game_unavailable", "kept_out", "refused", "hosting_paused", "account_needed", "game_not_open_to_player", "blocked_player_here", "other_version", "spectators_full", "room_not_running", "already_playing", "bad_origin"];
746
+ var REFUSED = ["room_full", "room_closed", "ended", "bad_pass", "bad_player_token", "too_big", "game_unavailable", "kept_out", "refused", "hosting_paused", "rented_share_used", "account_needed", "game_not_open_to_player", "blocked_player_here", "other_version", "spectators_full", "room_not_running", "already_playing", "bad_origin"];
700
747
 
701
748
  /**
702
749
  * Whether a Zaisma room on another version (other_version) has the page reload the game at it: on the
@@ -730,7 +777,15 @@
730
777
  room._probeIntervalSec = null;
731
778
  var hello = { type: "hello", protocolVersionNumber: PROTOCOL_VERSION_NUMBER, room: room._made, joinData: room._joinData, idleTimeoutSec: room._clientIdle.idleTimeoutSec, idleCheckEnabled: room._clientIdle.idleCheckEnabled };
732
779
  if (room._isOffZaisma) hello.playerToken = offZaisma.playerToken;
733
- else hello.pass = room._pass;
780
+ else {
781
+ hello.pass = room._pass;
782
+ if (!room._serverUrl) {
783
+ var told = playShareFacts();
784
+ hello.playShare = told.playShare;
785
+ hello.hasGraphicsChip = told.hasGraphicsChip;
786
+ hello.isInputSeen = told.isInputSeen;
787
+ }
788
+ }
734
789
  // quickJoin's first try at a room: rather another room than one with someone this player blocked.
735
790
  if (room._avoidsBlockedPlayers) hello.avoidBlockedPlayers = true;
736
791
  // Any quickJoin: Zaisma learns the game joins players by itself, for its Rooms tab.
@@ -860,6 +915,7 @@
860
915
  window.parent.postMessage(room._spectating({ type: "zaisma:reload", code: room.roomCode, versionId: message.versionId }), config.site);
861
916
  } else if (message.type === "error") {
862
917
  room._emit("error", { code: message.code, message: message.message });
918
+ if (message.code === "rented_share_used") tellRentedShareUsed();
863
919
  if (room._ready && REFUSED.indexOf(message.code) !== -1) {
864
920
  room._done = true;
865
921
  room._ready.reject(codedError(message.code, message.message));
@@ -1840,7 +1896,11 @@
1840
1896
  var base = (serverUrl || config.rooms).replace(/^ws/, "http");
1841
1897
  var isOffZaisma = !config.rooms;
1842
1898
  // Off Zaisma there's no pass: the playerToken goes in its place.
1843
- var sender = isOffZaisma ? Promise.resolve({ playerToken: offZaisma.playerToken }) : getPass().then(function (mine) { return { pass: mine.pass }; });
1899
+ var sender = isOffZaisma ? Promise.resolve({ playerToken: offZaisma.playerToken }) : getPass().then(function (mine) {
1900
+ if (serverUrl) return { pass: mine.pass };
1901
+ var told = playShareFacts();
1902
+ return { pass: mine.pass, playShare: told.playShare, hasGraphicsChip: told.hasGraphicsChip, isInputSeen: told.isInputSeen };
1903
+ });
1844
1904
  return sender.then(function (who) {
1845
1905
  who.request = JSON.parse(requestText);
1846
1906
  return fetch(base + "/v1/requests/" + (isOffZaisma ? offZaisma.gameSlug : config.game), { method: "POST", headers: { "Content-Type": "text/plain" }, body: JSON.stringify(who) }).then(answered, requestFailed);
package/src/doors.d.ts CHANGED
@@ -48,9 +48,104 @@ export interface DoorProof {
48
48
  source_path: string;
49
49
  symbol_name: string;
50
50
  }
51
+ /**
52
+ * What a door takes, as the small part of JSON Schema Zaisma's schemas use. Only the helpers below make one, and each
53
+ * takes its bound, so "accepts anything" can't be stated. Only a game's own data goes by size alone, at the caps it has.
54
+ */
55
+ export type DoorShape = Readonly<Record<string, unknown>> & {
56
+ readonly [SHAPE_MARK]: true;
57
+ };
58
+ declare const SHAPE_MARK: unique symbol;
59
+ export declare const text: (maximumCharacterCount: number, pattern?: string) => DoorShape;
60
+ export declare const oneOfWords: (...words: readonly string[]) => DoorShape;
61
+ export declare const integer: (minimumNumber?: number, maximumNumber?: number) => DoorShape;
62
+ export declare const realNumber: (minimumNumber: number, maximumNumber: number) => DoorShape;
63
+ export declare const yesOrNo: DoorShape;
64
+ export declare const anyOf: (...shapes: readonly DoorShape[]) => DoorShape;
65
+ export declare const orNothing: (shape: DoorShape) => DoorShape;
66
+ export declare const listOf: (items: DoorShape, maximumItemCount: number, minimumItemCount?: number) => DoorShape;
67
+ /** A closed object: these fields and no others, the required ones named. */
68
+ export declare const fieldsOf: (properties: Readonly<Record<string, DoorShape>>, required?: readonly string[]) => DoorShape;
69
+ /** A game's own data, of any shape: its size only, in characters of its JSON. */
70
+ export declare const gameData: (maximumSizeBytes: number) => DoorShape;
71
+ /** A door's body, when it takes one. No read gets past maximumSizeBytes, whatever the request's headers say; raw bytes go by size alone, and the door reads them itself. */
72
+ export type DoorBody = {
73
+ kind: "json";
74
+ fields: Readonly<Record<string, DoorShape>>;
75
+ required?: readonly string[];
76
+ maximumSizeBytes: number;
77
+ isOptional?: true;
78
+ } | {
79
+ kind: "form";
80
+ fields: Readonly<Record<string, DoorShape>>;
81
+ fileNames?: readonly string[];
82
+ maximumSizeBytes: number;
83
+ } | {
84
+ kind: "bytes";
85
+ maximumSizeBytes: number;
86
+ };
87
+ /** What a door takes: its query's names and its body. A query's values arrive as text: integer and boolean shapes read theirs. */
88
+ export interface DoorTakes {
89
+ query: Readonly<Record<string, DoorShape>>;
90
+ body: DoorBody | null;
91
+ }
92
+ /** Nothing: a door with no query it reads and no body. */
93
+ export declare const TAKES_NOTHING: DoorTakes;
94
+ /** What a door answers: a JSON schema, checked on live requests and cut to what it declares, or a kind with nothing to cut. */
95
+ export type DoorAnswer = {
96
+ kind: "json";
97
+ schema: Readonly<Record<string, unknown>>;
98
+ } | {
99
+ kind: "page" | "empty" | "redirect" | "file" | "bytes";
100
+ };
101
+ export declare const ANSWERS_PAGE: DoorAnswer;
102
+ export declare const ANSWERS_EMPTY: DoorAnswer;
103
+ export declare const ANSWERS_REDIRECT: DoorAnswer;
104
+ export declare const ANSWERS_FILE: DoorAnswer;
105
+ export declare const ANSWERS_BYTES: DoorAnswer;
106
+ export declare const answersJson: (schema: Readonly<Record<string, unknown>>) => DoorAnswer;
107
+ /** A door's limit in calls a minute, from this short list. Each is a rate limit binding in the Workers that use it: LIMIT_<LEVEL>. */
108
+ export declare const DOOR_LIMIT_LEVELS: {
109
+ readonly very_rare: 2;
110
+ readonly guess: 5;
111
+ readonly rare: 10;
112
+ readonly steady: 30;
113
+ readonly busy: 120;
114
+ readonly building: 1000;
115
+ };
116
+ export type DoorLimitLevel = keyof typeof DOOR_LIMIT_LEVELS;
117
+ /** Why a door has no limit of its own: from this list only, and the daily review reads every door that gives one. */
118
+ export declare const DOOR_NO_LIMIT_REASONS: {
119
+ readonly answers_from_memory: "It answers fixed bytes from memory and reads nothing.";
120
+ readonly cached_file: "It serves a file Cloudflare caches, with no database behind it and no egress fee.";
121
+ readonly admin_only: "Cloudflare Access stands in front of it, and only Zaisma's own people get through.";
122
+ readonly signed_by_sender: "It reads nothing until the sender's signature on the body checks out.";
123
+ readonly counted_by_its_call: "All it does is make calls that have their own limit.";
124
+ readonly room_token_bucket: "A room's message bucket takes a token for each message, per player, before any door runs.";
125
+ };
126
+ export type DoorNoLimitReason = keyof typeof DOOR_NO_LIMIT_REASONS;
127
+ /**
128
+ * How much a door can be used. A limit counts the player, the account or the game wherever Zaisma knows it, and an
129
+ * address only before it knows, at the level a whole building clears, so 25 players on one school's wifi play like
130
+ * anyone. group shares one counter between doors; with none, each door counts on its own.
131
+ */
132
+ export type DoorLimit = {
133
+ level: DoorLimitLevel;
134
+ counts: "player" | "account" | "game";
135
+ group?: string;
136
+ } | {
137
+ level: "building";
138
+ counts: "address";
139
+ group?: string;
140
+ } | {
141
+ whyNoLimit: DoorNoLimitReason;
142
+ };
51
143
  export interface DoorContractFields {
52
144
  reaches: DoorReach;
53
145
  trusts: readonly DoorTrust[];
146
+ takes: DoorTakes;
147
+ answers: DoorAnswer;
148
+ limit: DoorLimit;
54
149
  }
55
150
  /** A caller a function proves can't be stated without naming the function, so it doesn't build. */
56
151
  export type DoorContract = DoorContractFields & ({
@@ -59,6 +154,52 @@ export type DoorContract = DoorContractFields & ({
59
154
  whoMayCall: DoorCallerProvenByFunction;
60
155
  provenBy: DoorProof;
61
156
  });
157
+ /** A Worker's rate limit bindings, by level. A self-hosted room has none, and a missing one lets the call through. */
158
+ /** A rate limit binding, as Workers gives one. */
159
+ export interface DoorLimitBinding {
160
+ limit(options: {
161
+ key: string;
162
+ }): Promise<object>;
163
+ }
164
+ export type DoorLimitBindings = {
165
+ [Level in DoorLimitLevel as `LIMIT_${Uppercase<Level>}`]?: DoorLimitBinding;
166
+ };
167
+ /** Whether a call goes past a level's limit: door's own counter, for one subject. Keys never leave Cloudflare's counter. */
168
+ export declare function isPastLevel(env: DoorLimitBindings, level: DoorLimitLevel, counterName: string, subject: string): Promise<boolean>;
169
+ /** Whether a call goes past a door's limit. doorName names its counter unless its limit shares a group. */
170
+ export declare function isPastDoorLimit(env: DoorLimitBindings, limit: DoorLimit, doorName: string, subject: string): Promise<boolean>;
171
+ type Schema = Readonly<Record<string, unknown>>;
172
+ /** Where a value doesn't match its shape or schema, as "path: problem" lines; [] when it fits. Answers and inputs both come here. */
173
+ export declare function schemaProblems(schema: Schema, given: unknown, path?: string): string[];
174
+ /** Where a value's types don't match a schema; null reads as left out, and what a call behind it allows, names included, stays the call's to say, with its own fix. */
175
+ export declare function typeProblems(schema: Schema, given: unknown, path?: string): string[];
176
+ /**
177
+ * An answer with every field its schema didn't declare taken out, and the paths it took (a list's index as []): the
178
+ * caller still gets the answer. Nothing else changes, and an answer with nothing to cut comes back as the same value.
179
+ * Only closed objects cut, so a game's own rows and arguments pass as they are.
180
+ */
181
+ export declare function cutUndeclared(schema: Schema, given: unknown, path?: string): {
182
+ kept: unknown;
183
+ cutPaths: string[];
184
+ };
185
+ /** The first problem with a query, as [name, what's wrong], or null. isOpenToOtherNames: a page ignores names it doesn't read, since share links pick some up. */
186
+ export declare function queryProblem(fields: Readonly<Record<string, DoorShape>>, query: URLSearchParams, isOpenToOtherNames?: boolean): {
187
+ field: string;
188
+ problem: string;
189
+ } | null;
190
+ /** The first problem with a JSON body's fields: closed, each field its shape, the required ones there. */
191
+ export declare function bodyProblem(fields: Readonly<Record<string, DoorShape>>, required: readonly string[], body: Readonly<Record<string, unknown>>): {
192
+ field: string;
193
+ problem: string;
194
+ } | null;
195
+ /** The first problem with a form's fields: its names closed, each value text, or a list where it may repeat. A form's text boxes send what they send, so its numbers are the door's to read. */
196
+ export declare function formProblem(fields: Readonly<Record<string, DoorShape>>, fileNames: readonly string[], form: {
197
+ keys(): IterableIterator<string>;
198
+ getAll(name: string): unknown[];
199
+ }): {
200
+ field: string;
201
+ problem: string;
202
+ } | null;
62
203
  /**
63
204
  * The one check every door goes through. `proven` is who the door's guard found the caller to be; a caller a
64
205
  * named function proves passes here, and that function refuses them, before anything else of the door runs.
@@ -66,3 +207,4 @@ export type DoorContract = DoorContractFields & ({
66
207
  export declare function checkDoor(contract: DoorContract, proven: readonly DoorCaller[]): boolean;
67
208
  /** Where a door's path parts come from: `:name` parts, each a [^/]+ unless named here, and a final `*` for the rest. */
68
209
  export declare function doorPathPattern(path: string, partPatterns?: Readonly<Record<string, string>>): RegExp;
210
+ export {};
package/src/doors.js CHANGED
@@ -4,6 +4,263 @@
4
4
  * game server package's internal part, so a room on a developer's own server goes through the same check;
5
5
  * never in the package's published index.
6
6
  */
7
+ const shapeOf = (schema) => schema;
8
+ export const text = (maximumCharacterCount, pattern) => shapeOf({ type: "string", maxLength: maximumCharacterCount, ...(pattern ? { pattern } : {}) });
9
+ export const oneOfWords = (...words) => shapeOf({ type: "string", maxLength: Math.max(...words.map((word) => word.length)), enum: words });
10
+ export const integer = (minimumNumber = 0, maximumNumber = Number.MAX_SAFE_INTEGER) => shapeOf({ type: "integer", minimum: minimumNumber, maximum: maximumNumber });
11
+ export const realNumber = (minimumNumber, maximumNumber) => shapeOf({ type: "number", minimum: minimumNumber, maximum: maximumNumber });
12
+ export const yesOrNo = shapeOf({ type: "boolean" });
13
+ export const anyOf = (...shapes) => shapeOf({ anyOf: shapes });
14
+ export const orNothing = (shape) => anyOf(shape, shapeOf({ type: "null" }));
15
+ export const listOf = (items, maximumItemCount, minimumItemCount = 0) => shapeOf({ type: "array", items, maxItems: maximumItemCount, ...(minimumItemCount ? { minItems: minimumItemCount } : {}) });
16
+ /** A closed object: these fields and no others, the required ones named. */
17
+ export const fieldsOf = (properties, required = []) => shapeOf({ type: "object", properties, required, additionalProperties: false });
18
+ /** A game's own data, of any shape: its size only, in characters of its JSON. */
19
+ export const gameData = (maximumSizeBytes) => shapeOf({ isGameData: true, maximumSizeBytes });
20
+ /** Nothing: a door with no query it reads and no body. */
21
+ export const TAKES_NOTHING = { query: {}, body: null };
22
+ export const ANSWERS_PAGE = { kind: "page" };
23
+ export const ANSWERS_EMPTY = { kind: "empty" };
24
+ export const ANSWERS_REDIRECT = { kind: "redirect" };
25
+ export const ANSWERS_FILE = { kind: "file" };
26
+ export const ANSWERS_BYTES = { kind: "bytes" };
27
+ export const answersJson = (schema) => ({ kind: "json", schema });
28
+ /** A door's limit in calls a minute, from this short list. Each is a rate limit binding in the Workers that use it: LIMIT_<LEVEL>. */
29
+ export const DOOR_LIMIT_LEVELS = { very_rare: 2, guess: 5, rare: 10, steady: 30, busy: 120, building: 1000 };
30
+ /** Why a door has no limit of its own: from this list only, and the daily review reads every door that gives one. */
31
+ export const DOOR_NO_LIMIT_REASONS = {
32
+ answers_from_memory: "It answers fixed bytes from memory and reads nothing.",
33
+ cached_file: "It serves a file Cloudflare caches, with no database behind it and no egress fee.",
34
+ admin_only: "Cloudflare Access stands in front of it, and only Zaisma's own people get through.",
35
+ signed_by_sender: "It reads nothing until the sender's signature on the body checks out.",
36
+ counted_by_its_call: "All it does is make calls that have their own limit.",
37
+ room_token_bucket: "A room's message bucket takes a token for each message, per player, before any door runs.",
38
+ };
39
+ /** Whether a call goes past a level's limit: door's own counter, for one subject. Keys never leave Cloudflare's counter. */
40
+ export async function isPastLevel(env, level, counterName, subject) {
41
+ const binding = env[`LIMIT_${level.toUpperCase()}`];
42
+ if (!binding)
43
+ return false;
44
+ const answer = await binding.limit({ key: `${counterName}:${subject}` });
45
+ return !("success" in answer && answer.success === true);
46
+ }
47
+ /** Whether a call goes past a door's limit. doorName names its counter unless its limit shares a group. */
48
+ export async function isPastDoorLimit(env, limit, doorName, subject) {
49
+ return "whyNoLimit" in limit ? false : isPastLevel(env, limit.level, limit.group ?? doorName, subject);
50
+ }
51
+ /** A pattern once compiled: shapes check on every call. */
52
+ const compiledPatterns = new Map();
53
+ function compiled(pattern) {
54
+ let found = compiledPatterns.get(pattern);
55
+ if (!found)
56
+ compiledPatterns.set(pattern, (found = new RegExp(pattern)));
57
+ return found;
58
+ }
59
+ const typesOf = (schema) => {
60
+ const { type } = schema;
61
+ if (Array.isArray(type))
62
+ return type;
63
+ if (typeof type === "string")
64
+ return [type];
65
+ if (Array.isArray(schema.enum))
66
+ return [...new Set(schema.enum.map((allowed) => (allowed === null ? "null" : typeof allowed)))];
67
+ return [];
68
+ };
69
+ /** Where a value doesn't match its shape or schema, as "path: problem" lines; [] when it fits. Answers and inputs both come here. */
70
+ export function schemaProblems(schema, given, path = "answer") {
71
+ // anyOf: it fits when one of them fits; otherwise say how it misses the nearest.
72
+ if (Array.isArray(schema.anyOf)) {
73
+ const misses = schema.anyOf.map((option) => schemaProblems(option, given, path));
74
+ return misses.some((problems) => problems.length === 0) ? [] : misses.sort((first, second) => first.length - second.length)[0];
75
+ }
76
+ if (schema.isGameData === true) {
77
+ const sizeBytes = JSON.stringify(given)?.length ?? 0;
78
+ return sizeBytes > schema.maximumSizeBytes ? [`${path}: over ${schema.maximumSizeBytes} bytes`] : [];
79
+ }
80
+ const out = [];
81
+ const types = typesOf(schema);
82
+ const actual = given === null ? "null" : Array.isArray(given) ? "array" : Number.isInteger(given) ? "integer" : typeof given;
83
+ const fits = types.length === 0 || types.includes(actual) || (actual === "integer" && types.includes("number"));
84
+ if (!fits)
85
+ return [`${path}: is ${actual}, expected ${types.join(" or ")}`];
86
+ if (Array.isArray(schema.enum) && !schema.enum.includes(given))
87
+ out.push(`${path}: ${JSON.stringify(given)} isn't one of ${JSON.stringify(schema.enum)}`);
88
+ if (typeof given === "string") {
89
+ if (typeof schema.maxLength === "number" && given.length > schema.maxLength)
90
+ out.push(`${path}: over ${schema.maxLength} characters`);
91
+ if (schema.format === "date-time" && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(given))
92
+ out.push(`${path}: ${given} isn't an ISO time in UTC`);
93
+ if (schema.format === "date" && !/^\d{4}-\d{2}-\d{2}$/.test(given))
94
+ out.push(`${path}: ${given} isn't YYYY-MM-DD`);
95
+ if (schema.format === "uri" && !/^https?:\/\//.test(given))
96
+ out.push(`${path}: ${given} isn't a link`);
97
+ if (typeof schema.pattern === "string" && !compiled(schema.pattern).test(given))
98
+ out.push(`${path}: ${given} doesn't match ${schema.pattern}`);
99
+ }
100
+ if (typeof given === "number") {
101
+ if (typeof schema.minimum === "number" && given < schema.minimum)
102
+ out.push(`${path}: below ${schema.minimum}`);
103
+ if (typeof schema.maximum === "number" && given > schema.maximum)
104
+ out.push(`${path}: above ${schema.maximum}`);
105
+ }
106
+ if (Array.isArray(given)) {
107
+ if (typeof schema.maxItems === "number" && given.length > schema.maxItems)
108
+ return [...out, `${path}: over ${schema.maxItems} items`];
109
+ if (typeof schema.minItems === "number" && given.length < schema.minItems)
110
+ out.push(`${path}: under ${schema.minItems} items`);
111
+ if (schema.items)
112
+ given.forEach((entry, index) => out.push(...schemaProblems(schema.items, entry, `${path}[${index}]`)));
113
+ }
114
+ if (actual === "object" && schema.properties) {
115
+ const properties = schema.properties;
116
+ const fields = given;
117
+ for (const name of schema.required ?? [])
118
+ if (!(name in fields))
119
+ out.push(`${path}.${name}: missing`);
120
+ if (schema.additionalProperties === false)
121
+ for (const name of Object.keys(fields))
122
+ if (!(name in properties))
123
+ out.push(`${path}.${name}: not in the schema`);
124
+ for (const [name, property] of Object.entries(properties))
125
+ if (name in fields)
126
+ out.push(...schemaProblems(property, fields[name], `${path}.${name}`));
127
+ }
128
+ return out;
129
+ }
130
+ /** Where a value's types don't match a schema; null reads as left out, and what a call behind it allows, names included, stays the call's to say, with its own fix. */
131
+ export function typeProblems(schema, given, path = "answer") {
132
+ if (given === null)
133
+ return [];
134
+ if (Array.isArray(schema.anyOf)) {
135
+ const misses = schema.anyOf.map((option) => typeProblems(option, given, path));
136
+ return misses.some((problems) => problems.length === 0) ? [] : misses.sort((first, second) => first.length - second.length)[0];
137
+ }
138
+ const types = typesOf(schema);
139
+ const actual = Array.isArray(given) ? "array" : Number.isInteger(given) ? "integer" : typeof given;
140
+ if (types.length && !types.includes(actual) && !(actual === "integer" && types.includes("number")))
141
+ return [`${path}: is ${actual}, expected ${types.join(" or ")}`];
142
+ if (actual !== "object" || !schema.properties)
143
+ return [];
144
+ const properties = schema.properties;
145
+ return Object.entries(given).flatMap(([name, fieldValue]) => (properties[name] ? typeProblems(properties[name], fieldValue, `${path}.${name}`) : []));
146
+ }
147
+ /**
148
+ * An answer with every field its schema didn't declare taken out, and the paths it took (a list's index as []): the
149
+ * caller still gets the answer. Nothing else changes, and an answer with nothing to cut comes back as the same value.
150
+ * Only closed objects cut, so a game's own rows and arguments pass as they are.
151
+ */
152
+ export function cutUndeclared(schema, given, path = "answer") {
153
+ if (Array.isArray(schema.anyOf)) {
154
+ const options = schema.anyOf;
155
+ // The option that fits as it is. Or else: an option of the answer's type, one that fits once cut, then the one that takes least,
156
+ // so an answer short of one field isn't emptied to look like a shorter option.
157
+ if (options.some((option) => schemaProblems(option, given, path).length === 0))
158
+ return { kept: given, cutPaths: [] };
159
+ const tried = options.map((option) => {
160
+ const cut = cutUndeclared(option, given, path);
161
+ return { cut, isOtherType: schemaProblems({ type: typesOf(option) }, given, path).length > 0, problemCount: schemaProblems(option, cut.kept, path).length };
162
+ });
163
+ tried.sort((first, second) => Number(first.isOtherType) - Number(second.isOtherType) || Number(first.problemCount > 0) - Number(second.problemCount > 0) || first.cut.cutPaths.length - second.cut.cutPaths.length || first.problemCount - second.problemCount);
164
+ return tried[0]?.cut ?? { kept: given, cutPaths: [] };
165
+ }
166
+ if (Array.isArray(given)) {
167
+ if (!schema.items)
168
+ return { kept: given, cutPaths: [] };
169
+ const cutPaths = [];
170
+ let changed = null;
171
+ given.forEach((entry, index) => {
172
+ const cut = cutUndeclared(schema.items, entry, `${path}[]`);
173
+ if (!cut.cutPaths.length)
174
+ return;
175
+ cutPaths.push(...cut.cutPaths);
176
+ (changed ??= [...given])[index] = cut.kept;
177
+ });
178
+ return { kept: changed ?? given, cutPaths: [...new Set(cutPaths)] };
179
+ }
180
+ if (given === null || typeof given !== "object" || !schema.properties)
181
+ return { kept: given, cutPaths: [] };
182
+ const properties = schema.properties;
183
+ const kept = {};
184
+ const cutPaths = [];
185
+ for (const [name, fieldValue] of Object.entries(given)) {
186
+ const property = properties[name];
187
+ if (!property) {
188
+ if (schema.additionalProperties === false)
189
+ cutPaths.push(`${path}.${name}`);
190
+ else
191
+ kept[name] = fieldValue;
192
+ continue;
193
+ }
194
+ const cut = cutUndeclared(property, fieldValue, `${path}.${name}`);
195
+ kept[name] = cut.kept;
196
+ cutPaths.push(...cut.cutPaths);
197
+ }
198
+ return cutPaths.length ? { kept, cutPaths } : { kept: given, cutPaths: [] };
199
+ }
200
+ /** A query value read as its shape says: a number or a yes or no comes as text. Text that can't be one stays text, so the shape refuses it. */
201
+ function queryValueOf(shape, given) {
202
+ if (Array.isArray(shape.anyOf)) {
203
+ for (const option of shape.anyOf) {
204
+ const read = queryValueOf(option, given);
205
+ if (schemaProblems(option, read).length === 0)
206
+ return read;
207
+ }
208
+ return given;
209
+ }
210
+ if ((shape.type === "integer" || shape.type === "number") && given.trim() !== "" && Number.isFinite(Number(given)))
211
+ return Number(given);
212
+ if (shape.type === "boolean" && (given === "true" || given === "false"))
213
+ return given === "true";
214
+ return given;
215
+ }
216
+ /** The first problem with a query, as [name, what's wrong], or null. isOpenToOtherNames: a page ignores names it doesn't read, since share links pick some up. */
217
+ export function queryProblem(fields, query, isOpenToOtherNames = false) {
218
+ for (const [name, given] of query) {
219
+ const shape = fields[name];
220
+ if (!shape) {
221
+ if (isOpenToOtherNames)
222
+ continue;
223
+ return { field: name, problem: `${name} isn't one of the query's names` };
224
+ }
225
+ const [problem] = schemaProblems(shape, queryValueOf(shape, given), name);
226
+ if (problem)
227
+ return { field: name, problem };
228
+ }
229
+ return null;
230
+ }
231
+ /** The first problem with a JSON body's fields: closed, each field its shape, the required ones there. */
232
+ export function bodyProblem(fields, required, body) {
233
+ for (const name of required)
234
+ if (!(name in body))
235
+ return { field: name, problem: `${name} is missing` };
236
+ for (const [name, given] of Object.entries(body)) {
237
+ const shape = fields[name];
238
+ if (!shape)
239
+ return { field: name, problem: `${name} isn't one of this door's fields` };
240
+ const [problem] = schemaProblems(shape, given, name);
241
+ if (problem)
242
+ return { field: name, problem };
243
+ }
244
+ return null;
245
+ }
246
+ /** The first problem with a form's fields: its names closed, each value text, or a list where it may repeat. A form's text boxes send what they send, so its numbers are the door's to read. */
247
+ export function formProblem(fields, fileNames, form) {
248
+ for (const name of new Set(form.keys())) {
249
+ if (fileNames.includes(name))
250
+ continue;
251
+ const shape = fields[name];
252
+ if (!shape)
253
+ return { field: name, problem: `${name} isn't one of this form's fields` };
254
+ const sent = form.getAll(name);
255
+ if (sent.some((fieldValue) => typeof fieldValue !== "string"))
256
+ return { field: name, problem: `${name} is a file, not text` };
257
+ const read = shape.type === "array" || sent.length !== 1 ? sent : sent[0];
258
+ const [problem] = schemaProblems(shape, typeof read === "string" ? queryValueOf(shape, read) : read, name);
259
+ if (problem)
260
+ return { field: name, problem };
261
+ }
262
+ return null;
263
+ }
7
264
  /**
8
265
  * The one check every door goes through. `proven` is who the door's guard found the caller to be; a caller a
9
266
  * named function proves passes here, and that function refuses them, before anything else of the door runs.
package/src/requests.d.ts CHANGED
@@ -67,7 +67,7 @@ export declare const REQUEST_LIMITS: {
67
67
  /** How long onRequest may take, awaiting the database included, before the request fails. */
68
68
  readonly answerTimeoutSec: 10;
69
69
  };
70
- export type RequestErrorCode = "bad_request" | "too_big" | "bad_pass" | "bad_player_token" | "account_needed" | "game_not_open_to_player" | "game_unavailable" | "no_request_handler" | "game_server_error" | "hosting_paused" | "slow_down" | "bad_origin" | "busy";
70
+ export type RequestErrorCode = "bad_request" | "too_big" | "bad_pass" | "bad_player_token" | "account_needed" | "game_not_open_to_player" | "game_unavailable" | "no_request_handler" | "game_server_error" | "hosting_paused" | "rented_share_used" | "slow_down" | "bad_origin" | "busy";
71
71
  export declare const REQUEST_ERROR_TEXT: Record<RequestErrorCode, string>;
72
72
  /** The HTTP status each request error answers with. */
73
73
  export declare const REQUEST_ERROR_HTTP_STATUS: Record<RequestErrorCode, number>;
package/src/requests.js CHANGED
@@ -35,6 +35,7 @@ export const REQUEST_ERROR_TEXT = {
35
35
  no_request_handler: "This game's server doesn't take requests.",
36
36
  game_server_error: "The game's server couldn't answer this request.",
37
37
  hosting_paused: "This game's hosting is paused. It starts again when the month resets or its developer adds credit.",
38
+ rented_share_used: "This network looks like a server. Turn off your VPN or sign in with an account that has played before.",
38
39
  slow_down: "Too many requests. Try again in a minute.",
39
40
  bad_origin: "Not allowed from here.",
40
41
  busy: "Zaisma is busy. Try again soon.",
@@ -51,6 +52,7 @@ export const REQUEST_ERROR_HTTP_STATUS = {
51
52
  no_request_handler: 404,
52
53
  game_server_error: 500,
53
54
  hosting_paused: 403,
55
+ rented_share_used: 403,
54
56
  slow_down: 429,
55
57
  bad_origin: 403,
56
58
  busy: 503,