@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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Webhooks to the game server: a webhook can send Zaisma's events about a game to that game's own
3
+ * game server, whose onWebhook(event, server) turns them into whatever it likes, like a Discord post
4
+ * or a GitHub issue made with the developer's own tokens from server.secrets (docs/DECISIONS.md,
5
+ * "Webhooks to the game server"). Zaisma runs it in the game's own object (apps/rooms/src/game-server.ts)
6
+ * for a webhook whose destination_kind is game_server. On the developer's own server the Node runner
7
+ * (@zaisma/game-server/node) takes the same event as a signed POST at /v1/webhooks, checks it here with the
8
+ * webhook's whsec_ signing secret (Standard Webhooks, standardwebhooks.com) and runs the same
9
+ * onWebhook, so the code moves unchanged.
10
+ */
11
+ export const WEBHOOK_LIMITS = {
12
+ /** How long onWebhook may take, awaiting the database and outside calls included, before the try fails and waits for the next. */
13
+ answerTimeoutSec: 10,
14
+ /** The most of a webhook's POST verifyWebhook and the Node runner take, in bytes: well above any event Zaisma sends. */
15
+ maximumEventSizeBytes: 16 * 1024 * 1024,
16
+ /** How far a POST's webhook-timestamp may be from the server's clock, so an old one can't be sent again. */
17
+ timestampToleranceDurationSec: 300,
18
+ };
19
+ const webhookError = (code, message, fix) => ({ error: { code, message, fix } });
20
+ /**
21
+ * Run a game server's onWebhook for one event, with a time limit. An error says why the try failed,
22
+ * for the game server's errors: it has no onWebhook, it threw, or it took longer than
23
+ * WEBHOOK_LIMITS.answerTimeoutSec.
24
+ */
25
+ export async function runWebhook(handler, event, server) {
26
+ if (typeof handler.onWebhook !== "function")
27
+ return webhookError("no_webhook_handler", "This game server has no onWebhook.", "Add onWebhook(event, server) to the game server.");
28
+ let timer;
29
+ try {
30
+ await Promise.race([
31
+ Promise.resolve().then(() => handler.onWebhook(event, server)),
32
+ new Promise((_, reject) => {
33
+ timer = setTimeout(() => reject(new Error(`onWebhook didn't finish within ${WEBHOOK_LIMITS.answerTimeoutSec} seconds`)), WEBHOOK_LIMITS.answerTimeoutSec * 1000);
34
+ }),
35
+ ]);
36
+ return { isDone: true };
37
+ }
38
+ catch (error) {
39
+ return webhookError("game_server_error", String((error instanceof Error && error.stack) || error), "Fix onWebhook: Zaisma tries the event again later.");
40
+ }
41
+ finally {
42
+ clearTimeout(timer);
43
+ }
44
+ }
45
+ const bytesOfBase64 = (text) => Uint8Array.from(atob(text), (character) => character.charCodeAt(0));
46
+ /**
47
+ * Check a webhook POST the Standard Webhooks way: its webhook-signature holds a "v1,<base64>" HMAC-SHA256
48
+ * of "<webhook-id>.<webhook-timestamp>.<body>" keyed by the signing secret's bytes, and its timestamp is
49
+ * within WEBHOOK_LIMITS.timestampToleranceDurationSec of now. Answers the event, or an error saying why
50
+ * it isn't one to trust.
51
+ */
52
+ export async function verifyWebhook(signingSecret, headers, body, nowMs = Date.now()) {
53
+ const { webhookId, webhookTimestamp, webhookSignature } = headers;
54
+ if (!webhookId || !webhookTimestamp || !webhookSignature)
55
+ return webhookError("missing_headers", "It has no webhook-id, webhook-timestamp or webhook-signature header.", "Only take POSTs Zaisma's webhooks send, with their headers as they came.");
56
+ if (new TextEncoder().encode(body).length > WEBHOOK_LIMITS.maximumEventSizeBytes) {
57
+ return webhookError("too_large", `Its body is over ${WEBHOOK_LIMITS.maximumEventSizeBytes} bytes, more than any event Zaisma sends.`, "Only take POSTs Zaisma's webhooks send.");
58
+ }
59
+ const timestampSec = Number(webhookTimestamp);
60
+ if (!Number.isInteger(timestampSec) || Math.abs(nowMs / 1000 - timestampSec) > WEBHOOK_LIMITS.timestampToleranceDurationSec) {
61
+ return webhookError("old_timestamp", `Its webhook-timestamp isn't within ${WEBHOOK_LIMITS.timestampToleranceDurationSec} seconds of this server's clock.`, "Check this server's clock; an old event sent again is refused on purpose.");
62
+ }
63
+ let key;
64
+ try {
65
+ key = await crypto.subtle.importKey("raw", bytesOfBase64(signingSecret.replace(/^whsec_/, "")), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
66
+ }
67
+ catch {
68
+ return webhookError("bad_signing_secret", "The signing secret isn't a whsec_ secret from create_webhook.", "Use the webhook's signing_secret, as create_webhook or replace_signing_secret answered it.");
69
+ }
70
+ const signed = new TextEncoder().encode(`${webhookId}.${webhookTimestamp}.${body}`);
71
+ let isSigned = false;
72
+ // Several signatures, space-separated, while a secret is replaced; any one that matches will do.
73
+ for (const part of webhookSignature.split(" ")) {
74
+ const [version, signature] = part.split(",");
75
+ if (version !== "v1" || !signature)
76
+ continue;
77
+ let signatureBytes;
78
+ try {
79
+ signatureBytes = bytesOfBase64(signature);
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ // crypto.subtle.verify compares in constant time.
85
+ if (await crypto.subtle.verify("HMAC", key, signatureBytes, signed)) {
86
+ isSigned = true;
87
+ break;
88
+ }
89
+ }
90
+ if (!isSigned)
91
+ return webhookError("bad_signature", "Its webhook-signature doesn't match the signing secret.", "Check the body as it came, byte for byte, with the webhook's current signing_secret.");
92
+ let event;
93
+ try {
94
+ event = JSON.parse(body);
95
+ }
96
+ catch {
97
+ return webhookError("not_an_event", "Its body isn't JSON.", "Only take POSTs Zaisma's webhooks send.");
98
+ }
99
+ if (!event || typeof event !== "object" || Array.isArray(event) || event.id !== webhookId)
100
+ return webhookError("not_an_event", "Its body isn't a webhook event with the webhook-id's id.", "Only take POSTs Zaisma's webhooks send.");
101
+ return { event: event };
102
+ }