From e924bb123a579e1c57b999565fe9d8d2e4e0345b Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:43:58 +0000 Subject: [PATCH 01/11] fix(deps): override esbuild audit advisory --- bun.lock | 157 ++++++++++----------------------------------------- package.json | 3 + 2 files changed, 33 insertions(+), 127 deletions(-) diff --git a/bun.lock b/bun.lock index c3c4c18..4192c11 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,9 @@ }, }, }, + "overrides": { + "esbuild": "^0.28.1", + }, "packages": { "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], @@ -69,57 +72,57 @@ "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "@pixi/colord": ["@pixi/colord@2.9.6", "", {}, "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA=="], @@ -149,7 +152,7 @@ "earcut": ["earcut@3.0.2", "", {}, "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -184,105 +187,5 @@ "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - - "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], - - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], - - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], } } diff --git a/package.json b/package.json index ee47125..b60098e 100644 --- a/package.json +++ b/package.json @@ -38,5 +38,8 @@ "@biomejs/biome": "^2.0.0", "bun-types": "^1.3.13", "typescript": "^5.8.3" + }, + "overrides": { + "esbuild": "^0.28.1" } } From c690851d55131212155c1b50c9928ac145f5b716 Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:48:54 +0000 Subject: [PATCH 02/11] fix(protocol): bound server message parsing --- apps/client/src/game/NetClient.test.ts | 18 ++++ apps/client/src/game/NetClient.ts | 19 +--- packages/protocol/src/parse.ts | 21 +++- packages/protocol/src/protocol.test.ts | 42 ++++++++ packages/protocol/src/schemas.ts | 135 +++++++++++++++---------- 5 files changed, 168 insertions(+), 67 deletions(-) diff --git a/apps/client/src/game/NetClient.test.ts b/apps/client/src/game/NetClient.test.ts index 0e33c4a..9b61e7b 100644 --- a/apps/client/src/game/NetClient.test.ts +++ b/apps/client/src/game/NetClient.test.ts @@ -141,6 +141,24 @@ describe("NetClient", () => { expect(() => client.send({ type: "ping", sentAt: "now" })).toThrow("WebSocket is not open"); }); + test("rejects oversized server messages before emitting them", async () => { + installBrowserFakes("http:"); + const client = new NetClient(); + const statuses: string[] = []; + const received: unknown[] = []; + client.onStatus((status) => statuses.push(status)); + client.onMessage((message) => received.push(message)); + + const connected = client.connect(); + const socket = currentSocket(); + socket.open(); + await connected; + socket.message("x".repeat(64 * 1024 + 1)); + + expect(statuses).toContain("received invalid server message"); + expect(received).toEqual([]); + }); + test("reports unexpected disconnects after a socket was open", async () => { installBrowserFakes("http:"); const client = new NetClient(); diff --git a/apps/client/src/game/NetClient.ts b/apps/client/src/game/NetClient.ts index 04bbcaa..98c955c 100644 --- a/apps/client/src/game/NetClient.ts +++ b/apps/client/src/game/NetClient.ts @@ -1,4 +1,4 @@ -import { parseServerMessage } from "@tilezo/protocol"; +import { parseRawServerMessage } from "@tilezo/protocol"; import type { ClientMessage, ServerMessage } from "@tilezo/protocol/messages"; import { getWebSocketUrl } from "../config"; @@ -88,19 +88,10 @@ export class NetClient { return; } - let parsedJson: unknown; - - try { - parsedJson = JSON.parse(raw); - } catch { - this.emitStatus("received invalid server message"); - return; - } - - // Validate the server message against the shared schema instead of blindly casting, - // so a malformed or skewed payload is reported cleanly rather than throwing deep in - // the scene/avatar code and silently dropping a state update. - const parsed = parseServerMessage(parsedJson); + // Validate the raw server message against size and schema limits instead of + // blindly parsing/casting, so malformed, oversized, or skewed payloads are + // reported cleanly rather than crashing the browser client. + const parsed = parseRawServerMessage(raw); if (!parsed.ok) { this.emitStatus("received invalid server message"); diff --git a/packages/protocol/src/parse.ts b/packages/protocol/src/parse.ts index 0d983d3..4b28252 100644 --- a/packages/protocol/src/parse.ts +++ b/packages/protocol/src/parse.ts @@ -1,5 +1,10 @@ import type { ClientMessage, ServerMessage } from "./messages"; -import { clientMessageSchema, MAX_RAW_MESSAGE_BYTES, serverMessageSchema } from "./schemas"; +import { + clientMessageSchema, + MAX_RAW_MESSAGE_BYTES, + MAX_RAW_SERVER_MESSAGE_BYTES, + serverMessageSchema, +} from "./schemas"; export type ParseResult = { ok: true; value: ClientMessage } | { ok: false; error: string }; @@ -38,3 +43,17 @@ export function parseRawClientMessage(raw: string | Buffer): ParseResult { return { ok: false, error: "Malformed JSON" }; } } + +export function parseRawServerMessage(raw: string | Buffer): ServerParseResult { + const byteLength = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength; + + if (byteLength > MAX_RAW_SERVER_MESSAGE_BYTES) { + return { ok: false, error: "Server message is too large" }; + } + + try { + return parseServerMessage(JSON.parse(raw.toString())); + } catch { + return { ok: false, error: "Malformed server JSON" }; + } +} diff --git a/packages/protocol/src/protocol.test.ts b/packages/protocol/src/protocol.test.ts index 003a6f9..6a6ed69 100644 --- a/packages/protocol/src/protocol.test.ts +++ b/packages/protocol/src/protocol.test.ts @@ -4,8 +4,10 @@ import { createRandomAvatarAppearance, DEFAULT_AVATAR_APPEARANCE, MAX_RAW_MESSAGE_BYTES, + MAX_RAW_SERVER_MESSAGE_BYTES, parseClientMessage, parseRawClientMessage, + parseRawServerMessage, parseServerMessage, } from "."; @@ -372,6 +374,46 @@ describe("parseServerMessage", () => { expect(parseServerMessage({ type: "unknown.kind" }).ok).toBe(false); expect(parseServerMessage(null).ok).toBe(false); }); + + test("rejects oversized and unbounded server payload fields", () => { + expect(parseRawServerMessage("x".repeat(MAX_RAW_SERVER_MESSAGE_BYTES + 1))).toEqual({ + ok: false, + error: "Server message is too large", + }); + expect(parseRawServerMessage("{bad json")).toEqual({ + ok: false, + error: "Malformed server JSON", + }); + expect( + parseServerMessage({ + type: "chat.message", + userId: "user_1", + username: "Dan", + text: "x".repeat(241), + sentAt: new Date().toISOString(), + }).ok, + ).toBe(false); + expect( + parseServerMessage({ + type: "room.list", + rooms: Array.from({ length: 101 }, (_, index) => ({ + id: `room_${index}`, + name: `Room ${index}`, + userCount: 0, + joined: false, + })), + }).ok, + ).toBe(false); + expect( + parseServerMessage({ + type: "dm.read", + readerUserId: "user_1", + otherUserId: "user_2", + messageIds: Array.from({ length: 201 }, (_, index) => `dm_${index}`), + readAt: new Date().toISOString(), + }).ok, + ).toBe(false); + }); }); function createSequenceRandom(values: readonly number[]): () => number { diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index f94faac..e0a9814 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -12,6 +12,7 @@ import { } from "./appearance"; export const MAX_RAW_MESSAGE_BYTES = 8 * 1024; +export const MAX_RAW_SERVER_MESSAGE_BYTES = 64 * 1024; export const USERNAME_MAX_LENGTH = 24; export const USER_ID_MAX_LENGTH = 64; export const ROOM_ID_MAX_LENGTH = 64; @@ -21,6 +22,15 @@ export const ITEM_ACTION_MAX_LENGTH = 64; export const MESSAGE_ID_MAX_LENGTH = 128; export const CHAT_MAX_LENGTH = 240; export const DIRECT_MESSAGE_MAX_LENGTH = 600; +export const SERVER_ERROR_MAX_LENGTH = 300; +export const SERVER_TIMESTAMP_MAX_LENGTH = 128; +export const SERVER_ROOM_LIST_MAX = 100; +export const SERVER_ROOM_USERS_MAX = 200; +export const SERVER_ROOM_TILES_MAX = 10_000; +export const SERVER_ROOM_ITEMS_MAX = 2_000; +export const SERVER_MOVEMENT_PATH_MAX = 256; +export const SERVER_INVENTORY_ITEMS_MAX = 500; +export const SERVER_DM_READ_RECEIPT_MAX = 200; export const DOLLARS_MAX = 999_999_999; // Tile coordinates are bounded at the trust boundary so untrusted clients cannot // send absurd integers (e.g. near MAX_SAFE_INTEGER). The bound is far larger than @@ -172,6 +182,10 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ pingMessageSchema, ]); +const boundedServerString = (maxLength: number) => z.string().min(1).max(maxLength); +const boundedServerText = (maxLength: number) => z.string().max(maxLength); +const serverTimestamp = boundedServerString(SERVER_TIMESTAMP_MAX_LENGTH); + const roomTileSchema = z.object({ x: tileCoordinate, y: tileCoordinate, @@ -180,28 +194,28 @@ const roomTileSchema = z.object({ }); const roomUserSnapshotSchema = z.object({ - id: z.string(), - username: z.string(), + id: boundedServerString(USER_ID_MAX_LENGTH), + username: boundedServerString(USERNAME_MAX_LENGTH), position: tilePositionSchema, appearance: avatarAppearanceSchema, - movementPath: z.array(tilePositionSchema).optional(), + movementPath: z.array(tilePositionSchema).max(SERVER_MOVEMENT_PATH_MAX).optional(), }); const publicRoomSummarySchema = z.object({ - id: z.string(), - name: z.string(), - userCount: z.number(), + id: boundedServerString(ROOM_ID_MAX_LENGTH), + name: boundedServerString(ROOM_ID_MAX_LENGTH), + userCount: z.number().int().min(0).max(SERVER_ROOM_USERS_MAX), joined: z.boolean(), }); const roomItemSchema = z.object({ - id: z.string(), - itemType: z.string(), + id: boundedServerString(ITEM_ID_MAX_LENGTH), + itemType: boundedServerString(ITEM_TYPE_MAX_LENGTH), x: tileCoordinate, y: tileCoordinate, z: z.number().int(), rotation: furnitureRotation, - state: z.record(z.string(), z.unknown()), + state: z.record(boundedServerString(ITEM_ACTION_MAX_LENGTH), z.unknown()), }); export const inventoryItemSchema = z.object({ @@ -213,88 +227,105 @@ export const inventoryItemSchema = z.object({ // payload surfaces as a clean "invalid server message" instead of throwing deep in // the scene/avatar code and silently dropping that state update (client desync). export const serverMessageSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("connected"), userId: z.string(), dollars: dollarsSchema }), + z.object({ + type: z.literal("connected"), + userId: boundedServerString(USER_ID_MAX_LENGTH), + dollars: dollarsSchema, + }), z.object({ type: z.literal("room.snapshot"), - roomId: z.string(), - users: z.array(roomUserSnapshotSchema), - tiles: z.array(roomTileSchema), - items: z.array(roomItemSchema), + roomId: boundedServerString(ROOM_ID_MAX_LENGTH), + users: z.array(roomUserSnapshotSchema).max(SERVER_ROOM_USERS_MAX), + tiles: z.array(roomTileSchema).max(SERVER_ROOM_TILES_MAX), + items: z.array(roomItemSchema).max(SERVER_ROOM_ITEMS_MAX), canEditItems: z.boolean(), }), - z.object({ type: z.literal("room.list"), rooms: z.array(publicRoomSummarySchema) }), + z.object({ + type: z.literal("room.list"), + rooms: z.array(publicRoomSummarySchema).max(SERVER_ROOM_LIST_MAX), + }), z.object({ type: z.literal("user.joined"), user: roomUserSnapshotSchema }), - z.object({ type: z.literal("user.left"), userId: z.string() }), + z.object({ type: z.literal("user.left"), userId: boundedServerString(USER_ID_MAX_LENGTH) }), z.object({ type: z.literal("avatar.moved"), - userId: z.string(), - path: z.array(tilePositionSchema), + userId: boundedServerString(USER_ID_MAX_LENGTH), + path: z.array(tilePositionSchema).max(SERVER_MOVEMENT_PATH_MAX), }), z.object({ type: z.literal("avatar.appearance.updated"), - userId: z.string(), + userId: boundedServerString(USER_ID_MAX_LENGTH), appearance: avatarAppearanceSchema, }), z.object({ type: z.literal("chat.message"), - userId: z.string(), - username: z.string(), - text: z.string(), - sentAt: z.string(), + userId: boundedServerString(USER_ID_MAX_LENGTH), + username: boundedServerString(USERNAME_MAX_LENGTH), + text: boundedServerText(CHAT_MAX_LENGTH), + sentAt: serverTimestamp, }), z.object({ type: z.literal("dm.message"), - id: z.string(), - fromUserId: z.string(), - toUserId: z.string(), - text: z.string(), - sentAt: z.string(), - readAt: z.string().optional(), - editedAt: z.string().optional(), - deletedAt: z.string().optional(), + id: boundedServerString(MESSAGE_ID_MAX_LENGTH), + fromUserId: boundedServerString(USER_ID_MAX_LENGTH), + toUserId: boundedServerString(USER_ID_MAX_LENGTH), + text: boundedServerText(DIRECT_MESSAGE_MAX_LENGTH), + sentAt: serverTimestamp, + readAt: serverTimestamp.optional(), + editedAt: serverTimestamp.optional(), + deletedAt: serverTimestamp.optional(), }), z.object({ type: z.literal("dm.typing"), - fromUserId: z.string(), - toUserId: z.string(), + fromUserId: boundedServerString(USER_ID_MAX_LENGTH), + toUserId: boundedServerString(USER_ID_MAX_LENGTH), isTyping: z.boolean(), }), z.object({ type: z.literal("dm.read"), - readerUserId: z.string(), - otherUserId: z.string(), - messageIds: z.array(z.string()), - readAt: z.string(), + readerUserId: boundedServerString(USER_ID_MAX_LENGTH), + otherUserId: boundedServerString(USER_ID_MAX_LENGTH), + messageIds: z.array(boundedServerString(MESSAGE_ID_MAX_LENGTH)).max(SERVER_DM_READ_RECEIPT_MAX), + readAt: serverTimestamp, }), z.object({ type: z.literal("dm.edited"), - id: z.string(), - fromUserId: z.string(), - toUserId: z.string(), - text: z.string(), - editedAt: z.string(), + id: boundedServerString(MESSAGE_ID_MAX_LENGTH), + fromUserId: boundedServerString(USER_ID_MAX_LENGTH), + toUserId: boundedServerString(USER_ID_MAX_LENGTH), + text: boundedServerText(DIRECT_MESSAGE_MAX_LENGTH), + editedAt: serverTimestamp, }), z.object({ type: z.literal("dm.deleted"), - id: z.string(), - fromUserId: z.string(), - toUserId: z.string(), - deletedAt: z.string(), + id: boundedServerString(MESSAGE_ID_MAX_LENGTH), + fromUserId: boundedServerString(USER_ID_MAX_LENGTH), + toUserId: boundedServerString(USER_ID_MAX_LENGTH), + deletedAt: serverTimestamp, }), z.object({ type: z.literal("chat.typing"), - userId: z.string(), - username: z.string(), + userId: boundedServerString(USER_ID_MAX_LENGTH), + username: boundedServerString(USERNAME_MAX_LENGTH), isTyping: z.boolean(), }), z.object({ type: z.literal("room.item.placed"), item: roomItemSchema }), z.object({ type: z.literal("room.item.moved"), item: roomItemSchema }), - z.object({ type: z.literal("room.item.picked_up"), itemId: z.string() }), + z.object({ + type: z.literal("room.item.picked_up"), + itemId: boundedServerString(ITEM_ID_MAX_LENGTH), + }), z.object({ type: z.literal("room.item.state_updated"), item: roomItemSchema }), z.object({ type: z.literal("balance.updated"), dollars: dollarsSchema }), - z.object({ type: z.literal("inventory.updated"), items: z.array(inventoryItemSchema) }), - z.object({ type: z.literal("pong"), sentAt: z.string() }), - z.object({ type: z.literal("error"), code: z.string(), message: z.string() }), + z.object({ + type: z.literal("inventory.updated"), + items: z.array(inventoryItemSchema).max(SERVER_INVENTORY_ITEMS_MAX), + }), + z.object({ type: z.literal("pong"), sentAt: serverTimestamp }), + z.object({ + type: z.literal("error"), + code: boundedServerString(ITEM_ACTION_MAX_LENGTH), + message: boundedServerText(SERVER_ERROR_MAX_LENGTH), + }), ]); // Keep only characters that are safe to broadcast and render as plain text. Unlike the From 68cbcb0e804edcffe57a8a4eff0054113adc89b9 Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:50:13 +0000 Subject: [PATCH 03/11] fix(client): reject insecure remote endpoints --- apps/client/src/config.test.ts | 26 ++++++++++++++++++--- apps/client/src/config.ts | 42 ++++++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/apps/client/src/config.test.ts b/apps/client/src/config.test.ts index 71e10e9..1b5ebc0 100644 --- a/apps/client/src/config.test.ts +++ b/apps/client/src/config.test.ts @@ -32,15 +32,35 @@ describe("config", () => { expect(getWebSocketUrl()).toBe(DEFAULT_WS_URL); }); - test("uses build-time values when no runtime config exists", () => { + test("uses secure build-time values when no runtime config exists", () => { Reflect.deleteProperty(globalThis, "window"); - process.env.PUBLIC_API_URL = "http://build-api.example.test/"; + process.env.PUBLIC_API_URL = "https://build-api.example.test/"; process.env.PUBLIC_WS_URL = "wss://build-ws.example.test/socket/"; - expect(getApiUrl()).toBe("http://build-api.example.test"); + expect(getApiUrl()).toBe("https://build-api.example.test"); expect(getWebSocketUrl()).toBe("wss://build-ws.example.test/socket"); }); + test("rejects insecure non-local endpoint overrides", () => { + installWindowConfig({ + PUBLIC_API_URL: "http://api.example.test", + PUBLIC_WS_URL: "ws://socket.example.test/ws", + }); + + expect(getApiUrl()).toBe(DEFAULT_API_URL); + expect(getWebSocketUrl()).toBe(DEFAULT_WS_URL); + }); + + test("allows insecure localhost endpoint overrides for development", () => { + installWindowConfig({ + PUBLIC_API_URL: "http://127.0.0.1:4000/", + PUBLIC_WS_URL: "ws://localhost:4000/ws/", + }); + + expect(getApiUrl()).toBe("http://127.0.0.1:4000"); + expect(getWebSocketUrl()).toBe("ws://localhost:4000/ws"); + }); + test("derives a secure browser websocket fallback on https pages", () => { Reflect.deleteProperty(globalThis, "window"); restoreEnv("PUBLIC_WS_URL", undefined); diff --git a/apps/client/src/config.ts b/apps/client/src/config.ts index 2c45e55..269b1f2 100644 --- a/apps/client/src/config.ts +++ b/apps/client/src/config.ts @@ -1,10 +1,9 @@ import { DEFAULT_API_URL, DEFAULT_WS_URL } from "./assets"; export function getApiUrl(): string { - return normalizeBaseUrl(getConfiguredValue("PUBLIC_API_URL"), DEFAULT_API_URL, [ - "http:", - "https:", - ]); + return normalizeBaseUrl(getConfiguredValue("PUBLIC_API_URL"), DEFAULT_API_URL, ["https:"], { + allowLocalInsecure: true, + }); } export function apiUrl(path: string): string { @@ -13,10 +12,15 @@ export function apiUrl(path: string): string { export function getWebSocketUrl(): string { const browserDefault = getBrowserWebSocketUrl(); - return normalizeBaseUrl(getConfiguredValue("PUBLIC_WS_URL"), browserDefault ?? DEFAULT_WS_URL, [ - "ws:", - "wss:", - ]); + return normalizeBaseUrl( + getConfiguredValue("PUBLIC_WS_URL"), + browserDefault ?? DEFAULT_WS_URL, + ["wss:"], + { + allowLocalInsecure: true, + insecureProtocols: ["ws:"], + }, + ); } function getConfiguredValue(key: "PUBLIC_API_URL" | "PUBLIC_WS_URL"): string | undefined { @@ -28,23 +32,37 @@ function getConfiguredValue(key: "PUBLIC_API_URL" | "PUBLIC_WS_URL"): string | u function normalizeBaseUrl( configured: string | undefined, fallback: string, - protocols: readonly string[], + secureProtocols: readonly string[], + options: { allowLocalInsecure: boolean; insecureProtocols?: readonly string[] }, ): string { const raw = configured?.trim() || fallback; try { const url = new URL(raw); - if (!protocols.includes(url.protocol)) { - return fallback; + if (secureProtocols.includes(url.protocol)) { + return url.toString().replace(/\/$/, ""); } - return url.toString().replace(/\/$/, ""); + if ( + options.allowLocalInsecure && + (options.insecureProtocols ?? ["http:"]).includes(url.protocol) && + isLocalHostname(url.hostname) + ) { + return url.toString().replace(/\/$/, ""); + } + + return fallback; } catch { return fallback; } } +function isLocalHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +} + function getBrowserWebSocketUrl(): string | undefined { if (typeof location === "undefined") { return undefined; From deaae0ea377674b875161558c4b155c79118322c Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:20:10 +0000 Subject: [PATCH 04/11] fix(server): rate limit purchases and websocket upgrades --- apps/server/src/config.test.ts | 22 ++++++++++ apps/server/src/config.ts | 38 ++++++++++++++++++ apps/server/src/http/router.test.ts | 18 +++++++++ apps/server/src/http/router.ts | 12 ++++++ apps/server/src/serverRuntime.test.ts | 48 ++++++++++++++++++++++ apps/server/src/serverRuntime.ts | 58 +++++++++++++++++++++++++++ 6 files changed, 196 insertions(+) diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index c8ea525..4fd9b56 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -28,8 +28,13 @@ describe("getConfig", () => { friendRateLimitWindowMs: 60000, clientEventRateLimitMax: 1000, clientEventRateLimitWindowMs: DEFAULT_CLIENT_EVENT_RATE_LIMIT_WINDOW_MS, + inventoryPurchaseRateLimitMax: 1000, + inventoryPurchaseRateLimitWindowMs: 60000, + websocketUpgradeRateLimitMax: 1000, + websocketUpgradeRateLimitWindowMs: 60000, maxRoomsPerUser: 50, maxFriendsPerUser: 500, + maxWebSocketConnectionsPerUser: 5, maxAuthBodyBytes: 4096, trustProxy: false, metricsToken: undefined, @@ -55,8 +60,11 @@ describe("getConfig", () => { ROOM_CREATE_RATE_LIMIT_MAX: "5", FRIEND_RATE_LIMIT_MAX: "40", CLIENT_EVENT_RATE_LIMIT_MAX: "30", + INVENTORY_PURCHASE_RATE_LIMIT_MAX: "7", + WEBSOCKET_UPGRADE_RATE_LIMIT_MAX: "9", MAX_ROOMS_PER_USER: "25", MAX_FRIENDS_PER_USER: "200", + MAX_WEBSOCKET_CONNECTIONS_PER_USER: "3", MAX_AUTH_BODY_BYTES: "2048", TRUST_PROXY: "true", METRICS_TOKEN: "metrics-secret", @@ -81,8 +89,13 @@ describe("getConfig", () => { friendRateLimitWindowMs: 60000, clientEventRateLimitMax: 30, clientEventRateLimitWindowMs: DEFAULT_CLIENT_EVENT_RATE_LIMIT_WINDOW_MS, + inventoryPurchaseRateLimitMax: 7, + inventoryPurchaseRateLimitWindowMs: 60000, + websocketUpgradeRateLimitMax: 9, + websocketUpgradeRateLimitWindowMs: 60000, maxRoomsPerUser: 25, maxFriendsPerUser: 200, + maxWebSocketConnectionsPerUser: 3, maxAuthBodyBytes: 2048, trustProxy: true, metricsToken: "metrics-secret", @@ -113,6 +126,15 @@ describe("getConfig", () => { expect(() => getConfig({ AUTH_REGISTER_RATE_LIMIT_WINDOW_MS: "0" })).toThrow( "AUTH_REGISTER_RATE_LIMIT_WINDOW_MS must be a positive integer", ); + expect(() => getConfig({ INVENTORY_PURCHASE_RATE_LIMIT_MAX: "0" })).toThrow( + "INVENTORY_PURCHASE_RATE_LIMIT_MAX must be a positive integer", + ); + expect(() => getConfig({ WEBSOCKET_UPGRADE_RATE_LIMIT_MAX: "0" })).toThrow( + "WEBSOCKET_UPGRADE_RATE_LIMIT_MAX must be a positive integer", + ); + expect(() => getConfig({ MAX_WEBSOCKET_CONNECTIONS_PER_USER: "0" })).toThrow( + "MAX_WEBSOCKET_CONNECTIONS_PER_USER must be a positive integer", + ); }); test("requires production database and strong auth secret", () => { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index dff70ee..2abf871 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -18,8 +18,13 @@ export type ServerConfig = { friendRateLimitWindowMs: number; clientEventRateLimitMax: number; clientEventRateLimitWindowMs: number; + inventoryPurchaseRateLimitMax: number; + inventoryPurchaseRateLimitWindowMs: number; + websocketUpgradeRateLimitMax: number; + websocketUpgradeRateLimitWindowMs: number; maxRoomsPerUser: number; maxFriendsPerUser: number; + maxWebSocketConnectionsPerUser: number; maxAuthBodyBytes: number; trustProxy: boolean; metricsToken?: string; @@ -39,8 +44,11 @@ export const DEFAULT_AUTH_LOGIN_RATE_LIMIT_WINDOW_MS = 60_000; export const DEFAULT_ROOM_CREATE_RATE_LIMIT_WINDOW_MS = 60_000; export const DEFAULT_FRIEND_RATE_LIMIT_WINDOW_MS = 60_000; export const DEFAULT_CLIENT_EVENT_RATE_LIMIT_WINDOW_MS = 60_000; +export const DEFAULT_INVENTORY_PURCHASE_RATE_LIMIT_WINDOW_MS = 60_000; +export const DEFAULT_WEBSOCKET_UPGRADE_RATE_LIMIT_WINDOW_MS = 60_000; export const DEFAULT_MAX_ROOMS_PER_USER = 50; export const DEFAULT_MAX_FRIENDS_PER_USER = 500; +export const DEFAULT_MAX_WEBSOCKET_CONNECTIONS_PER_USER = 5; export const DEFAULT_MAX_AUTH_BODY_BYTES = 4 * 1024; // Placeholder secrets that must never be accepted in production even if long enough. @@ -117,6 +125,26 @@ export function getConfig(env = Bun.env): ServerConfig { env.CLIENT_EVENT_RATE_LIMIT_MAX, isProduction ? 120 : 1000, ); + const inventoryPurchaseRateLimitWindowMs = parsePositiveInteger( + "INVENTORY_PURCHASE_RATE_LIMIT_WINDOW_MS", + env.INVENTORY_PURCHASE_RATE_LIMIT_WINDOW_MS, + DEFAULT_INVENTORY_PURCHASE_RATE_LIMIT_WINDOW_MS, + ); + const inventoryPurchaseRateLimitMax = parsePositiveInteger( + "INVENTORY_PURCHASE_RATE_LIMIT_MAX", + env.INVENTORY_PURCHASE_RATE_LIMIT_MAX, + isProduction ? 30 : 1000, + ); + const websocketUpgradeRateLimitWindowMs = parsePositiveInteger( + "WEBSOCKET_UPGRADE_RATE_LIMIT_WINDOW_MS", + env.WEBSOCKET_UPGRADE_RATE_LIMIT_WINDOW_MS, + DEFAULT_WEBSOCKET_UPGRADE_RATE_LIMIT_WINDOW_MS, + ); + const websocketUpgradeRateLimitMax = parsePositiveInteger( + "WEBSOCKET_UPGRADE_RATE_LIMIT_MAX", + env.WEBSOCKET_UPGRADE_RATE_LIMIT_MAX, + isProduction ? 20 : 1000, + ); const maxRoomsPerUser = parsePositiveInteger( "MAX_ROOMS_PER_USER", env.MAX_ROOMS_PER_USER, @@ -127,6 +155,11 @@ export function getConfig(env = Bun.env): ServerConfig { env.MAX_FRIENDS_PER_USER, DEFAULT_MAX_FRIENDS_PER_USER, ); + const maxWebSocketConnectionsPerUser = parsePositiveInteger( + "MAX_WEBSOCKET_CONNECTIONS_PER_USER", + env.MAX_WEBSOCKET_CONNECTIONS_PER_USER, + DEFAULT_MAX_WEBSOCKET_CONNECTIONS_PER_USER, + ); const maxAuthBodyBytes = parsePositiveInteger( "MAX_AUTH_BODY_BYTES", env.MAX_AUTH_BODY_BYTES, @@ -177,8 +210,13 @@ export function getConfig(env = Bun.env): ServerConfig { friendRateLimitWindowMs, clientEventRateLimitMax, clientEventRateLimitWindowMs, + inventoryPurchaseRateLimitMax, + inventoryPurchaseRateLimitWindowMs, + websocketUpgradeRateLimitMax, + websocketUpgradeRateLimitWindowMs, maxRoomsPerUser, maxFriendsPerUser, + maxWebSocketConnectionsPerUser, maxAuthBodyBytes, trustProxy, metricsToken, diff --git a/apps/server/src/http/router.test.ts b/apps/server/src/http/router.test.ts index 84d55b0..27e115d 100644 --- a/apps/server/src/http/router.test.ts +++ b/apps/server/src/http/router.test.ts @@ -111,6 +111,7 @@ function makeDeps(overrides: Partial = {}): RouterDeps { roomCreateRateLimiter: limiter(), friendRateLimiter: limiter(), clientEventRateLimiter: limiter(), + inventoryPurchaseRateLimiter: limiter(), ...overrides, }; } @@ -1230,6 +1231,23 @@ describe("createHttpRouter", () => { ]); }); + test("rate limits purchases by authenticated user", async () => { + const route = createHttpRouter(makeDeps({ inventoryPurchaseRateLimiter: limiter(1) })); + + const first = await route( + request("/inventory/purchase", { token: "good-token", body: { itemType: "crate_table" } }), + "ip-1", + ); + const second = await route( + request("/inventory/purchase", { token: "good-token", body: { itemType: "crate_table" } }), + "ip-2", + ); + + expect(first.status).toBe(200); + expect(second.status).toBe(429); + expect(second.headers.get("retry-after")).toBe("60"); + }); + test("validates inventory access, request bodies, and economy errors", async () => { const missing = createHttpRouter(makeDeps({ economy: undefined })); expect( diff --git a/apps/server/src/http/router.ts b/apps/server/src/http/router.ts index 8631d67..d58f234 100644 --- a/apps/server/src/http/router.ts +++ b/apps/server/src/http/router.ts @@ -35,6 +35,7 @@ export type RouterDeps = { roomCreateRateLimiter: FixedWindowRateLimiter; friendRateLimiter: FixedWindowRateLimiter; clientEventRateLimiter: FixedWindowRateLimiter; + inventoryPurchaseRateLimiter: FixedWindowRateLimiter; }; type RouteContext = RouterDeps & { @@ -852,6 +853,17 @@ async function handlePurchaseRequest(ctx: RouteContext): Promise { ); } + const limited = enforceRateLimit( + ctx, + ctx.inventoryPurchaseRateLimiter, + `user:${user.id}`, + "inventory.purchase.rate_limited", + "Too many purchase attempts, try again shortly", + ); + if (limited) { + return limited; + } + const body = await readJsonWithLimit(ctx.request, ctx.config.maxAuthBodyBytes); if (!body.ok) { diff --git a/apps/server/src/serverRuntime.test.ts b/apps/server/src/serverRuntime.test.ts index fd9382b..6ccb454 100644 --- a/apps/server/src/serverRuntime.test.ts +++ b/apps/server/src/serverRuntime.test.ts @@ -134,6 +134,54 @@ describe("startServerRuntime", () => { await stopRuntime(runtime); }); + test("rate limits websocket upgrades and caps active sockets per user", async () => { + const harness = createRuntimeHarness({ + env: { WEBSOCKET_UPGRADE_RATE_LIMIT_MAX: "1", MAX_WEBSOCKET_CONNECTIONS_PER_USER: "1" }, + }); + const auth = { + verifyToken: async (token: string) => + token === "good-token" + ? { + id: "user_1", + username: "Dan", + appearance: DEFAULT_AVATAR_APPEARANCE, + dollars: 500, + } + : undefined, + } as unknown as AuthService; + const runtime = await startServerRuntime({ ...harness.deps, auth }); + + const firstIp = await harness.serveOptions.fetch(webSocketRequest("bad-token"), harness.server); + const secondIp = await harness.serveOptions.fetch( + webSocketRequest("bad-token"), + harness.server, + ); + expect(firstIp?.status).toBe(401); + expect(secondIp?.status).toBe(429); + + await stopRuntime(runtime); + + const capHarness = createRuntimeHarness({ env: { MAX_WEBSOCKET_CONNECTIONS_PER_USER: "1" } }); + const capRuntime = await startServerRuntime({ ...capHarness.deps, auth }); + const existing = createSocket({ + userId: "user_1", + username: "Dan", + connectionId: "socket_existing", + dollars: 500, + }); + capHarness.serveOptions.websocket.open(existing); + + const capped = await capHarness.serveOptions.fetch( + webSocketRequest("good-token"), + capHarness.server, + ); + expect(capped?.status).toBe(429); + expect(await capped?.json()).toMatchObject({ error: { code: "TOO_MANY_CONNECTIONS" } }); + + capHarness.serveOptions.websocket.close(existing); + await stopRuntime(capRuntime); + }); + test("upgrades authenticated websocket handshakes and reports failed upgrades", async () => { const harness = createRuntimeHarness(); let resumeShouldThrow = false; diff --git a/apps/server/src/serverRuntime.ts b/apps/server/src/serverRuntime.ts index cf5d6f5..51114b0 100644 --- a/apps/server/src/serverRuntime.ts +++ b/apps/server/src/serverRuntime.ts @@ -118,12 +118,22 @@ export async function startServerRuntime(deps: ServerRuntimeDeps = {}): Promise< limit: config.clientEventRateLimitMax, windowMs: config.clientEventRateLimitWindowMs, }); + const inventoryPurchaseRateLimiter = new FixedWindowRateLimiter({ + limit: config.inventoryPurchaseRateLimitMax, + windowMs: config.inventoryPurchaseRateLimitWindowMs, + }); + const websocketUpgradeRateLimiter = new FixedWindowRateLimiter({ + limit: config.websocketUpgradeRateLimitMax, + windowMs: config.websocketUpgradeRateLimitWindowMs, + }); const rateLimiters = [ registerRateLimiter, loginRateLimiter, roomCreateRateLimiter, friendRateLimiter, clientEventRateLimiter, + inventoryPurchaseRateLimiter, + websocketUpgradeRateLimiter, ]; const rateLimiterPruneTimer = setIntervalRef(() => { for (const limiter of rateLimiters) { @@ -193,6 +203,7 @@ export async function startServerRuntime(deps: ServerRuntimeDeps = {}): Promise< roomCreateRateLimiter, friendRateLimiter, clientEventRateLimiter, + inventoryPurchaseRateLimiter, }); const server = serve({ @@ -284,6 +295,22 @@ export async function startServerRuntime(deps: ServerRuntimeDeps = {}): Promise< // Browsers send the HttpOnly session cookie on the WS handshake; API clients can use the // Authorization header. Do not accept query tokens because they leak through URLs/logs. + const clientKey = resolveClientKey(request, bunServer, config); + const ipLimit = websocketUpgradeRateLimiter.consume(`ip:${clientKey}`); + if (!ipLimit.allowed) { + metrics.increment("websocket.upgrade.rate_limited"); + logger.warn("websocket.upgrade.rate_limited", { + retryAfterSeconds: ipLimit.retryAfterSeconds, + }); + return Response.json( + { error: { code: "RATE_LIMITED", message: "Too many websocket connection attempts" } }, + { + status: 429, + headers: { ...corsHeaders(), "retry-after": ipLimit.retryAfterSeconds.toString() }, + }, + ); + } + const user = await auth?.verifyToken(readWebSocketSessionToken(request) ?? ""); if (!user) { @@ -294,6 +321,37 @@ export async function startServerRuntime(deps: ServerRuntimeDeps = {}): Promise< ); } + const userLimit = websocketUpgradeRateLimiter.consume(`user:${user.id}`); + if (!userLimit.allowed) { + metrics.increment("websocket.upgrade.rate_limited"); + logger.warn("websocket.upgrade.rate_limited", { + userId: user.id, + retryAfterSeconds: userLimit.retryAfterSeconds, + }); + return Response.json( + { error: { code: "RATE_LIMITED", message: "Too many websocket connection attempts" } }, + { + status: 429, + headers: { ...corsHeaders(), "retry-after": userLimit.retryAfterSeconds.toString() }, + }, + ); + } + + const existingSockets = userSockets.get(user.id)?.size ?? 0; + if (existingSockets >= config.maxWebSocketConnectionsPerUser) { + metrics.increment("websocket.upgrade.too_many_connections"); + logger.warn("websocket.upgrade.too_many_connections", { + userId: user.id, + sockets: existingSockets, + }); + return Response.json( + { + error: { code: "TOO_MANY_CONNECTIONS", message: "Too many active websocket connections" }, + }, + { status: 429, headers: corsHeaders() }, + ); + } + const upgraded = bunServer.upgrade(request, { data: { userId: user.id, From c9046a185ddf2e581f692e03d33044f9b72188ab Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:23:22 +0000 Subject: [PATCH 05/11] fix(server): bound blocked user lists --- apps/server/src/blocks/blocks.test.ts | 17 +++++- apps/server/src/blocks/blocks.ts | 73 ++++++++++++++++++++++--- apps/server/src/config.test.ts | 6 +++ apps/server/src/config.ts | 8 +++ apps/server/src/http/router.test.ts | 76 ++++++++++++++++++++++++--- apps/server/src/http/router.ts | 25 ++++++++- apps/server/src/serverRuntime.ts | 6 ++- 7 files changed, 193 insertions(+), 18 deletions(-) diff --git a/apps/server/src/blocks/blocks.test.ts b/apps/server/src/blocks/blocks.test.ts index c02ba4a..167098d 100644 --- a/apps/server/src/blocks/blocks.test.ts +++ b/apps/server/src/blocks/blocks.test.ts @@ -18,6 +18,9 @@ function createStore(): BlockStore & { pairs: Set } { async isBlockedEitherDirection(userId, otherUserId) { return pairs.has(pairKey(userId, otherUserId)) || pairs.has(pairKey(otherUserId, userId)); }, + async countBlockedUsers(blockerUserId) { + return [...pairs].filter((pair) => pair.startsWith(`${blockerUserId}:`)).length; + }, async listBlockedUsers() { return []; }, @@ -44,6 +47,16 @@ describe("BlockService", () => { await expect(service.block("user_1", "user_1")).rejects.toBeInstanceOf(BlockError); }); + + test("enforces a maximum number of blocked users", async () => { + const service = new BlockService(createStore(), { maxBlockedUsers: 1 }); + + await service.block("user_1", "user_2"); + await service.block("user_1", "user_2"); + await expect(service.block("user_1", "user_3")).rejects.toMatchObject({ + code: "BLOCK_LIMIT_REACHED", + }); + }); }); describe("DrizzleBlockStore", () => { @@ -56,7 +69,9 @@ describe("DrizzleBlockStore", () => { }; const store = new DrizzleBlockStore(queryDouble([[row]])); - await expect(store.listBlockedUsers("user_1")).resolves.toEqual([ + await expect( + store.listBlockedUsers("user_1", { limit: 10, afterUsername: "ivy" }), + ).resolves.toEqual([ { id: "user_2", username: "Kai", diff --git a/apps/server/src/blocks/blocks.ts b/apps/server/src/blocks/blocks.ts index bd0d8b8..b31f348 100644 --- a/apps/server/src/blocks/blocks.ts +++ b/apps/server/src/blocks/blocks.ts @@ -1,5 +1,5 @@ import type { AvatarAppearance } from "@tilezo/protocol"; -import { and, asc, eq, or } from "drizzle-orm"; +import { and, asc, eq, gt, or } from "drizzle-orm"; import type { TilezoDatabase } from "../db/db"; import { blockedUsers, users } from "../db/schema"; @@ -17,12 +17,21 @@ type BlockedUserRow = { blockedAt: Date; }; +export type BlockListOptions = { + limit: number; + afterUsername?: string; +}; + export type BlockStore = { blockUser(blockerUserId: string, blockedUserId: string): Promise; unblockUser(blockerUserId: string, blockedUserId: string): Promise; isBlocked(blockerUserId: string, blockedUserId: string): Promise; isBlockedEitherDirection(userId: string, otherUserId: string): Promise; - listBlockedUsers(blockerUserId: string): Promise; + countBlockedUsers(blockerUserId: string): Promise; + listBlockedUsers( + blockerUserId: string, + options?: BlockListOptions, + ): Promise; }; export class BlockError extends Error { @@ -34,14 +43,29 @@ export class BlockError extends Error { } } +export const DEFAULT_BLOCK_LIST_LIMIT = 50; +export const MAX_BLOCK_LIST_LIMIT = 100; +export const DEFAULT_MAX_BLOCKED_USERS = 500; + export class BlockService { - constructor(private readonly store: BlockStore) {} + constructor( + private readonly store: BlockStore, + private readonly options: { maxBlockedUsers?: number } = {}, + ) {} async block(userId: string, blockedUserId: string): Promise { if (userId === blockedUserId) { throw new BlockError("INVALID_BLOCK", "You cannot block yourself"); } + if (!(await this.store.isBlocked(userId, blockedUserId))) { + const count = await this.store.countBlockedUsers(userId); + const maxBlockedUsers = this.options.maxBlockedUsers ?? DEFAULT_MAX_BLOCKED_USERS; + if (count >= maxBlockedUsers) { + throw new BlockError("BLOCK_LIMIT_REACHED", "You have reached the blocked user limit"); + } + } + await this.store.blockUser(userId, blockedUserId); } @@ -57,8 +81,11 @@ export class BlockService { return this.store.isBlockedEitherDirection(userId, otherUserId); } - list(userId: string): Promise { - return this.store.listBlockedUsers(userId); + list(userId: string, options: Partial = {}): Promise { + return this.store.listBlockedUsers(userId, { + limit: clampLimit(options.limit), + afterUsername: normalizeCursor(options.afterUsername), + }); } } @@ -111,7 +138,24 @@ export class DrizzleBlockStore implements BlockStore { return Boolean(row); } - async listBlockedUsers(blockerUserId: string): Promise { + async countBlockedUsers(blockerUserId: string): Promise { + const rows = await this.db + .select({ blockedUserId: blockedUsers.blockedUserId }) + .from(blockedUsers) + .where(eq(blockedUsers.blockerUserId, blockerUserId)); + return rows.length; + } + + async listBlockedUsers( + blockerUserId: string, + options: BlockListOptions = { limit: DEFAULT_BLOCK_LIST_LIMIT }, + ): Promise { + const afterUsername = normalizeCursor(options.afterUsername); + const conditions = [eq(blockedUsers.blockerUserId, blockerUserId)]; + if (afterUsername) { + conditions.push(gt(users.usernameKey, afterUsername)); + } + const rows = await this.db .select({ id: users.id, @@ -121,8 +165,9 @@ export class DrizzleBlockStore implements BlockStore { }) .from(blockedUsers) .innerJoin(users, eq(users.id, blockedUsers.blockedUserId)) - .where(eq(blockedUsers.blockerUserId, blockerUserId)) - .orderBy(asc(users.usernameKey)); + .where(and(...conditions)) + .orderBy(asc(users.usernameKey)) + .limit(clampLimit(options.limit)); return rows.map(toSummary); } @@ -136,3 +181,15 @@ function toSummary(row: BlockedUserRow): BlockedUserSummary { blockedAt: row.blockedAt.toISOString(), }; } + +function clampLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isInteger(limit) || limit < 1) { + return DEFAULT_BLOCK_LIST_LIMIT; + } + return Math.min(limit, MAX_BLOCK_LIST_LIMIT); +} + +function normalizeCursor(cursor: string | undefined): string | undefined { + const trimmed = cursor?.trim().toLowerCase(); + return trimmed || undefined; +} diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 4fd9b56..9d73e7a 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -34,6 +34,7 @@ describe("getConfig", () => { websocketUpgradeRateLimitWindowMs: 60000, maxRoomsPerUser: 50, maxFriendsPerUser: 500, + maxBlockedUsersPerUser: 500, maxWebSocketConnectionsPerUser: 5, maxAuthBodyBytes: 4096, trustProxy: false, @@ -64,6 +65,7 @@ describe("getConfig", () => { WEBSOCKET_UPGRADE_RATE_LIMIT_MAX: "9", MAX_ROOMS_PER_USER: "25", MAX_FRIENDS_PER_USER: "200", + MAX_BLOCKED_USERS_PER_USER: "250", MAX_WEBSOCKET_CONNECTIONS_PER_USER: "3", MAX_AUTH_BODY_BYTES: "2048", TRUST_PROXY: "true", @@ -95,6 +97,7 @@ describe("getConfig", () => { websocketUpgradeRateLimitWindowMs: 60000, maxRoomsPerUser: 25, maxFriendsPerUser: 200, + maxBlockedUsersPerUser: 250, maxWebSocketConnectionsPerUser: 3, maxAuthBodyBytes: 2048, trustProxy: true, @@ -132,6 +135,9 @@ describe("getConfig", () => { expect(() => getConfig({ WEBSOCKET_UPGRADE_RATE_LIMIT_MAX: "0" })).toThrow( "WEBSOCKET_UPGRADE_RATE_LIMIT_MAX must be a positive integer", ); + expect(() => getConfig({ MAX_BLOCKED_USERS_PER_USER: "0" })).toThrow( + "MAX_BLOCKED_USERS_PER_USER must be a positive integer", + ); expect(() => getConfig({ MAX_WEBSOCKET_CONNECTIONS_PER_USER: "0" })).toThrow( "MAX_WEBSOCKET_CONNECTIONS_PER_USER must be a positive integer", ); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 2abf871..65af7b0 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -24,6 +24,7 @@ export type ServerConfig = { websocketUpgradeRateLimitWindowMs: number; maxRoomsPerUser: number; maxFriendsPerUser: number; + maxBlockedUsersPerUser: number; maxWebSocketConnectionsPerUser: number; maxAuthBodyBytes: number; trustProxy: boolean; @@ -48,6 +49,7 @@ export const DEFAULT_INVENTORY_PURCHASE_RATE_LIMIT_WINDOW_MS = 60_000; export const DEFAULT_WEBSOCKET_UPGRADE_RATE_LIMIT_WINDOW_MS = 60_000; export const DEFAULT_MAX_ROOMS_PER_USER = 50; export const DEFAULT_MAX_FRIENDS_PER_USER = 500; +export const DEFAULT_MAX_BLOCKED_USERS_PER_USER = 500; export const DEFAULT_MAX_WEBSOCKET_CONNECTIONS_PER_USER = 5; export const DEFAULT_MAX_AUTH_BODY_BYTES = 4 * 1024; @@ -155,6 +157,11 @@ export function getConfig(env = Bun.env): ServerConfig { env.MAX_FRIENDS_PER_USER, DEFAULT_MAX_FRIENDS_PER_USER, ); + const maxBlockedUsersPerUser = parsePositiveInteger( + "MAX_BLOCKED_USERS_PER_USER", + env.MAX_BLOCKED_USERS_PER_USER, + DEFAULT_MAX_BLOCKED_USERS_PER_USER, + ); const maxWebSocketConnectionsPerUser = parsePositiveInteger( "MAX_WEBSOCKET_CONNECTIONS_PER_USER", env.MAX_WEBSOCKET_CONNECTIONS_PER_USER, @@ -216,6 +223,7 @@ export function getConfig(env = Bun.env): ServerConfig { websocketUpgradeRateLimitWindowMs, maxRoomsPerUser, maxFriendsPerUser, + maxBlockedUsersPerUser, maxWebSocketConnectionsPerUser, maxAuthBodyBytes, trustProxy, diff --git a/apps/server/src/http/router.test.ts b/apps/server/src/http/router.test.ts index 27e115d..335a8b2 100644 --- a/apps/server/src/http/router.test.ts +++ b/apps/server/src/http/router.test.ts @@ -60,7 +60,17 @@ function makeDeps(overrides: Partial = {}): RouterDeps { remove: async () => {}, } as unknown as FriendService, blocks: { - list: async () => [], + list: async (_userId: string, options?: { limit?: number; afterUsername?: string }) => + options + ? [ + { + id: "user_2", + username: `limit:${options.limit}:after:${options.afterUsername ?? ""}`, + appearance: DEFAULT_AVATAR_APPEARANCE, + blockedAt: "2026-06-13T00:00:00.000Z", + }, + ] + : [], block: async () => {}, unblock: async () => {}, } as unknown as BlockService, @@ -605,7 +615,17 @@ describe("createHttpRouter", () => { const route = createHttpRouter( makeDeps({ friends: { - list: async () => [], + list: async (_userId: string, options?: { limit?: number; afterUsername?: string }) => + options + ? [ + { + id: "user_2", + username: `limit:${options.limit}:after:${options.afterUsername ?? ""}`, + appearance: DEFAULT_AVATAR_APPEARANCE, + blockedAt: "2026-06-13T00:00:00.000Z", + }, + ] + : [], add: async () => ({ friend: { ...authUser, @@ -664,7 +684,17 @@ describe("createHttpRouter", () => { const route = createHttpRouter( makeDeps({ friends: { - list: async () => [], + list: async (_userId: string, options?: { limit?: number; afterUsername?: string }) => + options + ? [ + { + id: "user_2", + username: `limit:${options.limit}:after:${options.afterUsername ?? ""}`, + appearance: DEFAULT_AVATAR_APPEARANCE, + blockedAt: "2026-06-13T00:00:00.000Z", + }, + ] + : [], add: async () => { throw new Error("db down"); }, @@ -828,10 +858,12 @@ describe("createHttpRouter", () => { const route = createHttpRouter( makeDeps({ blocks: { - list: async () => [ + list: async (_userId: string, options?: { limit?: number; afterUsername?: string }) => [ { id: "user_2", - username: "Kai", + username: options + ? `limit:${options.limit}:after:${options.afterUsername ?? ""}` + : "Kai", appearance: DEFAULT_AVATAR_APPEARANCE, blockedAt: "2026-06-13T00:00:00.000Z", }, @@ -858,6 +890,13 @@ describe("createHttpRouter", () => { request("/blocked-users", { method: "GET", token: "good-token" }), "ip", ); + const paged = await route( + request("/blocked-users?limit=500&afterUsername=Kai", { + method: "GET", + token: "good-token", + }), + "ip", + ); const remove = await route( request("/blocked-users/user_2", { method: "DELETE", token: "good-token" }), "ip", @@ -866,6 +905,9 @@ describe("createHttpRouter", () => { expect(post.status).toBe(200); expect(list.status).toBe(200); expect(await list.json()).toMatchObject({ blockedUsers: [{ id: "user_2" }] }); + expect(await paged.json()).toMatchObject({ + blockedUsers: [{ username: "limit:100:after:Kai" }], + }); expect(remove.status).toBe(200); expect(blocked).toEqual(["user_2"]); expect(unblocked).toEqual(["user_2"]); @@ -875,7 +917,17 @@ describe("createHttpRouter", () => { const route = createHttpRouter( makeDeps({ blocks: { - list: async () => [], + list: async (_userId: string, options?: { limit?: number; afterUsername?: string }) => + options + ? [ + { + id: "user_2", + username: `limit:${options.limit}:after:${options.afterUsername ?? ""}`, + appearance: DEFAULT_AVATAR_APPEARANCE, + blockedAt: "2026-06-13T00:00:00.000Z", + }, + ] + : [], block: async () => { throw new BlockError("INVALID_BLOCK", "You cannot block yourself"); }, @@ -961,7 +1013,17 @@ describe("createHttpRouter", () => { const broken = createHttpRouter( makeDeps({ blocks: { - list: async () => [], + list: async (_userId: string, options?: { limit?: number; afterUsername?: string }) => + options + ? [ + { + id: "user_2", + username: `limit:${options.limit}:after:${options.afterUsername ?? ""}`, + appearance: DEFAULT_AVATAR_APPEARANCE, + blockedAt: "2026-06-13T00:00:00.000Z", + }, + ] + : [], block: async () => { throw new Error("db down"); }, diff --git a/apps/server/src/http/router.ts b/apps/server/src/http/router.ts index d58f234..c7191a9 100644 --- a/apps/server/src/http/router.ts +++ b/apps/server/src/http/router.ts @@ -440,7 +440,15 @@ async function handleBlockedUsersRequest(ctx: RouteContext): Promise { try { if (url.pathname === "/blocked-users" && ctx.request.method === "GET") { - return authJson({ blockedUsers: await blocks.list(user.id) }, 200); + return authJson( + { + blockedUsers: await blocks.list(user.id, { + limit: parseBoundedQueryInteger(url.searchParams.get("limit"), 50, 100), + afterUsername: url.searchParams.get("afterUsername") ?? undefined, + }), + }, + 200, + ); } if (url.pathname === "/blocked-users" && ctx.request.method === "POST") { @@ -1142,6 +1150,21 @@ function applyCors(response: Response, ctx: RouteContext): void { } } +function parseBoundedQueryInteger( + value: string | null, + defaultValue: number, + maxValue: number, +): number { + if (!value) { + return defaultValue; + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + return defaultValue; + } + return Math.min(parsed, maxValue); +} + function authJson(body: unknown, status: number, headers: Record = {}): Response { return Response.json(body, { status, headers: { ...corsHeaders(), ...headers } }); } diff --git a/apps/server/src/serverRuntime.ts b/apps/server/src/serverRuntime.ts index 51114b0..37c9771 100644 --- a/apps/server/src/serverRuntime.ts +++ b/apps/server/src/serverRuntime.ts @@ -150,7 +150,11 @@ export async function startServerRuntime(deps: ServerRuntimeDeps = {}): Promise< : undefined; const presence = new PresenceTracker(); const rooms = await RoomManager.create({ persistence, bots: DEFAULT_ROOM_BOTS }); - const blocks = database ? new BlockService(new DrizzleBlockStore(database)) : undefined; + const blocks = database + ? new BlockService(new DrizzleBlockStore(database), { + maxBlockedUsers: config.maxBlockedUsersPerUser, + }) + : undefined; const friends = database ? new FriendService(new DrizzleFriendStore(database), (userId) => presence.get(userId), { canJoinRoom: (userId, roomId) => rooms.canJoinRoom(roomId, userId).ok, From 3e96f435862b3ad4bf2171a7576fc902cd6f1ec7 Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:23:58 +0000 Subject: [PATCH 06/11] ci: pin workflow actions and permissions --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67f4ac1..fef6a12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ on: branches: - main +permissions: + contents: read + jobs: test: name: Test and coverage @@ -34,10 +37,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.13" From 419776db2cc751e7e3c6531aee62e5afd1fc4ccd Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:24:53 +0000 Subject: [PATCH 07/11] build: pin container image digests --- .github/workflows/ci.yml | 2 +- Dockerfile | 6 +++--- compose.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fef6a12..6ebff2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: services: postgres: - image: postgres:16-alpine + image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres diff --git a/Dockerfile b/Dockerfile index de3c640..6e19e2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM oven/bun:1.3.13 AS base +FROM oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e AS base WORKDIR /app COPY package.json bun.lock tsconfig.base.json biome.json ./ @@ -25,7 +25,7 @@ ENV PUBLIC_API_URL=$PUBLIC_API_URL ENV PUBLIC_WS_URL=$PUBLIC_WS_URL RUN bun run --filter '@tilezo/client' build -FROM caddy:2-alpine AS client +FROM caddy:2-alpine@sha256:77c07d5ebfa5be9fd6c820d2094ae662c9e7eeb9bf98346b7f639900263ee2a2 AS client COPY --from=client-build /app/apps/client/dist /usr/share/caddy EXPOSE 80 @@ -38,7 +38,7 @@ ENV NODE_ENV=production USER bun CMD ["bun", "run", "--cwd", "apps/server", "db:migrate"] -FROM oven/bun:1.3.13 AS server +FROM oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e AS server WORKDIR /app ENV HOST=0.0.0.0 ENV NODE_ENV=production diff --git a/compose.yml b/compose.yml index bba42e0..7dc1ba0 100644 --- a/compose.yml +++ b/compose.yml @@ -55,7 +55,7 @@ services: condition: service_started db: - image: postgres:17-alpine + image: postgres:17-alpine@sha256:979c4379dd698aba0b890599a6104e082035f98ef31d9b9291ec22f2b13059ca environment: POSTGRES_DB: ${POSTGRES_DB:-tilezo} POSTGRES_USER: ${POSTGRES_USER:-postgres} From 983114b427f0e6f44232dca4e6f8cf514fb07fe5 Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:26:26 +0000 Subject: [PATCH 08/11] fix(dev): isolate local database secrets --- .env.example | 6 +++--- compose.db.yml | 4 ++-- compose.yml | 2 +- scripts/setup-worktree.ts | 9 +++++++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 219fcbf..0eb8f33 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,17 @@ COMPOSE_PROJECT_NAME=tilezo SERVER_PORT=3000 CLIENT_PORT=3001 -DB_PORT=5432 +DB_PORT=5432 # compose binds this to 127.0.0.1 only POSTGRES_DB=tilezo POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres +POSTGRES_PASSWORD=replace-with-worktree-generated-password HOST=0.0.0.0 PORT=3000 DATABASE_URL=postgres://postgres:postgres@localhost:5432/tilezo # Development-only secret. Production requires a strong, random value of at least 32 # characters that is NOT a placeholder phrase (the server refuses to start otherwise). # Generate one with: openssl rand -base64 48 -AUTH_SECRET=dev-only-insecure-secret-change-before-production +AUTH_SECRET=replace-with-worktree-generated-auth-secret NODE_ENV=development # Set TRUST_PROXY=true only when running behind a proxy that overwrites x-forwarded-for / # x-real-ip; otherwise rate-limit keys use the real socket peer address. diff --git a/compose.db.yml b/compose.db.yml index d0c6906..3b5ef33 100644 --- a/compose.db.yml +++ b/compose.db.yml @@ -1,12 +1,12 @@ services: db: - image: postgres:17-alpine + image: postgres:17-alpine@sha256:979c4379dd698aba0b890599a6104e082035f98ef31d9b9291ec22f2b13059ca environment: POSTGRES_DB: ${POSTGRES_DB:-tilezo} POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} ports: - - "${DB_PORT:-5432}:5432" + - "127.0.0.1:${DB_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: diff --git a/compose.yml b/compose.yml index 7dc1ba0..063031e 100644 --- a/compose.yml +++ b/compose.yml @@ -61,7 +61,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} ports: - - "${DB_PORT:-5432}:5432" + - "127.0.0.1:${DB_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index 3d8e276..c317056 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { basename, resolve } from "node:path"; @@ -70,7 +71,7 @@ async function generateWorktreeEnv(existing: EnvValues): Promise { const postgresDb = existing.POSTGRES_DB ?? "tilezo"; const postgresUser = existing.POSTGRES_USER ?? "postgres"; - const postgresPassword = existing.POSTGRES_PASSWORD ?? "postgres"; + const postgresPassword = existing.POSTGRES_PASSWORD ?? randomSecret(24); return { COMPOSE_PROJECT_NAME: projectName, @@ -85,7 +86,7 @@ async function generateWorktreeEnv(existing: EnvValues): Promise { DATABASE_URL: existing.DATABASE_URL ?? `postgres://${postgresUser}:${postgresPassword}@localhost:${dbPort}/${postgresDb}`, - AUTH_SECRET: existing.AUTH_SECRET ?? "tilezo-development-secret", + AUTH_SECRET: existing.AUTH_SECRET ?? randomSecret(48), NODE_ENV: existing.NODE_ENV ?? "development", PUBLIC_API_URL: existing.PUBLIC_API_URL ?? `http://localhost:${serverPort}`, PUBLIC_WS_URL: existing.PUBLIC_WS_URL ?? `ws://localhost:${serverPort}/ws`, @@ -217,3 +218,7 @@ async function isPortAvailable(port: number): Promise { function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } + +function randomSecret(bytes: number): string { + return randomBytes(bytes).toString("base64url"); +} From c13a0f6df94291925a4d6d23003cadad4cf4a45f Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:27:11 +0000 Subject: [PATCH 09/11] fix(server): require bearer token for metrics --- .env.example | 2 +- apps/server/src/http/router.test.ts | 5 ++--- apps/server/src/http/router.ts | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 0eb8f33..b17e99b 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ NODE_ENV=development # Set TRUST_PROXY=true only when running behind a proxy that overwrites x-forwarded-for / # x-real-ip; otherwise rate-limit keys use the real socket peer address. # TRUST_PROXY=false -# Required to expose GET /debug/metrics in production (request with ?token=... ); when +# Required to expose GET /debug/metrics in production (request with Authorization: Bearer ... ); when # unset, the metrics endpoint returns 404 in production. # METRICS_TOKEN= PUBLIC_API_URL=http://localhost:3000 diff --git a/apps/server/src/http/router.test.ts b/apps/server/src/http/router.test.ts index 335a8b2..5d9f5ed 100644 --- a/apps/server/src/http/router.test.ts +++ b/apps/server/src/http/router.test.ts @@ -1476,12 +1476,11 @@ describe("createHttpRouter", () => { const route = createHttpRouter(makeDeps({ config })); expect((await route(request("/debug/metrics", { method: "GET" }), "ip")).status).toBe(404); - const allowed = await route( + const queryToken = await route( request("/debug/metrics?token=metrics-secret", { method: "GET" }), "ip", ); - expect(allowed.status).toBe(200); - expect(allowed.headers.get("access-control-allow-origin")).toBeNull(); + expect(queryToken.status).toBe(404); expect((await route(request("/debug/metrics/reset", { method: "POST" }), "ip")).status).toBe( 404, ); diff --git a/apps/server/src/http/router.ts b/apps/server/src/http/router.ts index c7191a9..0eec1a7 100644 --- a/apps/server/src/http/router.ts +++ b/apps/server/src/http/router.ts @@ -927,8 +927,7 @@ function metricsAccessAllowed(ctx: RouteContext): boolean { return false; } - const provided = ctx.url.searchParams.get("token") ?? readBearerToken(ctx.request); - return provided === token; + return readBearerToken(ctx.request) === token; } function enforceRateLimit( From 9cc30ec6da4cc1e9c424e64dab600a95610634c5 Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:28:32 +0000 Subject: [PATCH 10/11] fix(server): hide unread counts for blocked dm threads --- apps/server/src/messaging/messaging.test.ts | 14 ++++++++++++-- apps/server/src/messaging/messaging.ts | 16 ++++++++++++++-- docs/development.md | 5 +++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/server/src/messaging/messaging.test.ts b/apps/server/src/messaging/messaging.test.ts index a83a411..aae0815 100644 --- a/apps/server/src/messaging/messaging.test.ts +++ b/apps/server/src/messaging/messaging.test.ts @@ -162,12 +162,22 @@ describe("DirectMessageService", () => { await expect(blocked.history("user_1", "user_2")).rejects.toBeInstanceOf(DirectMessageError); }); - test("marks conversations read and returns unread counts", async () => { + test("marks conversations read and returns unread counts only for allowed conversations", async () => { const store = createStore(); - const service = new DirectMessageService(store, async () => true); + const service = new DirectMessageService( + store, + async (_userId, otherUserId) => otherUserId !== "user_3", + ); await service.send("user_2", "user_1", "a"); await service.send("user_2", "user_1", "b"); + store.saved.push({ + id: "dm_blocked", + fromUserId: "user_3", + toUserId: "user_1", + text: "hidden", + sentAt: "2026-06-13T00:00:00.000Z", + }); expect(await service.unreadCounts("user_1")).toEqual([{ friendId: "user_2", count: 2 }]); diff --git a/apps/server/src/messaging/messaging.ts b/apps/server/src/messaging/messaging.ts index c3a99c1..326ccc3 100644 --- a/apps/server/src/messaging/messaging.ts +++ b/apps/server/src/messaging/messaging.ts @@ -3,6 +3,9 @@ import type { TilezoDatabase } from "../db/db"; import { directMessages } from "../db/schema"; import { createId } from "../util/ids"; +// Direct messages are stored as server-readable plaintext so moderation/export/deletion workflows +// can operate today. This is not end-to-end encrypted; keep the feature friend-gated, +// block-aware, and documented as private to participants plus operators with database access. export type DirectMessageRecord = { id: string; fromUserId: string; @@ -112,8 +115,17 @@ export class DirectMessageService { return this.store.markConversationRead(readerUserId, otherUserId); } - unreadCounts(userId: string): Promise { - return this.store.listUnreadCounts(userId); + async unreadCounts(userId: string): Promise { + const counts = await this.store.listUnreadCounts(userId); + const visibleCounts: DirectMessageUnreadCount[] = []; + + for (const count of counts) { + if (await this.canMessage(userId, count.friendId)) { + visibleCounts.push(count); + } + } + + return visibleCounts; } async edit(senderUserId: string, messageId: string, text: string): Promise { diff --git a/docs/development.md b/docs/development.md index 0914a28..1a7acf4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -218,3 +218,8 @@ Current test coverage includes: - Room join, leave, and authoritative movement. Rendering tests should stay light until the client UI becomes more complex. + + +## Direct message privacy + +Direct messages are friend-gated and block-aware, but message bodies are stored as server-readable plaintext for now so moderation, account export, and deletion workflows can operate. They are private from other players, not end-to-end encrypted from server operators or anyone with database access. Do not represent Tilezo DMs as E2EE unless a future encrypted storage design is implemented and reviewed. From d1acea53cf289749fc23654bf5ad8093d02e5a4c Mon Sep 17 00:00:00 2001 From: forjd-hermes-bot <282037251+forjd-hermes-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:31:20 +0000 Subject: [PATCH 11/11] fix(db): fail safely on duplicate legacy users --- apps/server/src/db/migrations.test.ts | 9 ++++---- .../db/migrations/0001_green_edwin_jarvis.sql | 23 ++++++++----------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/apps/server/src/db/migrations.test.ts b/apps/server/src/db/migrations.test.ts index e43193a..9f84753 100644 --- a/apps/server/src/db/migrations.test.ts +++ b/apps/server/src/db/migrations.test.ts @@ -3,16 +3,17 @@ import { readdir } from "node:fs/promises"; import { basename } from "node:path"; describe("user auth migration", () => { - test("removes duplicate legacy username keys before adding the unique constraint", async () => { + test("fails safely instead of deleting duplicate legacy username rows", async () => { const migration = await Bun.file( new URL("./migrations/0001_green_edwin_jarvis.sql", import.meta.url), ).text(); - const deleteDuplicatesIndex = migration.indexOf('DELETE FROM "users"'); + const duplicateGuardIndex = migration.indexOf("Cannot add users_username_key_unique"); const uniqueConstraintIndex = migration.indexOf('ADD CONSTRAINT "users_username_key_unique"'); - expect(deleteDuplicatesIndex).toBeGreaterThan(-1); - expect(deleteDuplicatesIndex).toBeLessThan(uniqueConstraintIndex); + expect(migration).not.toContain('DELETE FROM "users"'); + expect(duplicateGuardIndex).toBeGreaterThan(-1); + expect(duplicateGuardIndex).toBeLessThan(uniqueConstraintIndex); }); }); diff --git a/apps/server/src/db/migrations/0001_green_edwin_jarvis.sql b/apps/server/src/db/migrations/0001_green_edwin_jarvis.sql index 902bcb4..d9c4b14 100644 --- a/apps/server/src/db/migrations/0001_green_edwin_jarvis.sql +++ b/apps/server/src/db/migrations/0001_green_edwin_jarvis.sql @@ -1,20 +1,17 @@ ALTER TABLE "users" ADD COLUMN "username_key" text;--> statement-breakpoint ALTER TABLE "users" ADD COLUMN "password_hash" text;--> statement-breakpoint UPDATE "users" SET "username_key" = lower("username"), "password_hash" = 'legacy-user-without-password';--> statement-breakpoint -DELETE FROM "users" -WHERE "id" IN ( - SELECT "id" - FROM ( - SELECT - "id", - row_number() OVER ( - PARTITION BY "username_key" - ORDER BY "created_at", "id" - ) AS "duplicate_rank" +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "users" - ) "ranked_users" - WHERE "duplicate_rank" > 1 -);--> statement-breakpoint + GROUP BY "username_key" + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'Cannot add users_username_key_unique while duplicate username_key values exist; resolve duplicates manually before rerunning migrations'; + END IF; +END $$;--> statement-breakpoint ALTER TABLE "users" ALTER COLUMN "username_key" SET NOT NULL;--> statement-breakpoint ALTER TABLE "users" ALTER COLUMN "password_hash" SET NOT NULL;--> statement-breakpoint ALTER TABLE "users" ADD CONSTRAINT "users_username_key_unique" UNIQUE("username_key");