@zaisma/game-server 0.0.1 → 1.1.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/LICENSE +21 -0
- package/README.md +35 -1
- package/bin/node-parts.js +313 -0
- package/bin/node.d.ts +47 -0
- package/bin/node.js +701 -0
- package/package.json +42 -1
- package/src/chat.d.ts +65 -0
- package/src/chat.js +108 -0
- package/src/database.d.ts +150 -0
- package/src/database.js +244 -0
- package/src/dump.d.ts +61 -0
- package/src/dump.js +282 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +12 -0
- package/src/internal.d.ts +13 -0
- package/src/internal.js +15 -0
- package/src/names.d.ts +4 -0
- package/src/names.js +18 -0
- package/src/outside-calls.d.ts +46 -0
- package/src/outside-calls.js +30 -0
- package/src/pass.d.ts +194 -0
- package/src/pass.js +231 -0
- package/src/relay.d.ts +7 -0
- package/src/relay.js +20 -0
- package/src/requests.d.ts +89 -0
- package/src/requests.js +127 -0
- package/src/room-calls.d.ts +92 -0
- package/src/room-calls.js +194 -0
- package/src/room.d.ts +1550 -0
- package/src/room.js +2315 -0
- package/src/schedules.d.ts +82 -0
- package/src/schedules.js +200 -0
- package/src/state-sync.d.ts +57 -0
- package/src/state-sync.js +175 -0
- package/src/webhooks.d.ts +114 -0
- package/src/webhooks.js +102 -0
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>;
|
package/src/pass.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
/** A pass's JWS header typ (RFC 8725 explicit typing): verifyPass refuses any other token (wrong_kind). */
|
|
7
|
+
export const PASS_TOKEN_TYPE = "pass+jwt";
|
|
8
|
+
/** The pass contract this copy reads: verifyPass refuses any other pass_version_number (wrong_version). */
|
|
9
|
+
export const PASS_VERSION_NUMBER = 1;
|
|
10
|
+
/** Zaisma's pass issuer: verifyPass expects it unless told another (a local preview of Zaisma). */
|
|
11
|
+
export const PASS_ISSUER_URL = "https://rooms.zaisma.com";
|
|
12
|
+
/** The pass helpers take passes verifyPass checked: anything else is a mistake in the server's code, not a player's. */
|
|
13
|
+
function mustBePasses(first, second, helper) {
|
|
14
|
+
for (const pass of [first, second]) {
|
|
15
|
+
if (!pass || typeof pass !== "object" || typeof pass.sub !== "string")
|
|
16
|
+
throw new TypeError(`${helper} takes two passes' claims, as verifyPass answered them.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether two players have a block between them, by the passes your server checked (verifyPass): either
|
|
21
|
+
* one blocked the other, which neither pass says. A block is a wall both ways: deliver nothing from one
|
|
22
|
+
* to the other (chat, voice, emotes) and don't match them together when you can help it, but never keep
|
|
23
|
+
* either out of a room. A new block reaches your server with the blocker's next pass, and the blocker's
|
|
24
|
+
* game also tells the room ({ type: "block", playerId }), which room.hasBlockBetween covers.
|
|
25
|
+
*/
|
|
26
|
+
export function passesHaveBlockBetween(first, second) {
|
|
27
|
+
mustBePasses(first, second, "passesHaveBlockBetween");
|
|
28
|
+
if (first.sub === second.sub)
|
|
29
|
+
return false;
|
|
30
|
+
const lists = (pass, other) => typeof other.player_tag_id === "string" && Array.isArray(pass.block_player_tag_ids) && pass.block_player_tag_ids.includes(other.player_tag_id);
|
|
31
|
+
return lists(first, second) || lists(second, first);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether two players are Zaisma friends, by the passes your server checked (verifyPass): both accounts,
|
|
35
|
+
* each pass listing the other's player_tag_id, so removing a friend holds as soon as either side has a
|
|
36
|
+
* new pass. It works across rooms too: any two passes for the game. Use it for friends-only chat (canChat
|
|
37
|
+
* does), parties, or showing a player their friends; guests are never anyone's friend.
|
|
38
|
+
*/
|
|
39
|
+
export function passesAreFriends(first, second) {
|
|
40
|
+
mustBePasses(first, second, "passesAreFriends");
|
|
41
|
+
if (first.kind !== "account" || second.kind !== "account" || first.sub === second.sub)
|
|
42
|
+
return false;
|
|
43
|
+
const lists = (pass, other) => typeof other.player_tag_id === "string" && Array.isArray(pass.friend_player_tag_ids) && pass.friend_player_tag_ids.includes(other.player_tag_id);
|
|
44
|
+
return lists(first, second) && lists(second, first);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether two players may chat, by the passes your server checked (verifyPass): both accounts, no block
|
|
48
|
+
* between them, neither letting nobody chat, and either friends or both letting anyone chat, in the same
|
|
49
|
+
* chat group. Guests and accounts set to nobody never chat; accounts set to friends chat only with
|
|
50
|
+
* friends (passesAreFriends). Use it on every message your own server delivers, text or voice; Zaisma's
|
|
51
|
+
* chat check (checkChat) applies the same rule, and Zaisma rooms will do the same for you.
|
|
52
|
+
*/
|
|
53
|
+
export function canChat(first, second) {
|
|
54
|
+
mustBePasses(first, second, "canChat");
|
|
55
|
+
if (first.kind !== "account" || second.kind !== "account" || first.sub === second.sub)
|
|
56
|
+
return false;
|
|
57
|
+
if (passesHaveBlockBetween(first, second))
|
|
58
|
+
return false;
|
|
59
|
+
const isOpenTo = (whoCanChat) => whoCanChat === "friends" || whoCanChat === "anyone";
|
|
60
|
+
if (!isOpenTo(first.who_can_chat) || !isOpenTo(second.who_can_chat))
|
|
61
|
+
return false;
|
|
62
|
+
if (passesAreFriends(first, second))
|
|
63
|
+
return true;
|
|
64
|
+
return first.who_can_chat === "anyone" && second.who_can_chat === "anyone" && typeof first.chat_group_id === "string" && first.chat_group_id === second.chat_group_id;
|
|
65
|
+
}
|
|
66
|
+
// Plain class fields (no TypeScript-only syntax) so Node can run this file directly.
|
|
67
|
+
export class PassError extends Error {
|
|
68
|
+
code;
|
|
69
|
+
constructor(code) {
|
|
70
|
+
super(`Player pass rejected: ${code}`);
|
|
71
|
+
this.code = code;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export class ReceiptError extends Error {
|
|
75
|
+
code;
|
|
76
|
+
constructor(code) {
|
|
77
|
+
super(`Receipt rejected: ${code}`);
|
|
78
|
+
this.name = "ReceiptError";
|
|
79
|
+
this.code = code;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** A receipt's JWS header typ (RFC 8725 explicit typing): verifyReceipt refuses any other token (wrong_kind). */
|
|
83
|
+
export const RECEIPT_TOKEN_TYPE = "receipt+jwt";
|
|
84
|
+
/** Who signs receipts on zaisma.com: a receipt's iss, apart from a pass's (PASS_ISSUER_URL). */
|
|
85
|
+
export const RECEIPT_ISSUER_URL = "https://rooms.zaisma.com/receipts";
|
|
86
|
+
/** The receipt contract this copy reads: verifyReceipt refuses any other receipt_version_number (wrong_version). */
|
|
87
|
+
export const RECEIPT_VERSION_NUMBER = 1;
|
|
88
|
+
const ED25519 = { name: "Ed25519" };
|
|
89
|
+
const encoder = new TextEncoder();
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
function toBase64Url(bytes) {
|
|
92
|
+
let binary = "";
|
|
93
|
+
for (const byte of bytes)
|
|
94
|
+
binary += String.fromCharCode(byte);
|
|
95
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
96
|
+
}
|
|
97
|
+
function fromBase64Url(text) {
|
|
98
|
+
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
99
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
100
|
+
}
|
|
101
|
+
async function keyId(publicKeyBase64Url) {
|
|
102
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(publicKeyBase64Url)));
|
|
103
|
+
return toBase64Url(digest).slice(0, 16);
|
|
104
|
+
}
|
|
105
|
+
/** Make a new signing key, as a JWK string for a signing secret (the rooms Worker's PASS_SIGNING_KEY and RECEIPT_SIGNING_KEY). */
|
|
106
|
+
export async function generateSigningKey() {
|
|
107
|
+
const pair = (await crypto.subtle.generateKey(ED25519, true, ["sign", "verify"]));
|
|
108
|
+
const jwk = (await crypto.subtle.exportKey("jwk", pair.privateKey));
|
|
109
|
+
return JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x, d: jwk.d });
|
|
110
|
+
}
|
|
111
|
+
export async function loadSigningKey(secret) {
|
|
112
|
+
const jwk = JSON.parse(secret);
|
|
113
|
+
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x || !jwk.d)
|
|
114
|
+
throw new Error("A signing key must be an Ed25519 private JWK");
|
|
115
|
+
const key = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x, d: jwk.d }, ED25519, false, ["sign"]);
|
|
116
|
+
const publicKey = { kty: "OKP", crv: "Ed25519", x: jwk.x, kid: await keyId(jwk.x), alg: "EdDSA", use: "sig" };
|
|
117
|
+
return { key, publicKey };
|
|
118
|
+
}
|
|
119
|
+
/** A pass to sign: Zaisma always lists owned_item_names, and verifyPass reads a pass without them as owning nothing. */
|
|
120
|
+
export async function signPass(signing, claims) {
|
|
121
|
+
const header = toBase64Url(encoder.encode(JSON.stringify({ alg: "EdDSA", typ: PASS_TOKEN_TYPE, kid: signing.publicKey.kid })));
|
|
122
|
+
const body = toBase64Url(encoder.encode(JSON.stringify(claims)));
|
|
123
|
+
const signature = new Uint8Array(await crypto.subtle.sign(ED25519, signing.key, encoder.encode(`${header}.${body}`)));
|
|
124
|
+
return `${header}.${body}.${toBase64Url(signature)}`;
|
|
125
|
+
}
|
|
126
|
+
export function jwks(keys) {
|
|
127
|
+
return { keys };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A token's claims once its signature checks out against one of the keys, for verifyPass and verifyReceipt, which
|
|
131
|
+
* say how to refuse. isKind checks the header's typ before any key is looked up, so a token of the other kind is
|
|
132
|
+
* wrong_kind wherever it's checked, whichever keys are at hand; it only ever refuses, so an unsigned header is enough.
|
|
133
|
+
*/
|
|
134
|
+
async function signedToken(token, keys, isKind, refuse) {
|
|
135
|
+
const parts = typeof token === "string" ? token.split(".") : [];
|
|
136
|
+
if (parts.length !== 3)
|
|
137
|
+
throw refuse("malformed");
|
|
138
|
+
let header;
|
|
139
|
+
let claims;
|
|
140
|
+
try {
|
|
141
|
+
header = JSON.parse(decoder.decode(fromBase64Url(parts[0])));
|
|
142
|
+
claims = JSON.parse(decoder.decode(fromBase64Url(parts[1])));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw refuse("malformed");
|
|
146
|
+
}
|
|
147
|
+
if (!header || header.alg !== "EdDSA" || !claims || typeof claims !== "object")
|
|
148
|
+
throw refuse("malformed");
|
|
149
|
+
if (!isKind(header.typ))
|
|
150
|
+
throw refuse("wrong_kind");
|
|
151
|
+
const jwk = keys.find((publicKey) => publicKey.kid === header.kid);
|
|
152
|
+
if (!jwk)
|
|
153
|
+
throw refuse("unknown_key");
|
|
154
|
+
const key = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, ED25519, false, ["verify"]);
|
|
155
|
+
let signature;
|
|
156
|
+
try {
|
|
157
|
+
signature = fromBase64Url(parts[2]);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
throw refuse("malformed");
|
|
161
|
+
}
|
|
162
|
+
if (!(await crypto.subtle.verify(ED25519, key, signature, encoder.encode(`${parts[0]}.${parts[1]}`))))
|
|
163
|
+
throw refuse("bad_signature");
|
|
164
|
+
return { claims };
|
|
165
|
+
}
|
|
166
|
+
const isStringList = (given) => Array.isArray(given) && given.every((entry) => typeof entry === "string");
|
|
167
|
+
/** Check a pass. Throws PassError if it's forged, expired, for another game, not a pass, or a version this copy doesn't know. */
|
|
168
|
+
export async function verifyPass(token, keys, expect) {
|
|
169
|
+
// A receipt, or any other token, never stands in for a pass, whichever keys it's checked against.
|
|
170
|
+
const { claims: signed } = await signedToken(token, keys, (typ) => typ === PASS_TOKEN_TYPE, (code) => new PassError(code));
|
|
171
|
+
const claims = signed;
|
|
172
|
+
if (claims.pass_version_number !== PASS_VERSION_NUMBER)
|
|
173
|
+
throw new PassError("wrong_version");
|
|
174
|
+
const nowSec = Math.floor((expect.nowMs ?? Date.now()) / 1000);
|
|
175
|
+
if (typeof claims.exp !== "number" || claims.exp <= nowSec)
|
|
176
|
+
throw new PassError("expired");
|
|
177
|
+
if (claims.iss !== (expect.issuerUrl ?? PASS_ISSUER_URL))
|
|
178
|
+
throw new PassError("wrong_issuer");
|
|
179
|
+
if (claims.aud !== expect.gameSlug)
|
|
180
|
+
throw new PassError("wrong_game");
|
|
181
|
+
if (typeof claims.sub !== "string" || typeof claims.kind !== "string")
|
|
182
|
+
throw new PassError("malformed");
|
|
183
|
+
if (claims.kind === "guest" && typeof claims.name !== "string")
|
|
184
|
+
throw new PassError("malformed");
|
|
185
|
+
if (claims.who_can_chat !== undefined && typeof claims.who_can_chat !== "string")
|
|
186
|
+
throw new PassError("malformed");
|
|
187
|
+
if (claims.chat_group_id !== undefined && typeof claims.chat_group_id !== "string")
|
|
188
|
+
throw new PassError("malformed");
|
|
189
|
+
if (claims.player_tag_id !== undefined && typeof claims.player_tag_id !== "string")
|
|
190
|
+
throw new PassError("malformed");
|
|
191
|
+
if (claims.friend_player_tag_ids !== undefined && !isStringList(claims.friend_player_tag_ids))
|
|
192
|
+
throw new PassError("malformed");
|
|
193
|
+
if (claims.block_player_tag_ids !== undefined && !isStringList(claims.block_player_tag_ids))
|
|
194
|
+
throw new PassError("malformed");
|
|
195
|
+
if (claims.owned_item_names === undefined)
|
|
196
|
+
claims.owned_item_names = [];
|
|
197
|
+
if (!isStringList(claims.owned_item_names))
|
|
198
|
+
throw new PassError("malformed");
|
|
199
|
+
return claims;
|
|
200
|
+
}
|
|
201
|
+
/** 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. */
|
|
202
|
+
export async function verifyReceipt(token, keys, expect) {
|
|
203
|
+
const { claims: signed } = await signedToken(token, keys, (typ) => typ === RECEIPT_TOKEN_TYPE, (code) => new ReceiptError(code));
|
|
204
|
+
const claims = signed;
|
|
205
|
+
if (claims.receipt_version_number !== RECEIPT_VERSION_NUMBER)
|
|
206
|
+
throw new ReceiptError(Number.isInteger(claims.receipt_version_number) ? "wrong_version" : "malformed");
|
|
207
|
+
if (claims.exp !== undefined && (typeof claims.exp !== "number" || claims.exp <= Math.floor((expect.nowMs ?? Date.now()) / 1000)))
|
|
208
|
+
throw new ReceiptError("expired");
|
|
209
|
+
if (claims.iss !== (expect.issuerUrl ?? RECEIPT_ISSUER_URL))
|
|
210
|
+
throw new ReceiptError("wrong_issuer");
|
|
211
|
+
if (claims.aud !== expect.gameSlug)
|
|
212
|
+
throw new ReceiptError("wrong_game");
|
|
213
|
+
const isText = (given) => typeof given === "string" && given.length > 0;
|
|
214
|
+
const isNullOr = (given, type) => given === null || typeof given === type;
|
|
215
|
+
if (!isText(claims.sub) ||
|
|
216
|
+
!isText(claims.jti) ||
|
|
217
|
+
!isText(claims.purchase_id) ||
|
|
218
|
+
!isText(claims.purchased_at) ||
|
|
219
|
+
!isText(claims.purchase_status) ||
|
|
220
|
+
!isText(claims.item_name) ||
|
|
221
|
+
!isText(claims.item_kind) ||
|
|
222
|
+
!Number.isInteger(claims.item_count) ||
|
|
223
|
+
!isText(claims.purchase_source) ||
|
|
224
|
+
!isNullOr(claims.paid_minor_units, "number") ||
|
|
225
|
+
!isNullOr(claims.currency_code, "string") ||
|
|
226
|
+
!isNullOr(claims.game_account_id, "string") ||
|
|
227
|
+
typeof claims.iat !== "number") {
|
|
228
|
+
throw new ReceiptError("malformed");
|
|
229
|
+
}
|
|
230
|
+
return claims;
|
|
231
|
+
}
|
package/src/relay.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in game server for games without server code: passes each message and bytes on to
|
|
3
|
+
* everyone else as they were sent, with fromPlayerId saying whose they are. With no server code to decide, the
|
|
4
|
+
* room's first player may hide, show, open and close it, and each player's game sets its own idle check
|
|
5
|
+
* (joinRoom({ idleTimeoutSec, idleCheckEnabled })).
|
|
6
|
+
*/
|
|
7
|
+
export declare const relay: import("./room.ts").GameServer<{}>;
|
package/src/relay.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineGameServer } from "./room.js";
|
|
2
|
+
/**
|
|
3
|
+
* The built-in game server for games without server code: passes each message and bytes on to
|
|
4
|
+
* everyone else as they were sent, with fromPlayerId saying whose they are. With no server code to decide, the
|
|
5
|
+
* room's first player may hide, show, open and close it, and each player's game sets its own idle check
|
|
6
|
+
* (joinRoom({ idleTimeoutSec, idleCheckEnabled })).
|
|
7
|
+
*/
|
|
8
|
+
export const relay = defineGameServer({
|
|
9
|
+
// Not the rooms' 100: the relay sends every message to everyone, so a room's sends grow with the square of its players.
|
|
10
|
+
maximumPlayerCount: 16,
|
|
11
|
+
hostControlsEnabled: true,
|
|
12
|
+
idleCheck: "client",
|
|
13
|
+
setup: () => ({}),
|
|
14
|
+
onMessage(state, player, room, message) {
|
|
15
|
+
room.broadcast(message, { exceptPlayerId: player.id, fromPlayerId: player.id });
|
|
16
|
+
},
|
|
17
|
+
onBytes(state, player, room, bytes) {
|
|
18
|
+
room.broadcastBytes(bytes, { exceptPlayerId: player.id, fromPlayerId: player.id });
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requests: plain calls from a game to its game server's onRequest, outside any room, for a
|
|
3
|
+
* leaderboard, a shop or a daily reward in any game, single-player ones too. Zaisma rooms answer them
|
|
4
|
+
* at rooms.zaisma.com (apps/rooms/src/game-server.ts) and the Node runner on the developer's own
|
|
5
|
+
* server (@zaisma/game-server/node), both at POST /v1/requests/<game>, both checked here so a request that works
|
|
6
|
+
* in one works the same in the other.
|
|
7
|
+
*
|
|
8
|
+
* The game sends { pass, request } as text/plain JSON (so the browser needs no preflight): its player
|
|
9
|
+
* pass, so onRequest knows who's asking, and its request, plain JSON. It gets back { answer }, what
|
|
10
|
+
* onRequest answered, or { error: { code, message } }.
|
|
11
|
+
*/
|
|
12
|
+
import type { GameDatabase } from "./database.ts";
|
|
13
|
+
import type { ReceiptClaims } from "./pass.ts";
|
|
14
|
+
import type { Player } from "./room.ts";
|
|
15
|
+
/**
|
|
16
|
+
* What onRequest, onSchedule and onWebhook reach besides what they're given: the game's own database, the
|
|
17
|
+
* same one its rooms use, its secrets, and receipt checks.
|
|
18
|
+
*/
|
|
19
|
+
export interface Server {
|
|
20
|
+
database: GameDatabase;
|
|
21
|
+
/**
|
|
22
|
+
* The game's secrets, by name, like server.secrets.DISCORD_WEBHOOK_URL: set per game on Zaisma
|
|
23
|
+
* (set_secret) and never shown again, or the environment variables on your own server.
|
|
24
|
+
*/
|
|
25
|
+
secrets: Readonly<Record<string, string>>;
|
|
26
|
+
/** Check a purchase's receipt, as room.verifyReceipt does: the runtime fills in Zaisma's keys and this game. */
|
|
27
|
+
verifyReceipt(receipt: string): Promise<ReceiptClaims>;
|
|
28
|
+
}
|
|
29
|
+
export declare const REQUEST_LIMITS: {
|
|
30
|
+
/** A request, as JSON. */
|
|
31
|
+
readonly maximumRequestSizeBytes: number;
|
|
32
|
+
/**
|
|
33
|
+
* The player pass that comes with it, or with a room's hello: the fullest pass Zaisma makes (1,000 owned
|
|
34
|
+
* items with 40-character names, 200 friends and 100 blocks) is about 70 KB, with room to spare.
|
|
35
|
+
*/
|
|
36
|
+
readonly maximumPassSizeBytes: number;
|
|
37
|
+
/** What onRequest answers, as JSON. */
|
|
38
|
+
readonly maximumAnswerSizeBytes: number;
|
|
39
|
+
/** How long onRequest may take, awaiting the database included, before the request fails. */
|
|
40
|
+
readonly answerTimeoutSec: 10;
|
|
41
|
+
};
|
|
42
|
+
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";
|
|
43
|
+
export declare const REQUEST_ERROR_TEXT: Record<RequestErrorCode, string>;
|
|
44
|
+
/** The HTTP status each request error answers with. */
|
|
45
|
+
export declare const REQUEST_ERROR_HTTP_STATUS: Record<RequestErrorCode, number>;
|
|
46
|
+
/** The body of an error answer. message replaces the usual text. */
|
|
47
|
+
export declare function requestError(code: RequestErrorCode, message?: string): {
|
|
48
|
+
error: {
|
|
49
|
+
code: RequestErrorCode;
|
|
50
|
+
message: string;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Read what a game sent: its pass and its request, plain JSON within REQUEST_LIMITS.maximumRequestSizeBytes
|
|
55
|
+
* (nothing is null). The pass's own size doesn't count.
|
|
56
|
+
*/
|
|
57
|
+
export declare function readRequestBody(text: string): {
|
|
58
|
+
pass: string;
|
|
59
|
+
request: unknown;
|
|
60
|
+
} | {
|
|
61
|
+
code: "bad_request" | "too_big";
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* What onRequest answered, as the game gets it: plain JSON (nothing is null), within
|
|
65
|
+
* REQUEST_LIMITS.maximumAnswerSizeBytes, or a mistake for the game server's errors.
|
|
66
|
+
*/
|
|
67
|
+
export declare function readRequestAnswer(answer: unknown): {
|
|
68
|
+
answer: unknown;
|
|
69
|
+
} | {
|
|
70
|
+
mistake: string;
|
|
71
|
+
};
|
|
72
|
+
/** A game server that answers requests. */
|
|
73
|
+
export interface RequestHandler {
|
|
74
|
+
onRequest?(player: Player, request: unknown, server: Server): unknown;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Run a game server's onRequest for one request, with a time limit, and say what the game gets. A
|
|
78
|
+
* game server without onRequest answers no_request_handler. When it throws, answers something that
|
|
79
|
+
* isn't plain JSON or takes too long, the game gets game_server_error, and failure says why for the
|
|
80
|
+
* game server's errors.
|
|
81
|
+
*/
|
|
82
|
+
export declare function answerRequest(handler: RequestHandler, player: Player, request: unknown, server: Server): Promise<{
|
|
83
|
+
answer: unknown;
|
|
84
|
+
} | {
|
|
85
|
+
code: "no_request_handler";
|
|
86
|
+
} | {
|
|
87
|
+
code: "game_server_error";
|
|
88
|
+
failure: string;
|
|
89
|
+
}>;
|