@zaisma/game-server 0.0.1 → 1.0.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/src/dump.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * The game database in and out as SQLite's own SQL dump (docs/DECISIONS.md, "Import, export and undo"):
3
+ * the CREATE TABLEs and INSERTs that `sqlite3 game.sqlite .dump` writes, so a dump from Zaisma loads into
4
+ * any SQLite (`sqlite3 game.sqlite < dump.sql`, the Node runner's file) and a dump from there loads into
5
+ * Zaisma. Zaisma's database is a Durable Object (apps/rooms/src/database.ts); the upload command keeps a
6
+ * copy of the same reading for .sqlite files (packages/cli/zaisma.mjs), held to this by test/node/dump.test.ts.
7
+ */
8
+ import { DATABASE_LIMITS, DatabaseError, scanStatements } from "./database.js";
9
+ export const DUMP_LIMITS = {
10
+ /** The most SQL one part of an export holds, unless a single row is bigger on its own. */
11
+ exportPartSizeBytes: 4 * 1024 * 1024,
12
+ /** The most one part of an import may hold: a little over an export's part, so every export part fits. */
13
+ maximumImportPartSizeBytes: 5 * 1024 * 1024,
14
+ };
15
+ /** A cursor that isn't an earlier part's next_cursor, or whose table is gone. */
16
+ export class DumpCursorError extends Error {
17
+ }
18
+ /** Rows read per SELECT while dumping a table. */
19
+ const PAGE_ROW_COUNT = 500;
20
+ /** A dump starts and ends the way sqlite3's does, so it loads in one transaction anywhere. */
21
+ const DUMP_START = "PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n";
22
+ const DUMP_END = "COMMIT;\n";
23
+ // What's in the database, in the order it was made: tables, views, indexes and triggers, with what kind
24
+ // of table each is (a normal one, a virtual one, or a virtual table's own "shadow" table) and whether it has
25
+ // rowids. Cloudflare's own tables (_cf_…) are never the developer's.
26
+ const SCHEMA_SQL = "SELECT entry.type AS type, entry.name AS name, entry.tbl_name AS table_name, entry.sql AS sql, listed.type AS table_kind, listed.wr AS is_without_rowid " +
27
+ "FROM sqlite_schema AS entry LEFT JOIN pragma_table_list AS listed ON listed.schema = 'main' AND listed.name = entry.name " +
28
+ "WHERE entry.sql IS NOT NULL AND substr(entry.tbl_name, 1, 4) <> '_cf_' ORDER BY entry.rowid";
29
+ /** A name in double quotes, as SQLite takes any table or column name. */
30
+ export function quoteName(name) {
31
+ return `"${name.replaceAll('"', '""')}"`;
32
+ }
33
+ /**
34
+ * One part of the database's dump, up to partSizeBytes of SQL (more only when one row is bigger on its own),
35
+ * starting where cursor says, or at the start for null. The parts, put together in order, are one whole dump. It reads
36
+ * table by table, so a game writing while it runs can land between parts: it isn't a snapshot.
37
+ */
38
+ export function dumpPart(read, cursor, partSizeBytes = DUMP_LIMITS.exportPartSizeBytes) {
39
+ let readRowCount = 0;
40
+ const select = (sql, values = []) => {
41
+ const answer = read(sql, values);
42
+ readRowCount += answer.readRowCount;
43
+ return answer.rows;
44
+ };
45
+ const entries = select(SCHEMA_SQL);
46
+ const tables = entries.filter((entry) => entry.type === "table" && (entry.table_kind === "table" || entry.table_kind === "virtual") && !entry.name.startsWith("sqlite_"));
47
+ let dumpSql = "";
48
+ let dumpSizeBytes = 0;
49
+ let hasRows = false;
50
+ const add = (sql) => {
51
+ dumpSql += sql;
52
+ dumpSizeBytes += utf8SizeBytes(sql);
53
+ };
54
+ let tableIndex = 0;
55
+ let position = null;
56
+ if (cursor === null)
57
+ add(DUMP_START);
58
+ else {
59
+ position = readCursor(cursor);
60
+ tableIndex = tables.findIndex((table) => table.name === position.table_name);
61
+ if (tableIndex === -1)
62
+ throw new DumpCursorError(`The table ${position.table_name} isn't there any more.`);
63
+ }
64
+ for (; tableIndex < tables.length; tableIndex++) {
65
+ const table = tables[tableIndex];
66
+ if (!position)
67
+ add(`${table.sql};\n`);
68
+ position ??= { table_name: table.name, last_rowid_number: table.is_without_rowid ? null : 0, dumped_row_count: 0 };
69
+ const columns = select("SELECT name, hidden = 0 AS is_insertable, pk AS primary_key_number FROM pragma_table_xinfo(?) ORDER BY cid", [table.name]);
70
+ // Generated columns, and a virtual table's hidden ones, can't be inserted: the INSERTs name the rest.
71
+ const insertedColumns = columns.filter((column) => column.is_insertable === 1);
72
+ const insertInto = `INSERT INTO ${quoteName(table.name)}${insertedColumns.length < columns.length ? `(${insertedColumns.map((column) => quoteName(column.name)).join(",")})` : ""} VALUES(`;
73
+ // quote() writes each value as SQL, the way sqlite3's .dump does: 'text' with '' for a quote, X'…' for bytes.
74
+ const valuesSql = insertedColumns.map((column, i) => `quote(${quoteName(column.name)}) AS value_${i}`).join(", ");
75
+ const primaryKey = columns.filter((column) => column.primary_key_number > 0).sort((first, second) => first.primary_key_number - second.primary_key_number);
76
+ while (true) {
77
+ const rows = position.last_rowid_number === null
78
+ ? select(`SELECT ${valuesSql} FROM ${quoteName(table.name)} ORDER BY ${primaryKey.map((column) => quoteName(column.name)).join(", ")} LIMIT ${PAGE_ROW_COUNT} OFFSET ?`, [position.dumped_row_count])
79
+ : select(`SELECT rowid AS paging_rowid, ${valuesSql} FROM ${quoteName(table.name)} WHERE rowid > ? ORDER BY rowid LIMIT ${PAGE_ROW_COUNT}`, [position.last_rowid_number]);
80
+ for (const row of rows) {
81
+ const insert = `${insertInto}${insertedColumns.map((_, i) => row[`value_${i}`]).join(",")});\n`;
82
+ // The part ends before a row that would take it past partSizeBytes; the next starts with that row.
83
+ if (hasRows && dumpSizeBytes + utf8SizeBytes(insert) > partSizeBytes)
84
+ return { dumpSql, nextCursor: writeCursor(position), readRowCount };
85
+ add(insert);
86
+ hasRows = true;
87
+ if (position.last_rowid_number === null)
88
+ position.dumped_row_count++;
89
+ else
90
+ position.last_rowid_number = row.paging_rowid;
91
+ }
92
+ if (rows.length < PAGE_ROW_COUNT)
93
+ break;
94
+ }
95
+ position = null;
96
+ }
97
+ // The rest is small: AUTOINCREMENT's counters, then views, indexes and triggers, made after the rows
98
+ // so triggers don't fire on them, then the end.
99
+ if (entries.some((entry) => entry.name === "sqlite_sequence")) {
100
+ add("DELETE FROM sqlite_sequence;\n");
101
+ for (const counter of select("SELECT quote(name) AS name_sql, quote(seq) AS counter_sql FROM sqlite_sequence"))
102
+ add(`INSERT INTO sqlite_sequence VALUES(${counter.name_sql},${counter.counter_sql});\n`);
103
+ }
104
+ for (const entry of entries) {
105
+ if ((entry.type === "view" || entry.type === "index" || entry.type === "trigger") && !entry.table_name.startsWith("sqlite_"))
106
+ add(`${entry.sql};\n`);
107
+ }
108
+ add(DUMP_END);
109
+ return { dumpSql, nextCursor: null, readRowCount };
110
+ }
111
+ /** How many bytes text takes as UTF-8, without making the bytes. */
112
+ export function utf8SizeBytes(text) {
113
+ let sizeBytes = 0;
114
+ for (let i = 0; i < text.length; i++) {
115
+ const codeUnitNumber = text.charCodeAt(i);
116
+ // A surrogate pair is one character of 4 bytes, 2 for each half.
117
+ sizeBytes += codeUnitNumber < 0x80 ? 1 : codeUnitNumber < 0x800 ? 2 : codeUnitNumber >= 0xd800 && codeUnitNumber < 0xe000 ? 2 : 3;
118
+ }
119
+ return sizeBytes;
120
+ }
121
+ function writeCursor(position) {
122
+ const bytes = new TextEncoder().encode(JSON.stringify(position));
123
+ return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
124
+ }
125
+ function readCursor(cursor) {
126
+ let position;
127
+ try {
128
+ const binary = atob(cursor.replaceAll("-", "+").replaceAll("_", "/"));
129
+ position = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))));
130
+ }
131
+ catch {
132
+ throw new DumpCursorError("The cursor isn't one an export answered.");
133
+ }
134
+ const { table_name: tableName, last_rowid_number: lastRowidNumber, dumped_row_count: dumpedRowCount } = (position ?? {});
135
+ if (typeof tableName !== "string" || !(lastRowidNumber === null || Number.isInteger(lastRowidNumber)) || !Number.isInteger(dumpedRowCount))
136
+ throw new DumpCursorError("The cursor isn't one an export answered.");
137
+ return { table_name: tableName, last_rowid_number: lastRowidNumber, dumped_row_count: dumpedRowCount };
138
+ }
139
+ const IMPORT_FIX = "Import takes a dump's CREATE and INSERT statements, as sqlite3's .dump or export_database writes them; it only adds. Run other changes with query_database or batch_database.";
140
+ /**
141
+ * Reads a part of a dump into the statements to run. Import only adds: it takes CREATE and INSERT
142
+ * statements, and leaves out what sqlite3 writes around them (the transaction, PRAGMAs, ANALYZE and its
143
+ * sqlite_stat1 rows). The values in an INSERT become values for ?s, since Zaisma's database takes
144
+ * statements up to 100 KB and a row of a dump can be bigger; unistr('…'), which sqlite3 writes for text with
145
+ * line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up.
146
+ */
147
+ export function importStatements(dumpSql) {
148
+ const statements = [];
149
+ const scanned = scanStatements(dumpSql);
150
+ let skippedStatementCount = 0;
151
+ scanned.forEach((scannedStatement, i) => {
152
+ const statementNumber = i + 1;
153
+ const text = dumpSql.slice(scannedStatement.startIndex, scannedStatement.endIndex);
154
+ const [firstWord = "", secondWord = "", thirdWord = ""] = scannedStatement.leadingWords;
155
+ const words = scannedStatement.leadingWords.join(" ");
156
+ const isSkipped = ["BEGIN", "COMMIT", "END", "ROLLBACK", "ANALYZE"].includes(firstWord) ||
157
+ (firstWord === "PRAGMA" && (secondWord === "FOREIGN_KEYS" || secondWord === "WRITABLE_SCHEMA")) ||
158
+ /^CREATE TABLE (IF NOT EXISTS )?SQLITE_SEQUENCE$/.test(words) ||
159
+ words === "DELETE FROM SQLITE_SEQUENCE" ||
160
+ (firstWord === "INSERT" && secondWord === "INTO" && (thirdWord === "SQLITE_STAT1" || thirdWord === "SQLITE_STAT4"));
161
+ if (isSkipped) {
162
+ skippedStatementCount++;
163
+ return;
164
+ }
165
+ const refuse = (message) => new DatabaseError("bad_sql", `Statement ${statementNumber} of the dump (${preview(text)}): ${message}`, IMPORT_FIX);
166
+ if (firstWord === "CREATE") {
167
+ statements.push({ sql: text, values: [], statementNumber });
168
+ return;
169
+ }
170
+ if (firstWord !== "INSERT")
171
+ throw refuse(`${firstWord || "it"} isn't a CREATE or INSERT.`);
172
+ if (secondWord === "OR" && thirdWord === "REPLACE")
173
+ throw refuse("INSERT OR REPLACE changes rows that are there.");
174
+ const bound = bindLiterals(text, refuse);
175
+ if (thirdWord === "SQLITE_SEQUENCE") {
176
+ // sqlite_sequence has a row per table and no key: set the counter, never lower it.
177
+ const counterMatch = /VALUES\s*\(\s*\?\s*,\s*(\d+)\s*\)$/i.exec(bound.sql);
178
+ if (!counterMatch || bound.values.length !== 1)
179
+ throw refuse("sqlite_sequence takes a table's name and its counter, like INSERT INTO sqlite_sequence VALUES('scores',12).");
180
+ const tableName = bound.values[0];
181
+ const counter = counterMatch[1];
182
+ // The counter goes in the SQL (it's digits only): bound, a number would be stored as a REAL.
183
+ statements.push({ sql: `INSERT INTO sqlite_sequence (name, seq) SELECT ?, ${counter} WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = ?)`, values: [tableName, tableName], statementNumber });
184
+ statements.push({ sql: `UPDATE sqlite_sequence SET seq = max(seq, ${counter}) WHERE name = ?`, values: [tableName], statementNumber });
185
+ return;
186
+ }
187
+ if (bound.values.length > DATABASE_LIMITS.maximumValueCount)
188
+ throw new DatabaseError("bad_values", `Statement ${statementNumber} of the dump (${preview(text)}) has ${bound.values.length} text and blob values.`, `Give each row its own INSERT, as sqlite3's .dump does: the database takes up to ${DATABASE_LIMITS.maximumValueCount} values a statement.`);
189
+ statements.push({ sql: bound.sql, values: bound.values, statementNumber });
190
+ });
191
+ return { statements, statementCount: scanned.length, skippedStatementCount };
192
+ }
193
+ /** The start of a statement, for an error. */
194
+ export function preview(text) {
195
+ const oneLine = text.replace(/\s+/g, " ");
196
+ return oneLine.length > 80 ? `${oneLine.slice(0, 79)}…` : oneLine;
197
+ }
198
+ /**
199
+ * An INSERT with its text and blob literals as ?s, and their values: 'it''s' becomes "it's", X'00ff' the
200
+ * bytes and unistr('a\u000ab') the text it stands for. Numbers, NULL and names stay as they are.
201
+ */
202
+ function bindLiterals(text, refuse) {
203
+ const values = [];
204
+ let sql = "";
205
+ let i = 0;
206
+ /** The text of the 'quoted' literal starting at startIndex, and where it ends. */
207
+ const readQuoted = (startIndex) => {
208
+ let endIndex = startIndex + 1;
209
+ let literal = "";
210
+ while (endIndex < text.length) {
211
+ if (text[endIndex] === "'") {
212
+ if (text[endIndex + 1] !== "'")
213
+ return { literal, endIndex: endIndex + 1 };
214
+ literal += "'";
215
+ endIndex += 2;
216
+ }
217
+ else
218
+ literal += text[endIndex++];
219
+ }
220
+ throw refuse("a quote isn't closed.");
221
+ };
222
+ const isWordCharacter = (character) => character !== undefined && /[A-Za-z0-9_$]/.test(character);
223
+ while (i < text.length) {
224
+ const character = text[i];
225
+ const afterWord = isWordCharacter(text[i - 1]);
226
+ if (character === "-" && text[i + 1] === "-") {
227
+ const endIndex = text.indexOf("\n", i);
228
+ i = endIndex === -1 ? text.length : endIndex + 1;
229
+ sql += " ";
230
+ }
231
+ else if (character === "/" && text[i + 1] === "*") {
232
+ const endIndex = text.indexOf("*/", i + 2);
233
+ i = endIndex === -1 ? text.length : endIndex + 2;
234
+ sql += " ";
235
+ }
236
+ else if (character === '"' || character === "`" || character === "[") {
237
+ // A name: copied as it is.
238
+ const close = character === "[" ? "]" : character;
239
+ let endIndex = i + 1;
240
+ while (endIndex < text.length && !(text[endIndex] === close && text[endIndex + 1] !== close))
241
+ endIndex += text[endIndex] === close ? 2 : 1;
242
+ sql += text.slice(i, endIndex + 1);
243
+ i = endIndex + 1;
244
+ }
245
+ else if (character === "'") {
246
+ const { literal, endIndex } = readQuoted(i);
247
+ values.push(literal);
248
+ sql += "?";
249
+ i = endIndex;
250
+ }
251
+ else if ((character === "x" || character === "X") && text[i + 1] === "'" && !afterWord) {
252
+ const { literal, endIndex } = readQuoted(i + 1);
253
+ if (literal.length % 2 || /[^0-9A-Fa-f]/.test(literal))
254
+ throw refuse(`X'${literal.slice(0, 20)}' isn't bytes in hex.`);
255
+ values.push(Uint8Array.from({ length: literal.length / 2 }, (_, byteIndex) => parseInt(literal.slice(byteIndex * 2, byteIndex * 2 + 2), 16)));
256
+ sql += "?";
257
+ i = endIndex;
258
+ }
259
+ else if (!afterWord && /^unistr\s*\(\s*'/i.test(text.slice(i, i + 40))) {
260
+ const quoteIndex = text.indexOf("'", i);
261
+ const { literal, endIndex } = readQuoted(quoteIndex);
262
+ const closeMatch = /^\s*\)/.exec(text.slice(endIndex));
263
+ if (!closeMatch)
264
+ throw refuse("unistr( takes one 'text'.");
265
+ values.push(literal.replace(/\\(\\|u[0-9A-Fa-f]{4}|\+[0-9A-Fa-f]{6}|U[0-9A-Fa-f]{8}|[0-9A-Fa-f]{4})/g, (_, escape) => (escape === "\\" ? "\\" : String.fromCodePoint(parseInt(escape.replace(/^[uU+]/, ""), 16)))));
266
+ sql += "?";
267
+ i = endIndex + closeMatch[0].length;
268
+ }
269
+ else if (isWordCharacter(character)) {
270
+ let endIndex = i + 1;
271
+ while (isWordCharacter(text[endIndex]))
272
+ endIndex++;
273
+ sql += text.slice(i, endIndex);
274
+ i = endIndex;
275
+ }
276
+ else {
277
+ sql += character;
278
+ i++;
279
+ }
280
+ }
281
+ return { sql, values };
282
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, GAME_SERVER_OPTIONS, GameServerError, NO_ANSWER_CLOSE_CODE, REMOVED_CLOSE_CODE, ROOM_LIMITS, SEAT_GONE_CLOSE_CODE, VERSION_UPDATE_CLOSE_CODE, defineGameServer, type Audience, type AwayInfo, type AwayMode, type AwayReason, type BroadcastOptions, type Delivery, type GameServer, type GameServerErrorCode, type HandlerResult, type IdleCheck, type JoinAnswer, type JoinInfo, type KickOptions, type LeaveInfo, type LeaveReason, type MoveOptions, type MoveResult, type Player, type Refusal, type ReserveOptions, type RestoreInfo, type Room, type RoomErrorCode, type RoomInfo, type SendOptions, type VersionUpdate, } from "./room.ts";
2
+ export { REQUEST_LIMITS, type RequestErrorCode, type Server } from "./requests.ts";
3
+ export { SCHEDULE_LIMITS, type Schedule, type ScheduledRun } from "./schedules.ts";
4
+ export { WEBHOOK_LIMITS, verifyWebhook, type WebhookError, type WebhookErrorCode, type WebhookEvent, type WebhookEventKind, type WebhookHeaders } from "./webhooks.ts";
5
+ export { DATABASE_LIMITS, DatabaseError, type DatabaseErrorCode, type DatabaseRow, type DatabaseStatement, type DatabaseValue, type GameDatabase } from "./database.ts";
6
+ export { OUTSIDE_CALL_LIMITS, OutsideCallError, type OutsideCallErrorCode } from "./outside-calls.ts";
7
+ export { PASS_ISSUER_URL, PASS_TOKEN_TYPE, PASS_VERSION_NUMBER, PassError, RECEIPT_ISSUER_URL, RECEIPT_TOKEN_TYPE, RECEIPT_VERSION_NUMBER, ReceiptError, canChat, passesAreFriends, passesHaveBlockBetween, verifyPass, verifyReceipt, type PassClaims, type PassErrorCode, type PassExpectations, type PlayerKind, type PublicKey, type ReceiptClaims, type ReceiptErrorCode, type ReceiptExpectations, type WhoCanChat } from "./pass.ts";
8
+ export { CHAT_CHECK_LIMITS, ChatCheckError, checkChat, type ChatCheck, type ChatCheckOptions, type ChatCheckReason } from "./chat.ts";
9
+ export { guestName, isRoomCode } from "./names.ts";
10
+ export { relay } from "./relay.ts";
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // What @zaisma/game-server gives developers, named one by one: from v1 it only grows (test/contracts).
2
+ // Zaisma's own parts of the package, never locked, are in ./internal.
3
+ export { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, GAME_SERVER_OPTIONS, GameServerError, NO_ANSWER_CLOSE_CODE, REMOVED_CLOSE_CODE, ROOM_LIMITS, SEAT_GONE_CLOSE_CODE, VERSION_UPDATE_CLOSE_CODE, defineGameServer, } from "./room.js";
4
+ export { REQUEST_LIMITS } from "./requests.js";
5
+ export { SCHEDULE_LIMITS } from "./schedules.js";
6
+ export { WEBHOOK_LIMITS, verifyWebhook } from "./webhooks.js";
7
+ export { DATABASE_LIMITS, DatabaseError } from "./database.js";
8
+ export { OUTSIDE_CALL_LIMITS, OutsideCallError } from "./outside-calls.js";
9
+ export { PASS_ISSUER_URL, PASS_TOKEN_TYPE, PASS_VERSION_NUMBER, PassError, RECEIPT_ISSUER_URL, RECEIPT_TOKEN_TYPE, RECEIPT_VERSION_NUMBER, ReceiptError, canChat, passesAreFriends, passesHaveBlockBetween, verifyPass, verifyReceipt } from "./pass.js";
10
+ export { CHAT_CHECK_LIMITS, ChatCheckError, checkChat } from "./chat.js";
11
+ export { guestName, isRoomCode } from "./names.js";
12
+ export { relay } from "./relay.js";
@@ -0,0 +1,13 @@
1
+ export * from "./room.ts";
2
+ export * from "./room-calls.ts";
3
+ export * from "./state-sync.ts";
4
+ export * from "./database.ts";
5
+ export * from "./dump.ts";
6
+ export * from "./requests.ts";
7
+ export * from "./outside-calls.ts";
8
+ export * from "./schedules.ts";
9
+ export * from "./webhooks.ts";
10
+ export * from "./pass.ts";
11
+ export * from "./names.ts";
12
+ export * from "./chat.ts";
13
+ export * from "./relay.ts";
@@ -0,0 +1,15 @@
1
+ // Zaisma's own use of the game server package: everything in src, and what only Zaisma does (signing passes).
2
+ // Never published or locked: games and developers' servers use the package's index.
3
+ export * from "./room.js";
4
+ export * from "./room-calls.js";
5
+ export * from "./state-sync.js";
6
+ export * from "./database.js";
7
+ export * from "./dump.js";
8
+ export * from "./requests.js";
9
+ export * from "./outside-calls.js";
10
+ export * from "./schedules.js";
11
+ export * from "./webhooks.js";
12
+ export * from "./pass.js";
13
+ export * from "./names.js";
14
+ export * from "./chat.js";
15
+ export * from "./relay.js";
package/src/names.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** A friendly guest name like "Swift Fox 42". Guests can't type their own names. */
2
+ export declare function guestName(): string;
3
+ export declare function roomCode(): string;
4
+ export declare function isRoomCode(code: string): boolean;
package/src/names.js ADDED
@@ -0,0 +1,18 @@
1
+ const ADJECTIVES = ["Swift", "Brave", "Quiet", "Lucky", "Bright", "Clever", "Sunny", "Bold", "Calm", "Fuzzy", "Jolly", "Nimble", "Rapid", "Sly", "Witty", "Zany", "Cosmic", "Mighty", "Tiny", "Wild"];
2
+ const ANIMALS = ["Fox", "Otter", "Panda", "Hawk", "Lynx", "Koala", "Badger", "Falcon", "Gecko", "Moose", "Owl", "Raven", "Seal", "Tiger", "Wolf", "Yak", "Bison", "Crane", "Heron", "Newt"];
3
+ function pick(list, randomNumber) {
4
+ return list[randomNumber % list.length];
5
+ }
6
+ /** A friendly guest name like "Swift Fox 42". Guests can't type their own names. */
7
+ export function guestName() {
8
+ const [adjectiveNumber, animalNumber, endNumber] = crypto.getRandomValues(new Uint32Array(3));
9
+ return `${pick(ADJECTIVES, adjectiveNumber)} ${pick(ANIMALS, animalNumber)} ${10 + (endNumber % 90)}`;
10
+ }
11
+ /** No 0/O or 1/I, so codes read out loud or typed from a screen don't get mixed up. */
12
+ const CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
13
+ export function roomCode() {
14
+ return Array.from(crypto.getRandomValues(new Uint8Array(6)), (byte) => CODE_ALPHABET[byte & 31]).join("");
15
+ }
16
+ export function isRoomCode(code) {
17
+ return /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{6}$/.test(code);
18
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Calls from a game server to other servers (game server reach): plain fetch(). On Zaisma every call
3
+ * goes through the rooms Worker's gateway (apps/rooms/src/outbound.ts), which takes only https to the
4
+ * hosts the game lists in game_server_allowed_hosts; a call it refuses throws an OutsideCallError in
5
+ * the game server with one of these codes. On the developer's own server the Node runner calls out
6
+ * freely, so the same code runs in both.
7
+ */
8
+ export type OutsideCallErrorCode =
9
+ /** Only https: addresses. */
10
+ "not_https"
11
+ /** An IP address, this computer, or Zaisma's own domains. */
12
+ | "unreachable_host"
13
+ /** A host the game doesn't list in game_server_allowed_hosts. */
14
+ | "host_not_allowed"
15
+ /** More calls a minute than a game makes in one place (OUTSIDE_CALL_LIMITS.maximumCallsPerMin). */
16
+ | "outside_call_limit"
17
+ /** A body over OUTSIDE_CALL_LIMITS.maximumBodySizeBytes. */
18
+ | "too_big"
19
+ /** No answer within OUTSIDE_CALL_LIMITS.timeoutSec. */
20
+ | "outside_call_timeout"
21
+ /** The other server couldn't be reached, like a name that doesn't exist. */
22
+ | "outside_call_failed";
23
+ export declare const OUTSIDE_CALL_LIMITS: {
24
+ readonly maximumBodySizeBytes: number;
25
+ readonly timeoutSec: 10;
26
+ readonly maximumCallsPerMin: 600;
27
+ };
28
+ export declare const OUTSIDE_CALL_ERROR_CODES: readonly string[];
29
+ /** A call from a game server to another server that Zaisma's gateway refused (or that failed), with a code, what happened and what to do. */
30
+ export declare class OutsideCallError extends Error {
31
+ readonly code: OutsideCallErrorCode;
32
+ readonly fix: string;
33
+ constructor(code: OutsideCallErrorCode, message: string, fix: string);
34
+ /** True for any error named OutsideCallError with a known code, so instanceof works for the copy a game's sandbox has too. */
35
+ static [Symbol.hasInstance](candidate: unknown): boolean;
36
+ }
37
+ /** What a refused call answers inside the sandbox, which its fetch() turns into an OutsideCallError. */
38
+ export interface OutsideCallRefusal {
39
+ error: {
40
+ code: OutsideCallErrorCode;
41
+ message: string;
42
+ fix: string;
43
+ };
44
+ }
45
+ /** The header a refusal comes back with, so the sandbox can tell it from a server's own answer. */
46
+ export declare const OUTSIDE_CALL_REFUSAL_HEADER = "zaisma-outside-call-refused";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Calls from a game server to other servers (game server reach): plain fetch(). On Zaisma every call
3
+ * goes through the rooms Worker's gateway (apps/rooms/src/outbound.ts), which takes only https to the
4
+ * hosts the game lists in game_server_allowed_hosts; a call it refuses throws an OutsideCallError in
5
+ * the game server with one of these codes. On the developer's own server the Node runner calls out
6
+ * freely, so the same code runs in both.
7
+ */
8
+ export const OUTSIDE_CALL_LIMITS = {
9
+ maximumBodySizeBytes: 5 * 1024 * 1024,
10
+ timeoutSec: 10,
11
+ maximumCallsPerMin: 600,
12
+ };
13
+ export const OUTSIDE_CALL_ERROR_CODES = ["not_https", "unreachable_host", "host_not_allowed", "outside_call_limit", "too_big", "outside_call_timeout", "outside_call_failed"];
14
+ /** A call from a game server to another server that Zaisma's gateway refused (or that failed), with a code, what happened and what to do. */
15
+ export class OutsideCallError extends Error {
16
+ code;
17
+ fix;
18
+ constructor(code, message, fix) {
19
+ super(`${message} ${fix} (${code})`);
20
+ this.name = "OutsideCallError";
21
+ this.code = code;
22
+ this.fix = fix;
23
+ }
24
+ /** True for any error named OutsideCallError with a known code, so instanceof works for the copy a game's sandbox has too. */
25
+ static [Symbol.hasInstance](candidate) {
26
+ return candidate instanceof Error && candidate.name === "OutsideCallError" && OUTSIDE_CALL_ERROR_CODES.includes(candidate.code);
27
+ }
28
+ }
29
+ /** The header a refusal comes back with, so the sandbox can tell it from a server's own answer. */
30
+ export const OUTSIDE_CALL_REFUSAL_HEADER = "zaisma-outside-call-refused";
package/src/pass.d.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Player passes: standard JWTs signed with Ed25519 (EdDSA). Our rooms check them,
3
+ * and so can any developer's own server, using the keys published at
4
+ * /.well-known/jwks.json. Any JWT library that supports EdDSA works.
5
+ */
6
+ /**
7
+ * What a pass proves: who the player is, for which game, until when. Nothing more; anything else
8
+ * about an account (its name) comes from its public profile, <issuer>/v1/players/<id>. A game that
9
+ * needs accounts gives guests no pass at all. Fields can be added within v1; none is removed.
10
+ */
11
+ export interface PassClaims {
12
+ /** Player ID. An account's is the same in every game; a guest's is random and only valid for this pass. */
13
+ sub: string;
14
+ /** A guest's generated name. Account passes carry none. */
15
+ name?: string;
16
+ /** The game's slug: a pass only works for the game it was issued for. */
17
+ aud: string;
18
+ /** Who made it: https://rooms.zaisma.com (PASS_ISSUER_URL) on zaisma.com. */
19
+ iss: string;
20
+ /** The pass contract it follows: 1. verifyPass refuses a number it doesn't know (wrong_version). */
21
+ pass_version_number: number;
22
+ /** guest or account, or a kind added later: treat one your server doesn't know as a guest. */
23
+ kind: PlayerKind | (string & {});
24
+ /**
25
+ * Who an account lets chat with it: nobody, its friends, or anyone in its chat group, or a choice
26
+ * added later (canChat matches only the ones it knows, so an unknown one chats with nobody).
27
+ * Guests' passes have none: guests don't chat.
28
+ */
29
+ who_can_chat?: WhoCanChat | (string & {});
30
+ /**
31
+ * For accounts that chat with anyone, the group they can chat in (chat_group_…): two such players
32
+ * chat when their groups match (canChat). Opaque and different in every game, so it never says an
33
+ * age; a game could guess which group is the younger one from its size.
34
+ */
35
+ chat_group_id?: string;
36
+ /**
37
+ * An account's own player tag in this game (player_tag_…): opaque, and different in every game, so
38
+ * it says nothing outside it. Other passes list it to say they're friends (friend_player_tag_ids) or
39
+ * have a block (block_player_tag_ids). Guests' passes have none.
40
+ */
41
+ player_tag_id?: string;
42
+ /**
43
+ * The player tags of an account's friends, up to 200, as they were when the pass was made: never
44
+ * their player IDs, so a game learns only which of its own players are friends, by their passes
45
+ * (passesAreFriends). Friends are always two-way, so passesAreFriends asks both passes. Guests'
46
+ * passes have none.
47
+ */
48
+ friend_player_tag_ids?: string[];
49
+ /**
50
+ * The player tags of the accounts this one has a block with, either way, newest first and up to 100,
51
+ * as they were when the pass was made. Never says who made a block, and means nothing without the
52
+ * other player's pass: two players have a block between them when either pass lists the other's
53
+ * player_tag_id (passesHaveBlockBetween).
54
+ */
55
+ block_player_tag_ids?: string[];
56
+ /**
57
+ * The permanent items an account owns in this game (bought or granted, not refunded), by item_name, as
58
+ * they were when the pass was made: [] for guests and when the player owns nothing here. Consumables are
59
+ * never listed: they're delivered once and then they're the game's to count.
60
+ */
61
+ owned_item_names: string[];
62
+ iat: number;
63
+ exp: number;
64
+ }
65
+ /** A signed-in Zaisma account, or a guest. */
66
+ export type PlayerKind = "guest" | "account";
67
+ /** No chat, friends only, or anyone in the same chat group: the player's "Who can chat with you" setting (docs/DECISIONS.md, "Social settings, with the social modes as presets"). */
68
+ export type WhoCanChat = "nobody" | "friends" | "anyone";
69
+ /** A pass's JWS header typ (RFC 8725 explicit typing): verifyPass refuses any other token (wrong_kind). */
70
+ export declare const PASS_TOKEN_TYPE = "pass+jwt";
71
+ /** The pass contract this copy reads: verifyPass refuses any other pass_version_number (wrong_version). */
72
+ export declare const PASS_VERSION_NUMBER = 1;
73
+ /** Zaisma's pass issuer: verifyPass expects it unless told another (a local preview of Zaisma). */
74
+ export declare const PASS_ISSUER_URL = "https://rooms.zaisma.com";
75
+ /**
76
+ * Whether two players have a block between them, by the passes your server checked (verifyPass): either
77
+ * one blocked the other, which neither pass says. A block is a wall both ways: deliver nothing from one
78
+ * to the other (chat, voice, emotes) and don't match them together when you can help it, but never keep
79
+ * either out of a room. A new block reaches your server with the blocker's next pass, and the blocker's
80
+ * game also tells the room ({ type: "block", playerId }), which room.hasBlockBetween covers.
81
+ */
82
+ export declare function passesHaveBlockBetween(first: PassClaims, second: PassClaims): boolean;
83
+ /**
84
+ * Whether two players are Zaisma friends, by the passes your server checked (verifyPass): both accounts,
85
+ * each pass listing the other's player_tag_id, so removing a friend holds as soon as either side has a
86
+ * new pass. It works across rooms too: any two passes for the game. Use it for friends-only chat (canChat
87
+ * does), parties, or showing a player their friends; guests are never anyone's friend.
88
+ */
89
+ export declare function passesAreFriends(first: PassClaims, second: PassClaims): boolean;
90
+ /**
91
+ * Whether two players may chat, by the passes your server checked (verifyPass): both accounts, no block
92
+ * between them, neither letting nobody chat, and either friends or both letting anyone chat, in the same
93
+ * chat group. Guests and accounts set to nobody never chat; accounts set to friends chat only with
94
+ * friends (passesAreFriends). Use it on every message your own server delivers, text or voice; Zaisma's
95
+ * chat check (checkChat) applies the same rule, and Zaisma rooms will do the same for you.
96
+ */
97
+ export declare function canChat(first: PassClaims, second: PassClaims): boolean;
98
+ export type PublicKey = JsonWebKey & {
99
+ kid: string;
100
+ };
101
+ /** Why verifyPass refused a pass. wrong_kind: a receipt, or another token. wrong_version: a pass_version_number this copy doesn't know. */
102
+ export type PassErrorCode = "malformed" | "unknown_key" | "bad_signature" | "expired" | "wrong_game" | "wrong_issuer" | "wrong_kind" | "wrong_version";
103
+ export declare class PassError extends Error {
104
+ readonly code: PassErrorCode;
105
+ constructor(code: PassErrorCode);
106
+ }
107
+ /** Why verifyReceipt refused a receipt. wrong_kind: it's a pass, or another token. wrong_version: a receipt_version_number this copy doesn't know. */
108
+ export type ReceiptErrorCode = "malformed" | "unknown_key" | "bad_signature" | "expired" | "wrong_game" | "wrong_issuer" | "wrong_kind" | "wrong_version";
109
+ export declare class ReceiptError extends Error {
110
+ readonly code: ReceiptErrorCode;
111
+ constructor(code: ReceiptErrorCode);
112
+ }
113
+ /** A receipt's JWS header typ (RFC 8725 explicit typing): verifyReceipt refuses any other token (wrong_kind). */
114
+ export declare const RECEIPT_TOKEN_TYPE = "receipt+jwt";
115
+ /** Who signs receipts on zaisma.com: a receipt's iss, apart from a pass's (PASS_ISSUER_URL). */
116
+ export declare const RECEIPT_ISSUER_URL = "https://rooms.zaisma.com/receipts";
117
+ /** The receipt contract this copy reads: verifyReceipt refuses any other receipt_version_number (wrong_version). */
118
+ export declare const RECEIPT_VERSION_NUMBER = 1;
119
+ /** Make a new signing key, as a JWK string for a signing secret (the rooms Worker's PASS_SIGNING_KEY and RECEIPT_SIGNING_KEY). */
120
+ export declare function generateSigningKey(): Promise<string>;
121
+ export interface SigningKey {
122
+ key: CryptoKey;
123
+ publicKey: PublicKey;
124
+ }
125
+ export declare function loadSigningKey(secret: string): Promise<SigningKey>;
126
+ /** A pass to sign: Zaisma always lists owned_item_names, and verifyPass reads a pass without them as owning nothing. */
127
+ export declare function signPass(signing: SigningKey, claims: Omit<PassClaims, "owned_item_names"> & {
128
+ owned_item_names?: string[];
129
+ }): Promise<string>;
130
+ export declare function jwks(keys: PublicKey[]): {
131
+ keys: PublicKey[];
132
+ };
133
+ /** What verifyPass checks a pass against: the game it's for, and who made it (PASS_ISSUER_URL unless a local preview of Zaisma). */
134
+ export interface PassExpectations {
135
+ gameSlug: string;
136
+ issuerUrl?: string;
137
+ /** The time to check exp against, in epoch milliseconds: now unless a test says otherwise. */
138
+ nowMs?: number;
139
+ }
140
+ /** Check a pass. Throws PassError if it's forged, expired, for another game, not a pass, or a version this copy doesn't know. */
141
+ export declare function verifyPass(token: string, keys: PublicKey[], expect: PassExpectations): Promise<PassClaims>;
142
+ /**
143
+ * What a receipt proves: that this player bought or was granted this item in this game, and the purchase's
144
+ * status when the receipt was signed. Signed with Zaisma's receipt key, published for good at its own address
145
+ * (/.well-known/receipt-jwks.json), apart from the pass key, so a receipt never expires and a receipt key never
146
+ * signs a pass anyone takes. Its typ is receipt+jwt and its issuer
147
+ * <pass issuer>/receipts (https://rooms.zaisma.com/receipts), so no JWT library takes it for a pass.
148
+ *
149
+ * Deliver once per purchase_id, and only when purchase_status is completed; never compare receipt strings,
150
+ * since the same purchase signs again with a new iat. item_kind, purchase_source and purchase_status may gain
151
+ * values within v1: a value your server doesn't know means don't deliver.
152
+ */
153
+ export interface ReceiptClaims {
154
+ /** The player's ID. */
155
+ sub: string;
156
+ /** The game's slug: a receipt is for the game it was issued for. */
157
+ aud: string;
158
+ /** https://rooms.zaisma.com/receipts on zaisma.com: the pass issuer with /receipts. */
159
+ iss: string;
160
+ /** The purchase's ID, as purchase_id, for JWT tools. */
161
+ jti: string;
162
+ /** The receipt contract it follows: 1. verifyReceipt refuses a number it doesn't know. */
163
+ receipt_version_number: number;
164
+ /** The purchase's ID (purchase_…): one purchase, one delivery. */
165
+ purchase_id: string;
166
+ /** When it was bought or granted, as an ISO date. */
167
+ purchased_at: string;
168
+ /** completed, refunded or charged_back when it was signed; deliver only on completed. */
169
+ purchase_status: "completed" | "refunded" | "charged_back" | (string & {});
170
+ item_name: string;
171
+ item_kind: "permanent" | "consumable" | (string & {});
172
+ /** How many of the item: 1 for a permanent item. */
173
+ item_count: number;
174
+ /** sale: paid through the buy sheet. grant: the developer gave it, with no money. */
175
+ purchase_source: "sale" | "grant" | (string & {});
176
+ /** What the whole purchase paid, in the currency's minor units; null for a grant. */
177
+ paid_minor_units: number | null;
178
+ currency_code: string | null;
179
+ /** The game's own ID for the player's account in its own system, as the game gave it; null when it gave none. */
180
+ game_account_id: string | null;
181
+ /** When the receipt was signed, in epoch seconds (not when it was bought: that's purchased_at). */
182
+ iat: number;
183
+ /** No receipt expires today; verifyReceipt refuses one past its exp if one ever does. */
184
+ exp?: number;
185
+ }
186
+ /** What verifyReceipt checks a receipt against: the game it's for, and who made it (RECEIPT_ISSUER_URL unless a local preview of Zaisma). */
187
+ export interface ReceiptExpectations {
188
+ gameSlug: string;
189
+ issuerUrl?: string;
190
+ /** The time to check exp against, in epoch milliseconds: now unless a test says otherwise. */
191
+ nowMs?: number;
192
+ }
193
+ /** Check a receipt. Throws ReceiptError if it's forged, for another game, a pass (wrong_kind), past its exp, or a version this copy doesn't know. */
194
+ export declare function verifyReceipt(token: string, keys: PublicKey[], expect: ReceiptExpectations): Promise<ReceiptClaims>;