@zaisma/game-server 1.13.0 → 1.14.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/bench/glass.js +8471 -438
- package/bin/node-parts.js +5 -1
- package/bin/node.d.ts +10 -1
- package/bin/node.js +70 -31
- package/package.json +1 -1
- package/sdk/zaisma.d.ts +59 -8
- package/sdk/zaisma.js +72 -17
- package/src/chat.d.ts +9 -4
- package/src/chat.js +20 -4
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/internal.d.ts +1 -0
- package/src/internal.js +1 -0
- package/src/outside-players.d.ts +65 -0
- package/src/outside-players.js +162 -0
- package/src/pass.d.ts +7 -2
- package/src/pass.js +1 -1
- package/src/requests.d.ts +17 -2
- package/src/requests.js +15 -8
- package/src/room.d.ts +11 -3
- package/src/room.js +12 -2
package/src/chat.js
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* checkChat sends the players' passes (as your server received them) and the sender's last few messages to
|
|
10
10
|
* Zaisma (POST <issuer>/v1/chat/check), so a phone number sent a few digits at a time is caught too.
|
|
11
11
|
*
|
|
12
|
+
* A player your server let in from outside Zaisma (kind outside_zaisma) has no pass: give null in their place.
|
|
13
|
+
* Zaisma counts them as strangers of unknown age, so a message between one and an under-18 account is stopped
|
|
14
|
+
* (not_allowed_to_chat); between them and adults it's checked as between adults. A message among players from
|
|
15
|
+
* outside Zaisma only is allowed without asking Zaisma: none of its players is in it, so your own rules apply.
|
|
16
|
+
*
|
|
12
17
|
* It's for your own server only. In Zaisma rooms the room filters the chat it delivers itself, so a game
|
|
13
18
|
* server running there never sees passes, and checkChat throws a ChatCheckError (own_server_only).
|
|
14
19
|
*/
|
|
@@ -84,15 +89,26 @@ function rememberText(senderPlayerId, text, nowMs) {
|
|
|
84
89
|
export async function checkChat({ text, senderPass, receiverPasses }) {
|
|
85
90
|
// Read apart from the destructuring, which is v1's signature as it's locked (test/contracts).
|
|
86
91
|
const isAdultFilterEnabled = arguments[0]?.isAdultFilterEnabled === true;
|
|
87
|
-
|
|
88
|
-
if (typeof claims.sub !== "string" || !isZaismaIssuer(claims.iss) || !Array.isArray(receiverPasses))
|
|
92
|
+
if (!Array.isArray(receiverPasses) || !receiverPasses.every((pass) => pass === null || typeof pass === "string") || !(senderPass === null || typeof senderPass === "string"))
|
|
89
93
|
return UNCHECKED;
|
|
90
94
|
const message = String(text ?? "");
|
|
91
95
|
if (message.length > CHAT_CHECK_LIMITS.maximumMessageCharacterCount)
|
|
92
96
|
return TOO_LONG;
|
|
97
|
+
// Among players from outside Zaisma only: no Zaisma player is in it, so the server's own rules apply, and Zaisma never hears of it.
|
|
98
|
+
const firstPass = [senderPass, ...receiverPasses].find((pass) => typeof pass === "string");
|
|
99
|
+
if (firstPass === undefined)
|
|
100
|
+
return { isAllowed: true, reason: null, message: null, hasSwearing: false, hasSlur: false };
|
|
101
|
+
// Where to ask comes from a pass, the sender's when they have one: they all come from the same issuer for the same game.
|
|
102
|
+
const claims = readClaims(firstPass);
|
|
103
|
+
const senderClaims = senderPass === null ? null : readClaims(senderPass);
|
|
104
|
+
if (typeof claims.sub !== "string" || !isZaismaIssuer(claims.iss) || (senderClaims && typeof senderClaims.sub !== "string"))
|
|
105
|
+
return UNCHECKED;
|
|
93
106
|
const nowMs = Date.now();
|
|
94
|
-
|
|
95
|
-
|
|
107
|
+
// A sender from outside Zaisma has no player ID Zaisma knows, so their last messages aren't sent along.
|
|
108
|
+
const senderPlayerId = senderClaims ? senderClaims.sub : null;
|
|
109
|
+
const recentTexts = senderPlayerId ? (recentBySender.get(senderPlayerId) ?? []).filter((sent) => nowMs - sent.sentAtMs < CHAT_CHECK_LIMITS.recentTextDurationMs).map((sent) => sent.text) : [];
|
|
110
|
+
if (senderPlayerId)
|
|
111
|
+
rememberText(senderPlayerId, message, nowMs);
|
|
96
112
|
try {
|
|
97
113
|
const response = await fetch(`${claims.iss}/v1/chat/check`, {
|
|
98
114
|
method: "POST",
|
package/src/index.d.ts
CHANGED
|
@@ -7,4 +7,5 @@ export { OUTSIDE_CALL_LIMITS, OutsideCallError, type OutsideCallErrorCode } from
|
|
|
7
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
8
|
export { CHAT_CHECK_LIMITS, CHAT_ROOM_LIMITS, ChatCheckError, checkChat, type ChatCheck, type ChatCheckOptions, type ChatCheckReason } from "./chat.ts";
|
|
9
9
|
export { guestName, isRoomCode } from "./names.ts";
|
|
10
|
+
export { guestPlayer, signInWithZaismaPlayers, type IdentifyPlayer, type OutsidePlayer, type SignInWithZaismaPlayersOptions } from "./outside-players.ts";
|
|
10
11
|
export { relay } from "./relay.ts";
|
package/src/index.js
CHANGED
|
@@ -9,4 +9,5 @@ export { OUTSIDE_CALL_LIMITS, OutsideCallError } from "./outside-calls.js";
|
|
|
9
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
10
|
export { CHAT_CHECK_LIMITS, CHAT_ROOM_LIMITS, ChatCheckError, checkChat } from "./chat.js";
|
|
11
11
|
export { guestName, isRoomCode } from "./names.js";
|
|
12
|
+
export { guestPlayer, signInWithZaismaPlayers } from "./outside-players.js";
|
|
12
13
|
export { relay } from "./relay.js";
|
package/src/internal.d.ts
CHANGED
package/src/internal.js
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./database.js";
|
|
|
7
7
|
export * from "./dump.js";
|
|
8
8
|
export * from "./requests.js";
|
|
9
9
|
export * from "./outside-calls.js";
|
|
10
|
+
export * from "./outside-players.js";
|
|
10
11
|
export * from "./schedules.js";
|
|
11
12
|
export * from "./webhooks.js";
|
|
12
13
|
export * from "./pass.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Players from outside Zaisma, for a game server on your own server: a game played anywhere but its Zaisma
|
|
3
|
+
* page (exported, on another site, wrapped for a store) has no Zaisma pass, so its game sends a playerToken
|
|
4
|
+
* instead (Zaisma.setUpOffZaisma) and your server says who that is with startServer's identifyPlayer. Such a
|
|
5
|
+
* player joins with kind "outside_zaisma", in the same rooms as players from the Zaisma page.
|
|
6
|
+
*
|
|
7
|
+
* signInWithZaismaPlayers makes an identifyPlayer for players signed in with Zaisma (Sign in with Zaisma's ID
|
|
8
|
+
* tokens): the same player ID and name as on Zaisma. guestPlayer is who a player with no token is, when
|
|
9
|
+
* startServer's outsideGuestsEnabled lets them in: a temporary ID and a generated name, like Zaisma's guests.
|
|
10
|
+
*/
|
|
11
|
+
import { type PublicKey } from "./pass.ts";
|
|
12
|
+
/** Who a player from outside Zaisma is, as identifyPlayer answers: an ID your server keeps for them, and the name others see. */
|
|
13
|
+
export interface OutsidePlayer {
|
|
14
|
+
/** 1 to 100 characters: a Zaisma player ID for a player signed in with Zaisma, or your own accounts' IDs. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** 1 to 40 characters, as other players see it. */
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
/** startServer's identifyPlayer: who a playerToken is, or null when it isn't one your server takes. */
|
|
20
|
+
export type IdentifyPlayer = (playerToken: string, connection: {
|
|
21
|
+
origin: string | null;
|
|
22
|
+
}) => Promise<OutsidePlayer | null>;
|
|
23
|
+
export interface SignInWithZaismaPlayersOptions {
|
|
24
|
+
/** Your sign-in apps' IDs (sign_in_app_…), 1 to 20: an ID token for any other app is refused. */
|
|
25
|
+
signInAppIds: string[];
|
|
26
|
+
/** Where Sign in with Zaisma runs, the ID tokens' iss: https://zaisma.com unless a local preview of Zaisma. */
|
|
27
|
+
siteUrl?: string;
|
|
28
|
+
/** async (isRefreshing) => the ID token keys; by default from <siteUrl>/.well-known/jwks.json, fetched again once on a key it doesn't know. */
|
|
29
|
+
loadKeys?: (isRefreshing: boolean) => Promise<PublicKey[]>;
|
|
30
|
+
/** async (playerId) => an account's public profile or null, for a token with no name (signed in without the profile scope). */
|
|
31
|
+
loadProfile?: (playerId: string) => Promise<{
|
|
32
|
+
name: string;
|
|
33
|
+
} | null>;
|
|
34
|
+
}
|
|
35
|
+
export declare const OUTSIDE_PLAYER_LIMITS: {
|
|
36
|
+
readonly maximumIdCharacterCount: 100;
|
|
37
|
+
readonly maximumSignInAppCount: 20;
|
|
38
|
+
/** How far an ID token's exp may be past, for clocks that differ a little. */
|
|
39
|
+
readonly clockLeewaySec: 60;
|
|
40
|
+
/** How long the keys and profiles are kept before they're fetched again. */
|
|
41
|
+
readonly keysKeptMs: 3600000;
|
|
42
|
+
readonly profileKeptMs: 300000;
|
|
43
|
+
/** A refresh for a key ID it doesn't know waits this long after the last fetch, so made-up key IDs can't make it fetch on every token. */
|
|
44
|
+
readonly minimumRefreshIntervalMs: 30000;
|
|
45
|
+
readonly maximumProfileCount: 10000;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* An identifyPlayer for players signed in with Zaisma: the game signs them in with Sign in with Zaisma and
|
|
49
|
+
* sends the ID token as its playerToken. The token is checked like any OpenID Connect ID token: RS256 only,
|
|
50
|
+
* signed by Zaisma's published key, its iss the siteUrl, its aud one of signInAppIds, not expired. The player
|
|
51
|
+
* is { id, name }: the account's Zaisma player ID, the same as on Zaisma, and its name from the token (or its
|
|
52
|
+
* public profile when the token has none). Answers null for any token that doesn't check out, and for an
|
|
53
|
+
* account deleted since; throws only when Zaisma's keys or profiles can't be reached (the runner answers busy).
|
|
54
|
+
*
|
|
55
|
+
* startServer({ gameServer, gameSlug, identifyPlayer: signInWithZaismaPlayers({ signInAppIds: ["sign_in_app_8f3k2m"] }) });
|
|
56
|
+
*/
|
|
57
|
+
export declare function signInWithZaismaPlayers(options: SignInWithZaismaPlayersOptions): IdentifyPlayer;
|
|
58
|
+
/** A player with no token, when startServer's outsideGuestsEnabled lets them in: a temporary player ID and a generated name, as Zaisma's guests have. */
|
|
59
|
+
export declare function guestPlayer(): OutsidePlayer;
|
|
60
|
+
/** What identifyPlayer answered, checked for the runner: the player, with their name tidied, or what was wrong with it. */
|
|
61
|
+
export declare function checkedOutsidePlayer(given: unknown): {
|
|
62
|
+
player: OutsidePlayer;
|
|
63
|
+
} | {
|
|
64
|
+
mistake: string;
|
|
65
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Players from outside Zaisma, for a game server on your own server: a game played anywhere but its Zaisma
|
|
3
|
+
* page (exported, on another site, wrapped for a store) has no Zaisma pass, so its game sends a playerToken
|
|
4
|
+
* instead (Zaisma.setUpOffZaisma) and your server says who that is with startServer's identifyPlayer. Such a
|
|
5
|
+
* player joins with kind "outside_zaisma", in the same rooms as players from the Zaisma page.
|
|
6
|
+
*
|
|
7
|
+
* signInWithZaismaPlayers makes an identifyPlayer for players signed in with Zaisma (Sign in with Zaisma's ID
|
|
8
|
+
* tokens): the same player ID and name as on Zaisma. guestPlayer is who a player with no token is, when
|
|
9
|
+
* startServer's outsideGuestsEnabled lets them in: a temporary ID and a generated name, like Zaisma's guests.
|
|
10
|
+
*/
|
|
11
|
+
import { guestName } from "./names.js";
|
|
12
|
+
import { fromBase64Url } from "./pass.js";
|
|
13
|
+
import { GameServerError, ROOM_LIMITS, tidyRoomInfo } from "./room.js";
|
|
14
|
+
export const OUTSIDE_PLAYER_LIMITS = {
|
|
15
|
+
maximumIdCharacterCount: 100,
|
|
16
|
+
maximumSignInAppCount: 20,
|
|
17
|
+
/** How far an ID token's exp may be past, for clocks that differ a little. */
|
|
18
|
+
clockLeewaySec: 60,
|
|
19
|
+
/** How long the keys and profiles are kept before they're fetched again. */
|
|
20
|
+
keysKeptMs: 3600_000,
|
|
21
|
+
profileKeptMs: 300_000,
|
|
22
|
+
/** A refresh for a key ID it doesn't know waits this long after the last fetch, so made-up key IDs can't make it fetch on every token. */
|
|
23
|
+
minimumRefreshIntervalMs: 30_000,
|
|
24
|
+
maximumProfileCount: 10_000,
|
|
25
|
+
};
|
|
26
|
+
const RS256 = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
|
|
27
|
+
const SIGN_IN_APP_ID = /^sign_in_app_[a-z0-9]+$/;
|
|
28
|
+
const decoder = new TextDecoder();
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
/** The ID token keys at <siteUrl>/.well-known/jwks.json, kept an hour; a fetch that fails keeps the keys already held. */
|
|
31
|
+
function keysAt(jwksUrl) {
|
|
32
|
+
let kept = null;
|
|
33
|
+
let fetchedAtMs = 0;
|
|
34
|
+
return async (isRefreshing) => {
|
|
35
|
+
const nowMs = Date.now();
|
|
36
|
+
if (kept && !isRefreshing && nowMs - kept.fetchedAtMs < OUTSIDE_PLAYER_LIMITS.keysKeptMs)
|
|
37
|
+
return kept.keys;
|
|
38
|
+
if (kept && isRefreshing && nowMs - fetchedAtMs < OUTSIDE_PLAYER_LIMITS.minimumRefreshIntervalMs)
|
|
39
|
+
return kept.keys;
|
|
40
|
+
fetchedAtMs = nowMs;
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(jwksUrl);
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new Error(`Couldn't load Zaisma's sign-in keys from ${jwksUrl}`);
|
|
45
|
+
kept = { keys: (await response.json()).keys, fetchedAtMs: nowMs };
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (kept)
|
|
49
|
+
return kept.keys;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
return kept.keys;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Accounts' public profiles at Zaisma (https://rooms.zaisma.com/v1/players/<id>), each kept 5 minutes; null when no account has that ID. */
|
|
56
|
+
function profilesAt(profilesUrl) {
|
|
57
|
+
const kept = new Map();
|
|
58
|
+
return async (playerId) => {
|
|
59
|
+
const found = kept.get(playerId);
|
|
60
|
+
if (found && Date.now() - found.fetchedAtMs < OUTSIDE_PLAYER_LIMITS.profileKeptMs)
|
|
61
|
+
return found.profile;
|
|
62
|
+
const response = await fetch(`${profilesUrl}/${encodeURIComponent(playerId)}`);
|
|
63
|
+
if (response.status !== 200 && response.status !== 404)
|
|
64
|
+
throw new Error(`Couldn't look up player ${playerId} at ${profilesUrl}`);
|
|
65
|
+
const profile = response.status === 200 ? (await response.json()).player : null;
|
|
66
|
+
if (kept.size > OUTSIDE_PLAYER_LIMITS.maximumProfileCount)
|
|
67
|
+
kept.clear();
|
|
68
|
+
kept.set(playerId, { profile, fetchedAtMs: Date.now() });
|
|
69
|
+
return profile;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** A token's header and claims, unchecked, or null when it isn't a JWT. */
|
|
73
|
+
function tokenParts(token) {
|
|
74
|
+
const parts = token.split(".");
|
|
75
|
+
if (parts.length !== 3)
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
const header = JSON.parse(decoder.decode(fromBase64Url(parts[0])));
|
|
79
|
+
const claims = JSON.parse(decoder.decode(fromBase64Url(parts[1])));
|
|
80
|
+
if (!header || typeof header !== "object" || !claims || typeof claims !== "object" || Array.isArray(claims))
|
|
81
|
+
return null;
|
|
82
|
+
return { header, claims, signature: fromBase64Url(parts[2]) };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* An identifyPlayer for players signed in with Zaisma: the game signs them in with Sign in with Zaisma and
|
|
90
|
+
* sends the ID token as its playerToken. The token is checked like any OpenID Connect ID token: RS256 only,
|
|
91
|
+
* signed by Zaisma's published key, its iss the siteUrl, its aud one of signInAppIds, not expired. The player
|
|
92
|
+
* is { id, name }: the account's Zaisma player ID, the same as on Zaisma, and its name from the token (or its
|
|
93
|
+
* public profile when the token has none). Answers null for any token that doesn't check out, and for an
|
|
94
|
+
* account deleted since; throws only when Zaisma's keys or profiles can't be reached (the runner answers busy).
|
|
95
|
+
*
|
|
96
|
+
* startServer({ gameServer, gameSlug, identifyPlayer: signInWithZaismaPlayers({ signInAppIds: ["sign_in_app_8f3k2m"] }) });
|
|
97
|
+
*/
|
|
98
|
+
export function signInWithZaismaPlayers(options) {
|
|
99
|
+
const fields = ["signInAppIds", "siteUrl", "loadKeys", "loadProfile"];
|
|
100
|
+
if (!options || typeof options !== "object")
|
|
101
|
+
throw new GameServerError("bad_option", "options", "signInWithZaismaPlayers takes one object of options.", 'Call signInWithZaismaPlayers({ signInAppIds: ["sign_in_app_…"] }).');
|
|
102
|
+
for (const field of Object.keys(options)) {
|
|
103
|
+
if (!fields.includes(field))
|
|
104
|
+
throw new GameServerError("unknown_option", field, `${field} isn't a signInWithZaismaPlayers option.`, `signInWithZaismaPlayers takes: ${fields.join(", ")}.`);
|
|
105
|
+
}
|
|
106
|
+
const { signInAppIds, siteUrl = "https://zaisma.com" } = options;
|
|
107
|
+
if (!Array.isArray(signInAppIds) || signInAppIds.length < 1 || signInAppIds.length > OUTSIDE_PLAYER_LIMITS.maximumSignInAppCount || !signInAppIds.every((id) => typeof id === "string" && SIGN_IN_APP_ID.test(id))) {
|
|
108
|
+
throw new GameServerError("bad_option", "signInAppIds", "signInAppIds isn't a list of sign-in app IDs.", `Give 1 to ${OUTSIDE_PLAYER_LIMITS.maximumSignInAppCount} of your sign-in apps' IDs, like ["sign_in_app_8f3k2m"] (list_sign_in_apps shows them).`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof siteUrl !== "string" || !URL.canParse(siteUrl))
|
|
111
|
+
throw new GameServerError("bad_option", "siteUrl", "siteUrl isn't a URL.", 'Give where Sign in with Zaisma runs, like "https://zaisma.com".');
|
|
112
|
+
for (const field of ["loadKeys", "loadProfile"]) {
|
|
113
|
+
if (options[field] !== undefined && typeof options[field] !== "function")
|
|
114
|
+
throw new GameServerError("bad_option", field, `${field} isn't a function.`, field === "loadKeys" ? "Give async (isRefreshing) => the keys, as a list." : "Give async (playerId) => { name } or null.");
|
|
115
|
+
}
|
|
116
|
+
const loadKeys = options.loadKeys ?? keysAt(`${siteUrl}/.well-known/jwks.json`);
|
|
117
|
+
const loadProfile = options.loadProfile ?? profilesAt("https://rooms.zaisma.com/v1/players");
|
|
118
|
+
return async (playerToken) => {
|
|
119
|
+
const parts = typeof playerToken === "string" ? tokenParts(playerToken) : null;
|
|
120
|
+
// An ID token, never an access token (at+jwt) or a token signed any other way.
|
|
121
|
+
if (!parts || parts.header.alg !== "RS256" || parts.header.typ !== "JWT" || typeof parts.header.kid !== "string")
|
|
122
|
+
return null;
|
|
123
|
+
const { header, claims } = parts;
|
|
124
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
125
|
+
if (claims.iss !== siteUrl || typeof claims.aud !== "string" || !signInAppIds.includes(claims.aud))
|
|
126
|
+
return null;
|
|
127
|
+
if (typeof claims.exp !== "number" || claims.exp + OUTSIDE_PLAYER_LIMITS.clockLeewaySec <= nowSec)
|
|
128
|
+
return null;
|
|
129
|
+
if (typeof claims.sub !== "string" || !claims.sub || claims.sub.length > OUTSIDE_PLAYER_LIMITS.maximumIdCharacterCount)
|
|
130
|
+
return null;
|
|
131
|
+
// A key published since the keys were fetched: fetched again once.
|
|
132
|
+
let jwk = (await loadKeys(false)).find((key) => key.kid === header.kid);
|
|
133
|
+
if (!jwk)
|
|
134
|
+
jwk = (await loadKeys(true)).find((key) => key.kid === header.kid);
|
|
135
|
+
if (!jwk || jwk.kty !== "RSA" || !jwk.n || !jwk.e)
|
|
136
|
+
return null;
|
|
137
|
+
const key = await crypto.subtle.importKey("jwk", { kty: "RSA", n: jwk.n, e: jwk.e }, RS256, false, ["verify"]);
|
|
138
|
+
const [signedHeader, signedClaims] = playerToken.split(".");
|
|
139
|
+
if (!(await crypto.subtle.verify(RS256, key, parts.signature, encoder.encode(`${signedHeader}.${signedClaims}`))))
|
|
140
|
+
return null;
|
|
141
|
+
// A token without the profile scope carries no name: the account's public one. A deleted account has none, and plays no more.
|
|
142
|
+
const name = typeof claims.name === "string" && claims.name ? claims.name : ((await loadProfile(claims.sub))?.name ?? null);
|
|
143
|
+
return name ? { id: claims.sub, name } : null;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** A player with no token, when startServer's outsideGuestsEnabled lets them in: a temporary player ID and a generated name, as Zaisma's guests have. */
|
|
147
|
+
export function guestPlayer() {
|
|
148
|
+
const randomPart = Array.from(crypto.getRandomValues(new Uint8Array(12)), (byte) => byte.toString(36).padStart(2, "0")).join("").slice(0, 20);
|
|
149
|
+
return { id: `player_${randomPart}`, name: guestName() };
|
|
150
|
+
}
|
|
151
|
+
/** What identifyPlayer answered, checked for the runner: the player, with their name tidied, or what was wrong with it. */
|
|
152
|
+
export function checkedOutsidePlayer(given) {
|
|
153
|
+
if (!given || typeof given !== "object" || Array.isArray(given))
|
|
154
|
+
return { mistake: "identifyPlayer answered something other than { id, name } or null." };
|
|
155
|
+
const { id, name } = given;
|
|
156
|
+
if (typeof id !== "string" || !id || id.length > OUTSIDE_PLAYER_LIMITS.maximumIdCharacterCount)
|
|
157
|
+
return { mistake: `identifyPlayer's id isn't text of 1 to ${OUTSIDE_PLAYER_LIMITS.maximumIdCharacterCount} characters.` };
|
|
158
|
+
const tidied = typeof name === "string" ? tidyRoomInfo({ name }).name : null;
|
|
159
|
+
if (typeof name !== "string" || !tidied || name.trim().length > ROOM_LIMITS.maximumNameCharacterCount)
|
|
160
|
+
return { mistake: `identifyPlayer's name isn't text of 1 to ${ROOM_LIMITS.maximumNameCharacterCount} characters.` };
|
|
161
|
+
return { player: { id, name: tidied } };
|
|
162
|
+
}
|
package/src/pass.d.ts
CHANGED
|
@@ -62,8 +62,12 @@ export interface PassClaims {
|
|
|
62
62
|
iat: number;
|
|
63
63
|
exp: number;
|
|
64
64
|
}
|
|
65
|
-
/**
|
|
66
|
-
|
|
65
|
+
/**
|
|
66
|
+
* A signed-in Zaisma account, a guest, or a player from outside Zaisma (outside_zaisma): one your own server let
|
|
67
|
+
* in without a Zaisma pass, by its identifyPlayer (signed in with Zaisma, or your game's own accounts) or as a
|
|
68
|
+
* guest from elsewhere (outsideGuestsEnabled). Zaisma's rooms and passes never give outside_zaisma.
|
|
69
|
+
*/
|
|
70
|
+
export type PlayerKind = "guest" | "account" | "outside_zaisma";
|
|
67
71
|
/** 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
72
|
export type WhoCanChat = "nobody" | "friends" | "anyone";
|
|
69
73
|
/** A pass's JWS header typ (RFC 8725 explicit typing): verifyPass refuses any other token (wrong_kind). */
|
|
@@ -117,6 +121,7 @@ export declare const RECEIPT_TOKEN_TYPE = "receipt+jwt";
|
|
|
117
121
|
export declare const RECEIPT_ISSUER_URL = "https://rooms.zaisma.com/receipts";
|
|
118
122
|
/** The receipt contract this copy reads: verifyReceipt refuses any other receipt_version_number (wrong_version). */
|
|
119
123
|
export declare const RECEIPT_VERSION_NUMBER = 1;
|
|
124
|
+
export declare function fromBase64Url(text: string): Uint8Array<ArrayBuffer>;
|
|
120
125
|
/** Make a new signing key, as a JWK string for a signing secret (the rooms Worker's PASS_SIGNING_KEY and RECEIPT_SIGNING_KEY). */
|
|
121
126
|
export declare function generateSigningKey(): Promise<string>;
|
|
122
127
|
export interface SigningKey {
|
package/src/pass.js
CHANGED
|
@@ -96,7 +96,7 @@ function toBase64Url(bytes) {
|
|
|
96
96
|
binary += String.fromCharCode(byte);
|
|
97
97
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
98
98
|
}
|
|
99
|
-
function fromBase64Url(text) {
|
|
99
|
+
export function fromBase64Url(text) {
|
|
100
100
|
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
101
101
|
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
102
102
|
}
|
package/src/requests.d.ts
CHANGED
|
@@ -60,12 +60,14 @@ export declare const REQUEST_LIMITS: {
|
|
|
60
60
|
* items with 40-character names, 200 friends and 100 blocks) is about 70 KB, with room to spare.
|
|
61
61
|
*/
|
|
62
62
|
readonly maximumPassSizeBytes: number;
|
|
63
|
+
/** A game played off Zaisma sends a playerToken in place of a pass (Zaisma.setUpOffZaisma), to a game server on your own server. */
|
|
64
|
+
readonly maximumPlayerTokenSizeBytes: number;
|
|
63
65
|
/** What onRequest answers, as JSON. */
|
|
64
66
|
readonly maximumAnswerSizeBytes: number;
|
|
65
67
|
/** How long onRequest may take, awaiting the database included, before the request fails. */
|
|
66
68
|
readonly answerTimeoutSec: 10;
|
|
67
69
|
};
|
|
68
|
-
export type RequestErrorCode = "bad_request" | "too_big" | "bad_pass" | "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" | "slow_down" | "bad_origin" | "busy";
|
|
69
71
|
export declare const REQUEST_ERROR_TEXT: Record<RequestErrorCode, string>;
|
|
70
72
|
/** The HTTP status each request error answers with. */
|
|
71
73
|
export declare const REQUEST_ERROR_HTTP_STATUS: Record<RequestErrorCode, number>;
|
|
@@ -78,7 +80,9 @@ export declare function requestError(code: RequestErrorCode, message?: string):
|
|
|
78
80
|
};
|
|
79
81
|
/**
|
|
80
82
|
* Read what a game sent: its pass and its request, plain JSON within REQUEST_LIMITS.maximumRequestSizeBytes
|
|
81
|
-
* (nothing is null). The pass's own size doesn't count.
|
|
83
|
+
* (nothing is null). The pass's own size doesn't count. A game server on its developer's own server
|
|
84
|
+
* (isPlayerTokenTaken) also takes { playerToken, request } from a game played off Zaisma: its token as text,
|
|
85
|
+
* or null for a guest. Zaisma's rooms never do.
|
|
82
86
|
*/
|
|
83
87
|
export declare function readRequestBody(text: string): {
|
|
84
88
|
pass: string;
|
|
@@ -86,6 +90,17 @@ export declare function readRequestBody(text: string): {
|
|
|
86
90
|
} | {
|
|
87
91
|
code: "bad_request" | "too_big";
|
|
88
92
|
};
|
|
93
|
+
export declare function readRequestBody(text: string, isPlayerTokenTaken: boolean): {
|
|
94
|
+
pass: string;
|
|
95
|
+
request: unknown;
|
|
96
|
+
} | {
|
|
97
|
+
playerToken: string | null;
|
|
98
|
+
request: unknown;
|
|
99
|
+
} | {
|
|
100
|
+
code: "bad_request" | "too_big";
|
|
101
|
+
};
|
|
102
|
+
/** A playerToken as a game off Zaisma sends it: text up to REQUEST_LIMITS.maximumPlayerTokenSizeBytes, or null for a guest. */
|
|
103
|
+
export declare function isPlayerToken(given: unknown): given is string | null;
|
|
89
104
|
/**
|
|
90
105
|
* What onRequest answered, as the game gets it: plain JSON (nothing is null), within
|
|
91
106
|
* REQUEST_LIMITS.maximumAnswerSizeBytes, or a mistake for the game server's errors.
|
package/src/requests.js
CHANGED
|
@@ -17,6 +17,8 @@ export const REQUEST_LIMITS = {
|
|
|
17
17
|
* items with 40-character names, 200 friends and 100 blocks) is about 70 KB, with room to spare.
|
|
18
18
|
*/
|
|
19
19
|
maximumPassSizeBytes: 96 * 1024,
|
|
20
|
+
/** A game played off Zaisma sends a playerToken in place of a pass (Zaisma.setUpOffZaisma), to a game server on your own server. */
|
|
21
|
+
maximumPlayerTokenSizeBytes: 8 * 1024,
|
|
20
22
|
/** What onRequest answers, as JSON. */
|
|
21
23
|
maximumAnswerSizeBytes: 1024 * 1024,
|
|
22
24
|
/** How long onRequest may take, awaiting the database included, before the request fails. */
|
|
@@ -26,6 +28,7 @@ export const REQUEST_ERROR_TEXT = {
|
|
|
26
28
|
bad_request: "Requests are JSON with the player's pass and the game's request: { pass, request }.",
|
|
27
29
|
too_big: `Requests are up to ${REQUEST_LIMITS.maximumRequestSizeBytes / 1024} KB.`,
|
|
28
30
|
bad_pass: "Your player pass isn't valid for this game.",
|
|
31
|
+
bad_player_token: "This server doesn't know this player. Sign in again.",
|
|
29
32
|
account_needed: "Playing online needs a Zaisma account. Sign in on the game's page to play.",
|
|
30
33
|
game_not_open_to_player: "This game isn't open to this account.",
|
|
31
34
|
game_unavailable: "This game isn't available right now.",
|
|
@@ -41,6 +44,7 @@ export const REQUEST_ERROR_HTTP_STATUS = {
|
|
|
41
44
|
bad_request: 400,
|
|
42
45
|
too_big: 413,
|
|
43
46
|
bad_pass: 401,
|
|
47
|
+
bad_player_token: 401,
|
|
44
48
|
account_needed: 403,
|
|
45
49
|
game_not_open_to_player: 403,
|
|
46
50
|
game_unavailable: 404,
|
|
@@ -55,11 +59,7 @@ export const REQUEST_ERROR_HTTP_STATUS = {
|
|
|
55
59
|
export function requestError(code, message) {
|
|
56
60
|
return { error: { code, message: message || REQUEST_ERROR_TEXT[code] } };
|
|
57
61
|
}
|
|
58
|
-
|
|
59
|
-
* Read what a game sent: its pass and its request, plain JSON within REQUEST_LIMITS.maximumRequestSizeBytes
|
|
60
|
-
* (nothing is null). The pass's own size doesn't count.
|
|
61
|
-
*/
|
|
62
|
-
export function readRequestBody(text) {
|
|
62
|
+
export function readRequestBody(text, isPlayerTokenTaken = false) {
|
|
63
63
|
let body;
|
|
64
64
|
try {
|
|
65
65
|
body = JSON.parse(text);
|
|
@@ -69,13 +69,20 @@ export function readRequestBody(text) {
|
|
|
69
69
|
}
|
|
70
70
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
71
71
|
return { code: "bad_request" };
|
|
72
|
-
const { pass, request } = body;
|
|
73
|
-
|
|
72
|
+
const { pass, playerToken, request } = body;
|
|
73
|
+
const isOutside = isPlayerTokenTaken && pass === undefined && isPlayerToken(playerToken);
|
|
74
|
+
if (!isOutside && (typeof pass !== "string" || pass.length > REQUEST_LIMITS.maximumPassSizeBytes))
|
|
74
75
|
return { code: "bad_request" };
|
|
75
76
|
const requestText = request === undefined ? "null" : JSON.stringify(request);
|
|
76
77
|
if (new TextEncoder().encode(requestText).length > REQUEST_LIMITS.maximumRequestSizeBytes)
|
|
77
78
|
return { code: "too_big" };
|
|
78
|
-
|
|
79
|
+
if (isOutside)
|
|
80
|
+
return { playerToken: playerToken, request: request === undefined ? null : request };
|
|
81
|
+
return { pass: pass, request: request === undefined ? null : request };
|
|
82
|
+
}
|
|
83
|
+
/** A playerToken as a game off Zaisma sends it: text up to REQUEST_LIMITS.maximumPlayerTokenSizeBytes, or null for a guest. */
|
|
84
|
+
export function isPlayerToken(given) {
|
|
85
|
+
return given === null || (typeof given === "string" && given.length > 0 && new TextEncoder().encode(given).length <= REQUEST_LIMITS.maximumPlayerTokenSizeBytes);
|
|
79
86
|
}
|
|
80
87
|
/**
|
|
81
88
|
* What onRequest answered, as the game gets it: plain JSON (nothing is null), within
|
package/src/room.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface Player {
|
|
|
17
17
|
id: string;
|
|
18
18
|
/** The account's name, or a guest's generated one like "Swift Fox 42". */
|
|
19
19
|
name: string;
|
|
20
|
-
/** A signed-in Zaisma account, or a
|
|
20
|
+
/** A signed-in Zaisma account, a guest, or a player your own server let in from outside Zaisma (outside_zaisma). */
|
|
21
21
|
kind: PlayerKind;
|
|
22
22
|
/**
|
|
23
23
|
* The permanent items the account owns in this game, by item_name, from the pass it joined with (its
|
|
@@ -1117,7 +1117,7 @@ export type ClientMessage = {
|
|
|
1117
1117
|
* the game became unavailable, its kept room was deleted, or no players were left for its spectators.
|
|
1118
1118
|
*/
|
|
1119
1119
|
export type EndReason = "game" | "hosting_paused" | "game_server_stopped" | "game_unavailable" | "kept_room_deleted" | "no_players";
|
|
1120
|
-
export type RoomErrorCode = "room_full" | "room_closed" | "ended" | "too_big" | "too_fast" | "bad_message" | "bad_pass" | "no_hello" | "not_allowed" | "game_unavailable" | "busy" | "kept_out" | "refused" | "room_not_running" | "hosting_paused" | "account_needed" | "game_not_open_to_player" | "blocked_player_here" | "other_version" | "spectators_full" | "spectator_cannot_send" | "already_playing" | "bad_origin" | "send_queue_full" | "chat_not_here" | "chat_not_allowed" | "chat_empty" | "chat_too_long" | "chat_too_fast";
|
|
1120
|
+
export type RoomErrorCode = "room_full" | "room_closed" | "ended" | "too_big" | "too_fast" | "bad_message" | "bad_pass" | "bad_player_token" | "no_hello" | "not_allowed" | "game_unavailable" | "busy" | "kept_out" | "refused" | "room_not_running" | "hosting_paused" | "account_needed" | "game_not_open_to_player" | "blocked_player_here" | "other_version" | "spectators_full" | "spectator_cannot_send" | "already_playing" | "bad_origin" | "send_queue_full" | "chat_not_here" | "chat_not_allowed" | "chat_empty" | "chat_too_long" | "chat_too_fast";
|
|
1121
1121
|
export declare const ERROR_TEXT: Record<RoomErrorCode, string>;
|
|
1122
1122
|
/** What players are told when Zaisma ends a match at room.endsAt. */
|
|
1123
1123
|
export declare const HOSTING_PAUSED_END_MESSAGE = "This game's hosting is paused, so this match has ended. New rooms open again when the month resets or its developer adds credit.";
|
|
@@ -1250,6 +1250,12 @@ export interface PlayerChatSettings {
|
|
|
1250
1250
|
whoCanChat: "nobody" | "friends" | "anyone";
|
|
1251
1251
|
chatGroupId: string | null;
|
|
1252
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* The chat settings of a player your own server let in from outside Zaisma (outside_zaisma): they chat with
|
|
1255
|
+
* anyone who chats with anyone, in no chat group, and Zaisma's chat check stops any message between them and
|
|
1256
|
+
* an under-18 account. Zaisma's chat groups are chat_group_… IDs, so none is ever this one.
|
|
1257
|
+
*/
|
|
1258
|
+
export declare const OUTSIDE_ZAISMA_CHAT_SETTINGS: PlayerChatSettings;
|
|
1253
1259
|
/** A checked pass's chat settings, for RoomCore.setChatSettings: null for a guest. A setting this version doesn't know chats with nobody. */
|
|
1254
1260
|
export declare function chatSettingsOf(claims: PassClaims): PlayerChatSettings | null;
|
|
1255
1261
|
/** A chat message from a client as readChatMessage found it, for startChat to check. */
|
|
@@ -1531,7 +1537,9 @@ export declare class RoomCore<S = any> {
|
|
|
1531
1537
|
canChat(playerId: string): boolean;
|
|
1532
1538
|
/**
|
|
1533
1539
|
* Whether one player's chat may reach another, canChat's rule on passes (pass.ts): two accounts in the
|
|
1534
|
-
* same chat group with no block either way, friends unless both chat with anyone.
|
|
1540
|
+
* same chat group with no block either way, friends unless both chat with anyone. A player from outside
|
|
1541
|
+
* Zaisma is in no chat group: they chat with anyone who chats with anyone, and Zaisma's chat check stops
|
|
1542
|
+
* what would reach an under-18 account or come from one.
|
|
1535
1543
|
*/
|
|
1536
1544
|
private mayChat;
|
|
1537
1545
|
/** Who a player's chat may reach: the players in the match (or those of toPlayerIds in it), never spectators, kept only where they may chat. */
|
package/src/room.js
CHANGED
|
@@ -312,6 +312,7 @@ export const ERROR_TEXT = {
|
|
|
312
312
|
too_fast: "Too many messages. Slow down.",
|
|
313
313
|
bad_message: "The room couldn't read or use that message.",
|
|
314
314
|
bad_pass: "Your player pass isn't valid for this game.",
|
|
315
|
+
bad_player_token: "This server doesn't know this player. Sign in again.",
|
|
315
316
|
no_hello: "Send your player pass first.",
|
|
316
317
|
not_allowed: "Only the game server or the room's host can do that.",
|
|
317
318
|
game_unavailable: "This game isn't available right now.",
|
|
@@ -411,6 +412,12 @@ export function checkRoomData(given, maximumSizeBytes) {
|
|
|
411
412
|
export function errorMessage(code, message, details) {
|
|
412
413
|
return JSON.stringify({ type: "error", code, message: message || ERROR_TEXT[code], ...details });
|
|
413
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* The chat settings of a player your own server let in from outside Zaisma (outside_zaisma): they chat with
|
|
417
|
+
* anyone who chats with anyone, in no chat group, and Zaisma's chat check stops any message between them and
|
|
418
|
+
* an under-18 account. Zaisma's chat groups are chat_group_… IDs, so none is ever this one.
|
|
419
|
+
*/
|
|
420
|
+
export const OUTSIDE_ZAISMA_CHAT_SETTINGS = { whoCanChat: "anyone", chatGroupId: "outside_zaisma" };
|
|
414
421
|
/** A checked pass's chat settings, for RoomCore.setChatSettings: null for a guest. A setting this version doesn't know chats with nobody. */
|
|
415
422
|
export function chatSettingsOf(claims) {
|
|
416
423
|
if (claims.kind !== "account")
|
|
@@ -971,14 +978,17 @@ export class RoomCore {
|
|
|
971
978
|
}
|
|
972
979
|
/**
|
|
973
980
|
* Whether one player's chat may reach another, canChat's rule on passes (pass.ts): two accounts in the
|
|
974
|
-
* same chat group with no block either way, friends unless both chat with anyone.
|
|
981
|
+
* same chat group with no block either way, friends unless both chat with anyone. A player from outside
|
|
982
|
+
* Zaisma is in no chat group: they chat with anyone who chats with anyone, and Zaisma's chat check stops
|
|
983
|
+
* what would reach an under-18 account or come from one.
|
|
975
984
|
*/
|
|
976
985
|
mayChat(senderPlayerId, receiverPlayerId) {
|
|
977
986
|
if (senderPlayerId === receiverPlayerId || !this.canChat(senderPlayerId) || !this.canChat(receiverPlayerId) || this.hasBlockBetween(senderPlayerId, receiverPlayerId))
|
|
978
987
|
return false;
|
|
979
988
|
const sender = this.chatSettings.get(senderPlayerId);
|
|
980
989
|
const receiver = this.chatSettings.get(receiverPlayerId);
|
|
981
|
-
|
|
990
|
+
const isOutside = (settings) => settings.chatGroupId === OUTSIDE_ZAISMA_CHAT_SETTINGS.chatGroupId;
|
|
991
|
+
if (sender.chatGroupId !== receiver.chatGroupId && !isOutside(sender) && !isOutside(receiver))
|
|
982
992
|
return false;
|
|
983
993
|
return (sender.whoCanChat === "anyone" && receiver.whoCanChat === "anyone") || this.areFriends(senderPlayerId, receiverPlayerId);
|
|
984
994
|
}
|