diff --git a/bun.lock b/bun.lock index c5eaee5ec..fe742dafa 100644 --- a/bun.lock +++ b/bun.lock @@ -79,7 +79,7 @@ "typescript": "^6.0.3", "vite": "8.2.1", "vitest": "^4.1.10", - "wrangler": "4.123.0", + "wrangler": "4.113.0", "zod-openapi": "^6.0.1", }, }, @@ -182,15 +182,15 @@ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260811.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260721.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260811.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260721.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260811.1", "", { "os": "linux", "cpu": "x64" }, "sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260721.1", "", { "os": "linux", "cpu": "x64" }, "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260811.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260721.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260811.1", "", { "os": "win32", "cpu": "x64" }, "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260721.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ=="], "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260612.1", "", {}, "sha512-PMQI7XP/wrMhxyjseUHoHj6XFqkHaf4utWQ/hhefVY8oMK2LJ730oeQ7H/nZSVMexZe39DzsdOx7sf1PqMr7+Q=="], @@ -386,57 +386,53 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="], - - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], "@internationalized/date": ["@internationalized/date@3.12.3", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q=="], @@ -1714,7 +1710,7 @@ "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], - "miniflare": ["miniflare@5.20260811.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA=="], + "miniflare": ["miniflare@4.20260721.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260721.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -2014,7 +2010,7 @@ "shadcn-svelte": ["shadcn-svelte@1.5.0", "", { "dependencies": { "commander": "^14.0.0", "node-fetch-native": "^1.6.4", "postcss": "^8.5.10", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "svelte": "^5.0.0" }, "bin": { "shadcn-svelte": "dist/index.mjs" } }, "sha512-fcxTeUwEIvELkL/h0Vg2i/9Sf6exAmLG5u3GXc0aN8CA+3fNffKQnfrTdMgkjUEmx9XP619I+wcJOvlr5aDWYQ=="], - "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -2262,11 +2258,11 @@ "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], - "workerd": ["workerd@1.20260811.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260811.1", "@cloudflare/workerd-darwin-arm64": "1.20260811.1", "@cloudflare/workerd-linux-64": "1.20260811.1", "@cloudflare/workerd-linux-arm64": "1.20260811.1", "@cloudflare/workerd-windows-64": "1.20260811.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw=="], + "workerd": ["workerd@1.20260721.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260721.1", "@cloudflare/workerd-darwin-arm64": "1.20260721.1", "@cloudflare/workerd-linux-64": "1.20260721.1", "@cloudflare/workerd-linux-arm64": "1.20260721.1", "@cloudflare/workerd-windows-64": "1.20260721.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg=="], "worktop": ["worktop@0.8.0-next.18", "", { "dependencies": { "mrmime": "^2.0.0", "regexparam": "^3.0.0" } }, "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw=="], - "wrangler": ["wrangler@4.123.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260811.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260811.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q=="], + "wrangler": ["wrangler@4.113.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260721.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260721.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260721.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -2450,8 +2446,6 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "miniflare/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "mode-watcher/runed": ["runed@0.25.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg=="], diff --git a/package.json b/package.json index 27578cc19..8f563183b 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "typescript": "^6.0.3", "vite": "8.2.1", "vitest": "^4.1.10", - "wrangler": "4.123.0", + "wrangler": "4.113.0", "zod-openapi": "^6.0.1" }, "patchedDependencies": { diff --git a/src/features/admin/server/admin-page-auth.ts b/src/features/admin/server/admin-page-auth.ts index 0a74045b5..bfe757793 100644 --- a/src/features/admin/server/admin-page-auth.ts +++ b/src/features/admin/server/admin-page-auth.ts @@ -1,4 +1,5 @@ import { error, redirect } from "@sveltejs/kit"; +import { logAdminSecurityEvent } from "@/lib/audit/security-events"; import { buildReauthenticationPageUrl, buildSignInPageUrl, @@ -23,6 +24,7 @@ export async function requireAdminPage( const { getSessionFromHeaders } = await import("@/lib/auth/core"); const session = await getSessionFromHeaders(request.headers); if (!session?.user?.id) { + logAdminSecurityEvent(request, "unauthenticated"); const url = new URL(request.url); throw redirect(303, buildSignInPageUrl(`${url.pathname}${url.search}`)); } @@ -33,16 +35,23 @@ export async function requireAdminPage( select: { id: true, isAdmin: true, name: true, username: true }, }); - if (!user?.isAdmin) error(404, "Not found"); + if (!user?.isAdmin) { + logAdminSecurityEvent(request, "not_admin"); + error(404, "Not found"); + } if (options.requireActive) { const suspension = await findActiveSuspension(user.id); - if (suspension) error(403, "Suspended"); + if (suspension) { + logAdminSecurityEvent(request, "suspended"); + error(403, "Suspended"); + } } if (options.requireRecent) { const recent = await resolveAuthoritativeRecentSession(request.headers, { expectedUserId: user.id, }); if (!recent.ok) { + logAdminSecurityEvent(request, "recent_auth_required"); const url = new URL(request.url); throw redirect( 303, diff --git a/src/features/calendar/server/calendar-export-cache.ts b/src/features/calendar/server/calendar-export-cache.ts index 2003e49c0..d3d787b81 100644 --- a/src/features/calendar/server/calendar-export-cache.ts +++ b/src/features/calendar/server/calendar-export-cache.ts @@ -137,14 +137,16 @@ async function persistStoredCalendar( entry: StoredUserCalendarExport, ) { const namespace = getCloudflareCalendarExportsNamespace(); - if (!namespace) return; + if (!namespace) return true; try { await namespace.put(cacheKey(userId), JSON.stringify(entry), { expirationTtl: USER_CALENDAR_EXPORT_KV_EXPIRATION_TTL_SECONDS, }); + return true; } catch { recordCalendarFeedCacheStatus("store_error"); + return false; } } @@ -166,11 +168,19 @@ export async function storeBuiltUserCalendarExport( pruneOldestEntries(); const persistence = persistStoredCalendar(userId, stored); if (options.defer) { - options.defer(persistence); + const deferredPersistence = persistence.then((persisted) => { + if (!persisted) { + throw new Error("Calendar export cache persistence failed"); + } + recordCalendarFeedCacheStatus("refresh_success"); + }); + options.defer(deferredPersistence); } else { - await persistence; + if (!(await persistence)) { + throw new Error("Calendar export cache persistence failed"); + } + recordCalendarFeedCacheStatus("refresh_success"); } - recordCalendarFeedCacheStatus("refresh_success"); return stored; } @@ -183,7 +193,13 @@ function refreshUserCalendarExport( if (pending) return pending; const refresh = (async () => { - const calendar = await buildExport(); + let calendar: UserCalendarExport | null; + try { + calendar = await buildExport(); + } catch (error) { + recordCalendarFeedCacheStatus("refresh_error"); + throw error; + } if (!calendar) return null; return storeBuiltUserCalendarExport(userId, calendar, { defer }); })(); @@ -196,9 +212,23 @@ function refreshUserCalendarExport( return refresh; } -function scheduleStaleCalendarExportRebuild(userId: string) { - void enqueueUserCalendarExportRebuild(userId).catch(() => { - // Stale-serve path must never fail because enqueue failed. +function scheduleStaleCalendarExportRebuild( + userId: string, + defer?: (promise: Promise) => void, +) { + const enqueue = enqueueUserCalendarExportRebuild(userId); + if (defer) { + try { + defer(enqueue); + return; + } catch { + // A failed scheduler must not turn a stale response into an error. + } + } + + enqueue.catch(() => { + // The enqueue helper records a low-cardinality failure metric. Keep this + // no-defer path non-blocking without leaving an unhandled rejection. }); } @@ -224,7 +254,7 @@ export async function getCachedUserCalendarExport( if (ageMs <= USER_CALENDAR_EXPORT_STALE_TTL_MS) { // Serve stale immediately and enqueue a Queue rebuild. Do not rebuild ICS // on the request path (or inside waitUntil) — that path hit cpu_ms / cancel. - scheduleStaleCalendarExportRebuild(userId); + scheduleStaleCalendarExportRebuild(userId, options.defer); recordCalendarFeedCacheStatus("stale"); return { calendar: cached, diff --git a/src/features/calendar/server/calendar-export-queue.ts b/src/features/calendar/server/calendar-export-queue.ts index 373b7b520..95083fc12 100644 --- a/src/features/calendar/server/calendar-export-queue.ts +++ b/src/features/calendar/server/calendar-export-queue.ts @@ -1,4 +1,7 @@ -import { getCloudflareCalendarExportRebuildQueue } from "@/lib/adapters/cloudflare-runtime"; +import { + getCloudflareCalendarExportRebuildQueue, + getCloudflareRuntimeTaskScheduler, +} from "@/lib/adapters/cloudflare-runtime"; import { writeCalendarExportRebuildAnalytics } from "@/lib/metrics/analytics-engine"; export type CalendarExportRebuildUserMessage = { @@ -60,39 +63,84 @@ async function deliverCalendarExportRebuildMessage( } const queue = getCloudflareCalendarExportRebuildQueue(); - if (queue) { - await queue.send(message); - writeCalendarExportRebuildAnalytics({ status: "enqueued" }); - return; + if (!queue) { + throw new Error("CALENDAR_EXPORT_REBUILD binding is required"); } - // Node / vitest without a Queue binding: rebuild in-process. - const { processCalendarExportRebuildMessage } = await import( - "./calendar-export-rebuild" - ); - await processCalendarExportRebuildMessage(message); + await queue.send(message); writeCalendarExportRebuildAnalytics({ status: "enqueued" }); } +function recordCalendarExportRebuildEnqueueFailure() { + writeCalendarExportRebuildAnalytics({ status: "enqueue_error" }); +} + +function observeEnqueueFailure(enqueue: Promise) { + enqueue.catch(() => { + // The enqueue function records the low-cardinality failure metric before + // rethrowing. This rejection handler keeps no-defer callers from creating + // an unhandled rejection while preserving immediate stale responses. + }); +} + export async function enqueueUserCalendarExportRebuild(userId: string) { const trimmed = userId.trim(); if (!trimmed) return; - await deliverCalendarExportRebuildMessage({ type: "user", userId: trimmed }); + try { + await deliverCalendarExportRebuildMessage({ + type: "user", + userId: trimmed, + }); + } catch (error) { + recordCalendarExportRebuildEnqueueFailure(); + throw error; + } } export async function enqueueSectionCalendarExportRebuild(sectionId: number) { if (!Number.isInteger(sectionId) || sectionId <= 0) return; - await deliverCalendarExportRebuildMessage({ type: "section", sectionId }); + try { + await deliverCalendarExportRebuildMessage({ type: "section", sectionId }); + } catch (error) { + recordCalendarExportRebuildEnqueueFailure(); + throw error; + } } -export function scheduleUserCalendarExportRebuild(userId: string) { - void enqueueUserCalendarExportRebuild(userId).catch(() => { - // Enqueue failures must not fail the write path. - }); +export function scheduleUserCalendarExportRebuild( + userId: string, + defer: + | ((promise: Promise) => void) + | undefined = getCloudflareRuntimeTaskScheduler(), +) { + const enqueue = enqueueUserCalendarExportRebuild(userId); + if (defer) { + try { + defer(enqueue); + return; + } catch { + // A failed scheduler cannot retain the promise. Attach a rejection + // observer so the write path remains non-blocking and the enqueue + // failure remains visible through its metric. + } + } + observeEnqueueFailure(enqueue); } -export function scheduleSectionCalendarExportRebuild(sectionId: number) { - void enqueueSectionCalendarExportRebuild(sectionId).catch(() => { - // Enqueue failures must not fail the write path. - }); +export function scheduleSectionCalendarExportRebuild( + sectionId: number, + defer: + | ((promise: Promise) => void) + | undefined = getCloudflareRuntimeTaskScheduler(), +) { + const enqueue = enqueueSectionCalendarExportRebuild(sectionId); + if (defer) { + try { + defer(enqueue); + return; + } catch { + // See the user-scoped scheduler path above. + } + } + observeEnqueueFailure(enqueue); } diff --git a/src/features/calendar/server/calendar-export-rebuild.ts b/src/features/calendar/server/calendar-export-rebuild.ts index a95fefec1..672e9ac42 100644 --- a/src/features/calendar/server/calendar-export-rebuild.ts +++ b/src/features/calendar/server/calendar-export-rebuild.ts @@ -10,9 +10,23 @@ import { logAppEvent } from "@/lib/log/app-logger"; import { writeCalendarExportRebuildAnalytics } from "@/lib/metrics/analytics-engine"; export async function rebuildUserCalendarExport(userId: string) { - const user = await getUserCalendarRecord(userId); + let user: Awaited>; + try { + user = await getUserCalendarRecord(userId); + } catch (error) { + writeCalendarExportRebuildAnalytics({ status: "refresh_error" }); + throw error; + } if (!user) return null; - const calendar = await buildUserCalendarExport(user, userId); + + let calendar: Awaited>; + try { + calendar = await buildUserCalendarExport(user, userId); + } catch (error) { + writeCalendarExportRebuildAnalytics({ status: "refresh_error" }); + throw error; + } + return storeBuiltUserCalendarExport(userId, calendar); } @@ -90,7 +104,15 @@ export async function handleCalendarExportRebuildBatch( for (const message of batch.messages) { const body = parseCalendarExportRebuildMessage(message.body); if (!body) { - message.ack(); + logAppEvent("error", "calendar-export-rebuild.invalid-message", { + event: "calendar-export-rebuild.invalid-message", + phase: "consumer", + reason: "invalid_envelope", + source: "calendar-export-rebuild", + }); + // Keep the body out of logs. Retrying lets the configured DLQ retain the + // invalid envelope for bounded operational inspection. + message.retry(); continue; } parsed.push(body); diff --git a/src/features/oauth/server/oauth-consent-action.ts b/src/features/oauth/server/oauth-consent-action.ts index 5271f85c8..db41a604c 100644 --- a/src/features/oauth/server/oauth-consent-action.ts +++ b/src/features/oauth/server/oauth-consent-action.ts @@ -484,10 +484,13 @@ async function createDeniedOAuthAuthorization(input: { } export async function submitOAuthConsentAction({ + locals, request, }: { + locals?: { requestId?: string }; request: Request; }) { + const requestId = locals?.requestId; assertTrustedCookieRequestOrigin(request); const form = await request.formData(); @@ -515,7 +518,10 @@ export async function submitOAuthConsentAction({ if (!accept) { redirectTarget = (await createDeniedOAuthAuthorization({ - audit: { channel: "web", ...getAuditRequestMetadata(request) }, + audit: { + channel: "web", + ...getAuditRequestMetadata(request, requestId), + }, authorizeQuery, session, })) ?? undefined; @@ -544,7 +550,7 @@ export async function submitOAuthConsentAction({ targetType: existingConsent ? "oauth_consent" : "oauth_client", userId: session.user.id, metadata: { reason: "operation_failed" }, - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), }; const recent = await resolveAuthoritativeRecentSession(request.headers, { expectedUserId: session.user.id, @@ -563,7 +569,7 @@ export async function submitOAuthConsentAction({ targetType: existingConsent ? "oauth_consent" : "oauth_client", userId: session.user.id, metadata: { reason: recent.reason }, - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), }); throw new OAuthRecentAuthRequiredError(); } @@ -588,7 +594,7 @@ export async function submitOAuthConsentAction({ acceptedScopes: uniqueScopes(scope), audit: { channel: "web", - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), }, authorizeQuery, session, diff --git a/src/features/section-detail/server/section-page-data.ts b/src/features/section-detail/server/section-page-data.ts index 6206d8e41..5a2643ee8 100644 --- a/src/features/section-detail/server/section-page-data.ts +++ b/src/features/section-detail/server/section-page-data.ts @@ -8,49 +8,58 @@ import { sectionPageTeachersWithDepartmentSelect, } from "@/features/section-detail/server/section-page-shape"; import type { Prisma } from "@/generated/prisma/client"; +import type { AppLocale } from "@/i18n/config"; import { runCloudflareTraceSpan } from "@/lib/adapters/cloudflare-runtime"; +import { cachedPublicDetailRuntimeData } from "@/lib/catalog-detail-runtime-cache"; import { getPrisma } from "@/lib/db/prisma"; type SectionPageRecord = Prisma.SectionGetPayload<{ select: typeof sectionPageSelect; }>; -export async function getSectionPage(jwId: number, locale = "zh-cn") { +async function getSectionPageCore(jwId: number, locale: AppLocale) { const prisma = getPrisma(locale); const relatedWhere = { jwId: { not: jwId }, retiredAt: null, } as const; - const section = await runCloudflareTraceSpan( - "catalog.detail.section.query", - { "catalog.detail.kind": "section" }, - async () => - await prisma.section.findUnique({ - where: { jwId }, - select: { - ...sectionPageSelect, - course: { + const section = await cachedPublicDetailRuntimeData({ + id: jwId, + kind: "section", + locale, + shape: "page-core-v1", + load: () => + runCloudflareTraceSpan( + "catalog.detail.section.query", + { "catalog.detail.kind": "section" }, + async () => + await prisma.section.findUnique({ + where: { jwId }, select: { - ...sectionPageSelect.course.select, - _count: { select: { sections: { where: relatedWhere } } }, - sections: { - where: relatedWhere, - orderBy: [ - { semester: { jwId: "desc" as const } }, - { code: "asc" as const }, - ], - take: SECTION_RELATED_PREVIEW_LIMIT, - select: sectionPageRelatedSectionSelect, + ...sectionPageSelect, + course: { + select: { + ...sectionPageSelect.course.select, + _count: { select: { sections: { where: relatedWhere } } }, + sections: { + where: relatedWhere, + orderBy: [ + { semester: { jwId: "desc" as const } }, + { code: "asc" as const }, + ], + take: SECTION_RELATED_PREVIEW_LIMIT, + select: sectionPageRelatedSectionSelect, + }, + }, }, + description: false, + teachers: sectionPageTeachersWithDepartmentSelect, + exams: sectionPageSelect.exams, + schedules: sectionPageSelect.schedules, }, - }, - description: { select: sectionPageDescriptionSelect }, - teachers: sectionPageTeachersWithDepartmentSelect, - exams: sectionPageSelect.exams, - schedules: sectionPageSelect.schedules, - }, - }), - ); + }), + ), + }); if (!section) return null; @@ -58,13 +67,7 @@ export async function getSectionPage(jwId: number, locale = "zh-cn") { "catalog.detail.section.transform", { "catalog.detail.kind": "section" }, () => { - const { - course: courseRecord, - description, - exams, - schedules, - ...data - } = section; + const { course: courseRecord, exams, schedules, ...data } = section; const { _count: relatedCount, sections: relatedSections, @@ -83,7 +86,6 @@ export async function getSectionPage(jwId: number, locale = "zh-cn") { }; return { - description: serializeDescriptionRecord(description), section: buildSectionPageLoadData(normalizedSection, { otherCourseSectionCount: relatedCount.sections, otherCourseSections: relatedSections, @@ -92,3 +94,39 @@ export async function getSectionPage(jwId: number, locale = "zh-cn") { }, ); } + +async function getSectionPageMutableData(jwId: number, locale: AppLocale) { + const record = await runCloudflareTraceSpan( + "catalog.detail.section.mutable.query", + { "catalog.detail.kind": "section" }, + () => + getPrisma(locale).section.findUnique({ + where: { jwId }, + select: { + description: { select: sectionPageDescriptionSelect }, + retiredAt: true, + }, + }), + ); + return { + description: serializeDescriptionRecord(record?.description), + retiredAt: record?.retiredAt?.toISOString() ?? null, + }; +} + +export async function getSectionPage( + jwId: number, + locale: AppLocale = "zh-cn", +) { + const [core, mutable] = await Promise.all([ + getSectionPageCore(jwId, locale), + getSectionPageMutableData(jwId, locale), + ]); + return core + ? { + ...core, + description: mutable.description, + section: { ...core.section, retiredAt: mutable.retiredAt }, + } + : null; +} diff --git a/src/features/settings/server/settings-account-actions.ts b/src/features/settings/server/settings-account-actions.ts index 291a976fc..cce2d1a31 100644 --- a/src/features/settings/server/settings-account-actions.ts +++ b/src/features/settings/server/settings-account-actions.ts @@ -19,13 +19,15 @@ import { authorizeRecentSettingsAction } from "./settings-recent-auth"; export async function unlinkSettingsAccountAction({ locale, request, + requestId, url, -}: SettingsActionInput) { +}: SettingsActionInput & { requestId?: string }) { const copy = getSettingsCopy(locale); const user = await requireSettingsUser(request, url); const recent = await authorizeRecentSettingsAction({ action: "account_unlink", request, + requestId, targetType: "account", userId: user.id, }); @@ -50,7 +52,7 @@ export async function unlinkSettingsAccountAction({ targetType: "account", userId: user.id, metadata: { provider }, - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), }); throw error; } @@ -64,7 +66,7 @@ export async function unlinkSettingsAccountAction({ targetType: "account", userId: user.id, metadata: { provider, reason: "last_account" }, - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), }); return fail(400, { kind: "accounts", @@ -81,7 +83,7 @@ export async function unlinkSettingsAccountAction({ targetType: "account", userId: user.id, metadata: { provider, reason: "not_linked" }, - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), }); return fail(404, { kind: "accounts", @@ -96,7 +98,7 @@ export async function unlinkSettingsAccountAction({ targetType: "account", userId: user.id, metadata: { provider }, - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), }); throw redirect(303, "/account/settings/accounts?message=AccountDisconnected"); } @@ -113,6 +115,7 @@ export async function linkSettingsAccountAction({ const recent = await authorizeRecentSettingsAction({ action: "account_link", request, + requestId, targetType: "account", userId: user.id, }); @@ -155,7 +158,7 @@ export async function linkSettingsAccountAction({ targetType: "account", userId: user.id, metadata: { provider: providerId }, - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), }); return fail(400, { kind: "accounts", @@ -168,8 +171,9 @@ export async function deleteSettingsAccountAction({ cookies, locale, request, + requestId, url, -}: SettingsActionInput & { cookies: Cookies }) { +}: SettingsActionInput & { cookies: Cookies; requestId?: string }) { const copy = getSettingsCopy(locale); const user = await requireSettingsUser(request, url); const form = await request.formData(); @@ -182,6 +186,7 @@ export async function deleteSettingsAccountAction({ const recent = await authorizeRecentSettingsAction({ action: "account_delete", request, + requestId, targetType: "user", userId: user.id, }); @@ -192,7 +197,7 @@ export async function deleteSettingsAccountAction({ }); } const result = await deleteOwnAccount(user.id, { - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), channel: "web", sessionId: recent.sessionId, }); diff --git a/src/features/settings/server/settings-authorization-actions.ts b/src/features/settings/server/settings-authorization-actions.ts index 71bfbf037..a30e7f835 100644 --- a/src/features/settings/server/settings-authorization-actions.ts +++ b/src/features/settings/server/settings-authorization-actions.ts @@ -32,6 +32,7 @@ export async function revokeSettingsAuthorizationAction({ action: "oauth_authorization_revoke", request, targetType: "oauth_consent", + requestId, userId: user.id, }); if (!recent.ok) { @@ -52,7 +53,7 @@ export async function revokeSettingsAuthorizationAction({ let result: Awaited>; try { result = await revokeUserOAuthAuthorization(user.id, consentId, { - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), channel: "web", sessionId: recent.sessionId, }); @@ -71,7 +72,7 @@ export async function revokeSettingsAuthorizationAction({ targetId: consentId, targetType: "oauth_consent", userId: user.id, - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), }); return fail(500, { kind: "authorizations", @@ -89,7 +90,7 @@ export async function revokeSettingsAuthorizationAction({ targetType: "oauth_consent", userId: user.id, metadata: { reason: "not_found" }, - ...getAuditRequestMetadata(request), + ...getAuditRequestMetadata(request, requestId), }); return fail(404, { kind: "authorizations", diff --git a/src/features/settings/server/settings-page-actions.ts b/src/features/settings/server/settings-page-actions.ts index f877cedee..e1e25c6b2 100644 --- a/src/features/settings/server/settings-page-actions.ts +++ b/src/features/settings/server/settings-page-actions.ts @@ -33,7 +33,12 @@ export const settingsPageActions = { url, }), unlinkAccount: async ({ locals, request, url }: SettingsActionEvent) => - unlinkSettingsAccountAction({ locale: locals.locale, request, url }), + unlinkSettingsAccountAction({ + locale: locals.locale, + request, + requestId: locals.requestId, + url, + }), linkAccount: async ({ cookies, locals, request, url }: SettingsActionEvent) => linkSettingsAccountAction({ cookies, @@ -66,6 +71,7 @@ export const settingsPageActions = { cookies, locale: locals.locale, request, + requestId: locals.requestId, url, }), }; diff --git a/src/features/settings/server/settings-recent-auth.ts b/src/features/settings/server/settings-recent-auth.ts index 38f85865c..8fa9379a9 100644 --- a/src/features/settings/server/settings-recent-auth.ts +++ b/src/features/settings/server/settings-recent-auth.ts @@ -8,6 +8,7 @@ import { resolveAuthoritativeRecentSession } from "@/lib/auth/recent-session"; export async function authorizeRecentSettingsAction(input: { action: AuditAction; request: Request; + requestId?: string; targetType: string; userId: string; }) { @@ -28,7 +29,7 @@ export async function authorizeRecentSettingsAction(input: { userId: input.userId, ...(recent.sessionId ? { sessionId: recent.sessionId } : {}), metadata: { reason: recent.reason }, - ...getAuditRequestMetadata(input.request), + ...getAuditRequestMetadata(input.request, input.requestId), }); return recent; } diff --git a/src/features/settings/server/settings-security-actions.ts b/src/features/settings/server/settings-security-actions.ts index 06527afb1..1685413d1 100644 --- a/src/features/settings/server/settings-security-actions.ts +++ b/src/features/settings/server/settings-security-actions.ts @@ -10,7 +10,7 @@ export async function rotateSettingsCalendarTokenAction({ request, requestId, url, -}: SettingsActionInput & { requestId?: string }) { +}: SettingsActionInput & { requestId: string }) { const copy = getSettingsCopy(locale); const user = await requireSettingsUser(request, url); try { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c5f9a107d..fba1d3929 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -8,6 +8,7 @@ import { import { getOptionalTrimmedEnv, loadEnv } from "@/app-env"; import { LOCALE_COOKIE, negotiateLocale } from "@/i18n/config"; import { + getCloudflareRequestContext, runCloudflareTraceSpan, runWithCloudflareRuntimeEnv, setCloudflareRequestContext, @@ -28,6 +29,7 @@ import { import { normalizeApiRoutePath } from "@/lib/log/api-observability-path"; import { logAppEvent } from "@/lib/log/app-logger"; import { getSafeErrorName } from "@/lib/log/safe-error-name"; +import { getTrustedRequestId } from "@/lib/log/worker-entrypoint-observability"; import { type PageAuthMode, recordPageRequestError, @@ -186,7 +188,10 @@ const handleWithRuntimeEnv: Handle = async ({ event, resolve }) => { event.request.headers.get("accept-language"), ); event.locals.locale = locale; - const requestId = crypto.randomUUID(); + const requestId = + getCloudflareRequestContext()?.requestId ?? + getTrustedRequestId(event.request) ?? + crypto.randomUUID(); event.locals.requestId = requestId; setCloudflareRequestContext({ method: event.request.method, diff --git a/src/lib/adapters/cloudflare-runtime.ts b/src/lib/adapters/cloudflare-runtime.ts index e48d55e2a..100d273e8 100644 --- a/src/lib/adapters/cloudflare-runtime.ts +++ b/src/lib/adapters/cloudflare-runtime.ts @@ -255,6 +255,7 @@ export function runWithCloudflareRuntimeEnv( cacheStorage: normalizeCloudflareCacheStorage(), cleanups: new Set(), env: normalizeCloudflareRuntimeEnv(env) ?? parentContext?.env, + request: parentContext?.request, scheduleTask: normalizeCloudflareTaskScheduler(executionContext) ?? parentContext?.scheduleTask, diff --git a/src/lib/api/routes/admin-route-auth.ts b/src/lib/api/routes/admin-route-auth.ts index 78a99a947..edf83ceca 100644 --- a/src/lib/api/routes/admin-route-auth.ts +++ b/src/lib/api/routes/admin-route-auth.ts @@ -9,6 +9,7 @@ import { suspensionForbidden, unauthorized, } from "@/lib/api/helpers"; +import { logAdminSecurityEvent } from "@/lib/audit/security-events"; import { resolveSessionUserId } from "@/lib/auth/api-auth"; import { resolveAuthoritativeRecentSession } from "@/lib/auth/recent-session"; import { findActiveSuspension } from "@/lib/auth/viewer-context"; @@ -47,21 +48,33 @@ export async function requireAdminRequest( options: AdminGuardOptions = {}, ) { const userId = await resolveSessionUserId(request); - if (!userId) return unauthorized(); + if (!userId) { + logAdminSecurityEvent(request, "unauthenticated"); + return unauthorized(); + } const admin = await resolveAdminByUserId(userId); - if (!admin) return unauthorized(); + if (!admin) { + logAdminSecurityEvent(request, "not_admin"); + return unauthorized(); + } if (!options.allowSuspended) { const suspension = await findActiveSuspension(admin.userId); - if (suspension) return suspensionForbidden(suspension.reason); + if (suspension) { + logAdminSecurityEvent(request, "suspended"); + return suspensionForbidden(suspension.reason); + } } if (options.requireRecent) { const recent = await resolveAuthoritativeRecentSession(request.headers, { expectedUserId: admin.userId, }); - if (!recent.ok) return recentAuthenticationRequired(); + if (!recent.ok) { + logAdminSecurityEvent(request, "recent_auth_required"); + return recentAuthenticationRequired(); + } } if (!["GET", "HEAD", "OPTIONS"].includes(request.method.toUpperCase())) { @@ -72,6 +85,12 @@ export async function requireAdminRequest( userId: admin.userId, }); if (!outcome.allowed) { + logAdminSecurityEvent( + request, + outcome.reason === "limited" + ? "rate_limited" + : "rate_limit_unavailable", + ); return rateLimitResponse( outcome.reason, USER_MUTATION_RATE_LIMIT_PERIOD_SECONDS, diff --git a/src/lib/api/routes/admin-suspensions.ts b/src/lib/api/routes/admin-suspensions.ts index 1c4632dd7..dc4cfb713 100644 --- a/src/lib/api/routes/admin-suspensions.ts +++ b/src/lib/api/routes/admin-suspensions.ts @@ -12,6 +12,7 @@ import { } from "@/lib/api/helpers"; import { withAdminApiRoute } from "@/lib/api/routes/admin-route-auth"; import { adminCreateSuspensionRequestSchema } from "@/lib/api/schemas/request-schemas"; +import { logAdminSecurityEvent } from "@/lib/audit/security-events"; import { getAuditRequestMetadata } from "@/lib/audit/write-audit-log"; import { type IdParams, parseIdParam } from "./admin-shared"; @@ -43,6 +44,7 @@ export async function postAdminSuspensionRoute(request: Request) { return badRequest("Invalid expiresAt"); } if (result.reason === "cannot_suspend_self") { + logAdminSecurityEvent(request, "self_protection"); return badRequest("Admins cannot suspend themselves"); } return notFound("User not found"); diff --git a/src/lib/api/routes/admin-users.ts b/src/lib/api/routes/admin-users.ts index 5654e1a37..6d7231342 100644 --- a/src/lib/api/routes/admin-users.ts +++ b/src/lib/api/routes/admin-users.ts @@ -15,6 +15,7 @@ import { adminUpdateUserRequestSchema, adminUsersQuerySchema, } from "@/lib/api/schemas/request-schemas"; +import { logAdminSecurityEvent } from "@/lib/audit/security-events"; import { getAuditRequestMetadata } from "@/lib/audit/write-audit-log"; import { type IdParams, parseIdParam } from "./admin-shared"; @@ -76,9 +77,11 @@ export async function patchAdminUserRoute(request: Request, params: IdParams) { return badRequest("Username already taken"); } if (result.reason === "cannot_demote_self") { + logAdminSecurityEvent(request, "self_protection"); return badRequest("Admins cannot remove their own admin role"); } if (result.reason === "cannot_remove_last_admin") { + logAdminSecurityEvent(request, "self_protection"); return badRequest("At least one admin must remain"); } return notFound("User not found"); diff --git a/src/lib/audit/audit-log-queue.ts b/src/lib/audit/audit-log-queue.ts index 1be2bd110..051e1485d 100644 --- a/src/lib/audit/audit-log-queue.ts +++ b/src/lib/audit/audit-log-queue.ts @@ -1,3 +1,8 @@ +import { + AuditAction, + AuditChannel, + AuditOutcome, +} from "@/generated/prisma/client"; import type { AuditLogParams } from "@/lib/audit/write-audit-log"; import { writeAuditLog } from "@/lib/audit/write-audit-log"; import { logAppEvent } from "@/lib/log/app-logger"; @@ -16,6 +21,66 @@ type QueueMessage = { retry(): void; }; +const AUDIT_ACTIONS = new Set(Object.values(AuditAction)); +const AUDIT_CHANNELS = new Set(Object.values(AuditChannel)); +const AUDIT_OUTCOMES = new Set(Object.values(AuditOutcome)); + +const AUDIT_PARAM_KEYS = new Set([ + "action", + "channel", + "ipAddress", + "metadata", + "oauthClientId", + "oauthGrantId", + "outcome", + "requestId", + "sessionId", + "subjectUserId", + "targetId", + "targetType", + "userAgent", + "userId", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function boundedString(value: unknown, maxLength: number) { + return typeof value === "string" && value.length <= maxLength; +} + +function validOptionalString(value: unknown, maxLength: number) { + return value === undefined || boundedString(value, maxLength); +} + +function parseAuditParams(value: unknown): Omit | null { + if (!isRecord(value)) return null; + if ( + [...Object.keys(value)].some((key) => !AUDIT_PARAM_KEYS.has(key)) || + !AUDIT_ACTIONS.has(value.action as AuditAction) || + !validOptionalString(value.userId, 128) || + !validOptionalString(value.subjectUserId, 128) || + !validOptionalString(value.oauthClientId, 128) || + !validOptionalString(value.oauthGrantId, 128) || + !validOptionalString(value.sessionId, 128) || + !validOptionalString(value.requestId, 128) || + !validOptionalString(value.targetId, 256) || + !validOptionalString(value.targetType, 128) || + !validOptionalString(value.ipAddress, 64) || + !validOptionalString(value.userAgent, 512) || + (value.channel !== undefined && + !AUDIT_CHANNELS.has(value.channel as AuditChannel)) || + (value.outcome !== undefined && + !AUDIT_OUTCOMES.has(value.outcome as AuditOutcome)) || + (value.metadata !== undefined && !isRecord(value.metadata)) + ) { + return null; + } + + return value as Omit; +} + export type AuditLogWriteQueueBatch = { messages: readonly QueueMessage[]; }; @@ -34,20 +99,35 @@ export function parseAuditLogWriteQueueMessage( typeof message.auditId !== "string" || message.auditId.length < 1 || message.auditId.length > 128 || - !message.params || - typeof message.params !== "object" || - typeof (message.params as { action?: unknown }).action !== "string" + !parseAuditParams(message.params) ) { return null; } - return message as AuditLogWriteQueueMessage; + return { + auditId: message.auditId, + params: parseAuditParams(message.params) as Omit, + type: "audit-log.write.v1", + }; +} + +function logInvalidAuditMessage() { + logAppEvent("error", "audit-log-write.invalid-message", { + event: "audit-log-write.invalid-message", + phase: "consumer", + reason: "invalid_envelope", + source: "audit", + }); } export async function handleAuditLogWriteBatch(batch: AuditLogWriteQueueBatch) { for (const message of batch.messages) { const parsed = parseAuditLogWriteQueueMessage(message.body); if (!parsed) { - message.ack(); + logInvalidAuditMessage(); + // Invalid messages are permanent failures, but retrying is required so + // Cloudflare Queues can move them to the configured DLQ for inspection. + // Never include the body in the log: it may contain credentials or PII. + message.retry(); continue; } try { @@ -60,6 +140,8 @@ export async function handleAuditLogWriteBatch(batch: AuditLogWriteQueueBatch) { { action: parsed.params.action, event: "audit-log-write.retry", + phase: "consumer", + reason: "database_write_failed", messageType: parsed.type, source: "audit", targetType: parsed.params.targetType, diff --git a/src/lib/audit/request-metadata.ts b/src/lib/audit/request-metadata.ts index a2a169b52..56ec729c3 100644 --- a/src/lib/audit/request-metadata.ts +++ b/src/lib/audit/request-metadata.ts @@ -1,16 +1,57 @@ -export function getAuditRequestMetadata(request: Pick) { +import { getCloudflareRequestContext } from "@/lib/adapters/cloudflare-runtime"; +import { getApiRequestObservabilityRequestId } from "@/lib/log/api-observability-context"; + +type AuditRequest = Pick; + +const fallbackRequestIds = new WeakMap(); + +function generatedRequestId(request: AuditRequest) { + const headersObject = request.headers as object; + const existing = fallbackRequestIds.get(headersObject); + if (existing) return existing; + + const requestId = crypto.randomUUID(); + fallbackRequestIds.set(headersObject, requestId); + return requestId; +} + +/** + * Return the request ID assigned by the application runtime. + * + * Request IDs are intentionally never read from request headers: x-request-id + * is client-controlled and cf-ray identifies an edge event, not this request's + * application correlation context. + */ +export function getAuditRequestId( + request: AuditRequest, + trustedRequestId?: string, +) { + const normalizedTrustedRequestId = trustedRequestId?.trim(); + if (normalizedTrustedRequestId) { + return normalizedTrustedRequestId.slice(0, 128); + } + + const observedRequestId = getApiRequestObservabilityRequestId( + request as Request, + ); + if (observedRequestId) return observedRequestId; + + const runtimeRequestId = getCloudflareRequestContext()?.requestId; + return runtimeRequestId ?? generatedRequestId(request); +} + +export function getAuditRequestMetadata( + request: AuditRequest, + trustedRequestId?: string, +) { // Production traffic terminates at Cloudflare. Forwarded headers supplied by // arbitrary clients are not authoritative and must not enter forensic data. const ipAddress = request.headers.get("cf-connecting-ip")?.trim() || undefined; const userAgent = request.headers.get("user-agent")?.trim() || undefined; - const requestId = - request.headers.get("cf-ray")?.trim() || - request.headers.get("x-request-id")?.trim() || - undefined; return { ipAddress: ipAddress?.slice(0, 64), - requestId: requestId?.slice(0, 128), + requestId: getAuditRequestId(request, trustedRequestId), userAgent: userAgent?.slice(0, 512), }; } diff --git a/src/lib/audit/security-events.ts b/src/lib/audit/security-events.ts new file mode 100644 index 000000000..2f936cea1 --- /dev/null +++ b/src/lib/audit/security-events.ts @@ -0,0 +1,54 @@ +import { getAuditRequestId } from "@/lib/audit/request-metadata"; +import { logAppEvent } from "@/lib/log/app-logger"; + +export type AdminSecurityDenialReason = + | "not_admin" + | "rate_limit_unavailable" + | "rate_limited" + | "recent_auth_required" + | "self_protection" + | "suspended" + | "unauthenticated"; + +function adminRouteClass(pathname: string) { + if (pathname.startsWith("/api/admin/")) return "api_admin"; + if (pathname.startsWith("/admin/")) return "admin_page"; + return "unknown"; +} + +function adminMethod(method: string) { + const normalized = method.toUpperCase(); + return ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"].includes( + normalized, + ) + ? normalized + : "OTHER"; +} + +/** + * Record an admin authorization or self-protection denial without creating a + * database audit row. Anonymous requests cannot be safely attributed to an + * AuditLog actor, so the structured security log is the source of truth. + * + * Keep this payload deliberately bounded: no cookie, token, body, email, IP, + * or raw target identifier is included. + */ +export function logAdminSecurityEvent( + request: Pick, + reason: AdminSecurityDenialReason, +) { + const pathname = new URL(request.url).pathname; + logAppEvent( + reason === "rate_limit_unavailable" ? "error" : "warn", + "admin.authorization.denied", + { + event: "admin.authorization.denied", + method: adminMethod(request.method), + phase: "authorization", + reason, + requestId: getAuditRequestId(request), + route: adminRouteClass(pathname), + source: "security", + }, + ); +} diff --git a/src/lib/audit/write-audit-log.ts b/src/lib/audit/write-audit-log.ts index 7346b97a0..2575d47d8 100644 --- a/src/lib/audit/write-audit-log.ts +++ b/src/lib/audit/write-audit-log.ts @@ -12,7 +12,10 @@ import { prisma } from "@/lib/db/prisma"; import { logAppEvent } from "@/lib/log/app-logger"; import { writeAuditWriteAnalytics } from "@/lib/metrics/analytics-engine"; -export { getAuditRequestMetadata } from "@/lib/audit/request-metadata"; +export { + getAuditRequestId, + getAuditRequestMetadata, +} from "@/lib/audit/request-metadata"; export type AuditLogParams = { id?: string; @@ -38,11 +41,23 @@ type AuditLogClient = { }; }; -function logAuditWriteFailure(params: AuditLogParams, error: unknown) { +function logAuditWriteFailure( + params: AuditLogParams, + error: unknown, + phase: "database" | "enqueue", +) { logAppEvent( "error", - "Audit log write failed", + phase === "enqueue" + ? "audit-log.enqueue.failure" + : "audit-log.write.failure", { + event: + phase === "enqueue" + ? "audit-log.enqueue.failure" + : "audit-log.write.failure", + phase, + outcome: "failure", source: "audit", action: params.action, targetType: params.targetType, @@ -51,6 +66,17 @@ function logAuditWriteFailure(params: AuditLogParams, error: unknown) { ); } +function logAuditEnqueueSuccess(params: AuditLogParams) { + logAppEvent("info", "audit-log.enqueue.success", { + action: params.action, + event: "audit-log.enqueue.success", + outcome: "success", + phase: "enqueue", + source: "audit", + targetType: params.targetType, + }); +} + export async function writeAuditLog( params: AuditLogParams, client: AuditLogClient = prisma, @@ -103,17 +129,24 @@ export async function writeAuditLog( export async function fireAuditLog(params: AuditLogParams) { const queue = getCloudflareAuditLogWriteQueue(); const { id, ...queueParams } = params; - const auditWrite = ( - queue - ? queue.send({ - auditId: id ?? crypto.randomUUID(), - params: queueParams, - type: "audit-log.write.v1", - }) - : writeAuditLog(params) - ).catch((error: unknown) => { - logAuditWriteFailure(params, error); - }); + const auditWrite = queue + ? Promise.resolve() + .then(() => + queue.send({ + auditId: id ?? crypto.randomUUID(), + params: queueParams, + type: "audit-log.write.v1", + }), + ) + .then( + () => logAuditEnqueueSuccess(params), + (error: unknown) => { + logAuditWriteFailure(params, error, "enqueue"); + }, + ) + : writeAuditLog(params).catch((error: unknown) => { + logAuditWriteFailure(params, error, "database"); + }); const scheduleTask = getCloudflareRuntimeTaskScheduler(); if (scheduleTask) { diff --git a/src/lib/log/app-log-emitter.ts b/src/lib/log/app-log-emitter.ts index ab166527c..5eebae551 100644 --- a/src/lib/log/app-log-emitter.ts +++ b/src/lib/log/app-log-emitter.ts @@ -2,6 +2,7 @@ import { type AppLogLevel, getLogMethod, isProductionEnvironment, + safeJsonStringify, serializeError, } from "@/lib/log/app-logger-core"; @@ -20,7 +21,17 @@ export function emitLog( ...payload, ...(serializedError ? { error: serializedError } : {}), }; - method(JSON.stringify(logObj)); + method( + safeJsonStringify( + logObj, + JSON.stringify({ + environment: "production", + event: "log.serialization-failed", + message: "Log serialization failed", + prefix: "[app]", + }), + ), + ); return; } diff --git a/src/lib/log/app-logger-core.ts b/src/lib/log/app-logger-core.ts index 953a33149..6ce1dba3f 100644 --- a/src/lib/log/app-logger-core.ts +++ b/src/lib/log/app-logger-core.ts @@ -32,6 +32,81 @@ export function shouldLog(level: AppLogLevel): boolean { const PRISMA_ERROR_CODE_PATTERN = /^P\d{4}$/; const SQLSTATE_CODE_PATTERN = /^[0-9A-Z]{5}$/; +const SAFE_DATABASE_CODE_KEYS = [ + "code", + "driverCode", + "originalCode", + "sqlState", + "sqlstate", +] as const; + +type SafeDatabaseErrorDetails = { + code?: string; + prismaCode?: string; +}; + +function readProperty(value: object, key: string): unknown { + try { + return Reflect.get(value, key); + } catch { + return undefined; + } +} + +function readSafeDatabaseCode(value: unknown) { + if (typeof value !== "string") return undefined; + if (SQLSTATE_CODE_PATTERN.test(value)) return value; + if (PRISMA_ERROR_CODE_PATTERN.test(value)) return value; + return undefined; +} + +function collectSafeDatabaseErrorDetails( + error: unknown, + details: SafeDatabaseErrorDetails, + ancestors: WeakSet, + depth: number, +) { + if ( + depth > 8 || + typeof error !== "object" || + error === null || + ancestors.has(error) + ) { + return; + } + + ancestors.add(error); + try { + for (const key of SAFE_DATABASE_CODE_KEYS) { + const code = readSafeDatabaseCode(readProperty(error, key)); + if (!code) continue; + if (PRISMA_ERROR_CODE_PATTERN.test(code)) { + details.prismaCode ??= code; + } else { + details.code ??= code; + } + } + + for (const key of ["meta", "cause"] as const) { + collectSafeDatabaseErrorDetails( + readProperty(error, key), + details, + ancestors, + depth + 1, + ); + } + } finally { + ancestors.delete(error); + } +} + +export function getSafeDatabaseErrorDetails( + error: unknown, +): SafeDatabaseErrorDetails { + const details: SafeDatabaseErrorDetails = {}; + collectSafeDatabaseErrorDetails(error, details, new WeakSet(), 0); + return details; +} /** * Prisma `P####` codes and PostgreSQL SQLSTATE codes are stable, documented @@ -42,46 +117,92 @@ const SQLSTATE_CODE_PATTERN = /^[0-9A-Z]{5}$/; * Walk `cause` / Prisma `meta` because driver-adapter failures often nest the * real SQLSTATE under the top-level Prisma wrapper. */ -export function getSafeDatabaseErrorCode( - error: unknown, - depth = 0, -): string | undefined { - if (depth > 5 || typeof error !== "object" || error === null) { - return undefined; - } +export function getSafeDatabaseErrorCode(error: unknown): string | undefined { + const details = getSafeDatabaseErrorDetails(error); + return details.code ?? details.prismaCode; +} - if ("code" in error) { - const { code } = error as { code: unknown }; - if (typeof code === "string") { - if (PRISMA_ERROR_CODE_PATTERN.test(code)) return code; - if (SQLSTATE_CODE_PATTERN.test(code)) return code; +const SAFE_LOG_MAX_DEPTH = 12; +const SAFE_LOG_MAX_KEYS = 256; + +function safeLogValue( + value: unknown, + ancestors: WeakSet, + depth: number, +): unknown { + if (value === null) return null; + if (typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value === "bigint") return "[BigInt]"; + if (typeof value === "undefined") return undefined; + if (typeof value === "function") return "[Function]"; + if (typeof value === "symbol") return "[Symbol]"; + if (depth >= SAFE_LOG_MAX_DEPTH) return "[MaxDepth]"; + if (typeof value !== "object") return "[Unserializable]"; + if (ancestors.has(value)) return "[Circular]"; + + ancestors.add(value); + try { + if (Array.isArray(value)) { + const output = [] as unknown[]; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor( + value, + String(index), + ); + output.push( + descriptor && "value" in descriptor + ? safeLogValue(descriptor.value, ancestors, depth + 1) + : "[Unserializable]", + ); + } + return output; } - } - if ("meta" in error) { - const fromMeta = getSafeDatabaseErrorCode( - (error as { meta: unknown }).meta, - depth + 1, - ); - if (fromMeta) return fromMeta; + const output = Object.create(null) as Record; + for (const key of Object.keys(value).slice(0, SAFE_LOG_MAX_KEYS)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + output[key] = "[Unserializable]"; + continue; + } + output[key] = safeLogValue(descriptor.value, ancestors, depth + 1); + } + return output; + } catch { + return "[Unserializable]"; + } finally { + ancestors.delete(value); } +} - if ("cause" in error) { - return getSafeDatabaseErrorCode( - (error as { cause: unknown }).cause, - depth + 1, - ); +export function safeJsonStringify(value: unknown, fallback: string) { + try { + const sanitized = safeLogValue(value, new WeakSet(), 0); + const serialized = JSON.stringify(sanitized); + return serialized === undefined ? fallback : serialized; + } catch { + return fallback; } - - return undefined; } export function serializeError(error: unknown) { - if (!error) return undefined; + if (error === undefined || error === null) return undefined; if (isProductionEnvironment()) { - const code = getSafeDatabaseErrorCode(error); - return { name: getSafeErrorName(error), ...(code ? { code } : {}) }; + const details = getSafeDatabaseErrorDetails(error); + const name = (() => { + try { + return getSafeErrorName(error); + } catch { + return "UnknownError"; + } + })(); + return { + name, + ...(details.code ? { code: details.code } : {}), + ...(details.prismaCode ? { prismaCode: details.prismaCode } : {}), + }; } if (error instanceof Error) { diff --git a/src/lib/log/worker-entrypoint-observability.ts b/src/lib/log/worker-entrypoint-observability.ts index 6d1196019..5ed72ea76 100644 --- a/src/lib/log/worker-entrypoint-observability.ts +++ b/src/lib/log/worker-entrypoint-observability.ts @@ -1,5 +1,9 @@ import { logAppEvent } from "@/lib/log/app-logger"; +export const INTERNAL_REQUEST_ID_HEADER = "x-life-ustc-request-id"; +const REQUEST_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + export type EdgeRequestClass = | "catalog-redirect" | "public-not-found" @@ -38,6 +42,27 @@ const SAFE_CACHE_OUTCOMES = new Set([ "updating", ]); +export type WorkerQueue = "audit" | "calendar" | "unknown"; + +export function resolveWorkerQueue(queue: string): WorkerQueue { + if (queue === "life-ustc-audit-log-write") return "audit"; + if (queue === "life-ustc-calendar-export-rebuild") return "calendar"; + return "unknown"; +} + +export function getTrustedRequestId(request: Request) { + const requestId = request.headers.get(INTERNAL_REQUEST_ID_HEADER); + return requestId && REQUEST_ID_PATTERN.test(requestId) + ? requestId + : undefined; +} + +export function setTrustedRequestIdHeader(headers: Headers, requestId: string) { + headers.delete("x-request-id"); + headers.delete(INTERNAL_REQUEST_ID_HEADER); + headers.set(INTERNAL_REQUEST_ID_HEADER, requestId); +} + export function normalizePublicSsrObservedRoute(pathname: string) { const detail = CATALOG_DETAIL_ROUTE.exec(pathname); if (detail) return `/catalog/${detail[1]}/:id`; @@ -85,21 +110,105 @@ export function observedEdgeResponse(input: { return response; } +export function logWorkerFetchError(input: { + error: unknown; + ioObservedDurationMs: number; + requestId: string; +}) { + logAppEvent( + "error", + "worker.fetch.error", + { + event: "worker.fetch.error", + ioObservedDurationMs: input.ioObservedDurationMs, + outcome: "error", + requestId: input.requestId, + source: "worker-entrypoint", + }, + input.error, + ); +} + +export function logWorkerQueueFinish(input: { + ioObservedDurationMs: number; + messageCount: number; + queue: Exclude; +}) { + logAppEvent("info", "worker.queue.finish", { + event: "worker.queue.finish", + ioObservedDurationMs: input.ioObservedDurationMs, + messageCount: input.messageCount, + outcome: "success", + queue: input.queue, + source: "worker-entrypoint", + }); +} + +export function logWorkerQueueError(input: { + error: unknown; + ioObservedDurationMs: number; + messageCount: number; + queue: WorkerQueue; +}) { + logAppEvent( + "error", + "worker.queue.error", + { + event: "worker.queue.error", + ioObservedDurationMs: input.ioObservedDurationMs, + messageCount: input.messageCount, + outcome: "error", + queue: input.queue, + source: "worker-entrypoint", + }, + input.error, + ); +} + +type ScheduledTask = + | "auth-and-audit-retention" + | "auth-record-cleanup" + | "upload-pending-cleanup"; + export function logScheduledTaskFinish( - task: "auth-record-cleanup" | "upload-pending-cleanup", + task: ScheduledTask, counts: Record, + ioObservedDurationMs?: number, ) { logAppEvent("info", "scheduled.task.finish", { ...counts, event: "scheduled.task.finish", + ...(ioObservedDurationMs === undefined ? {} : { ioObservedDurationMs }), + outcome: "success", source: "worker-entrypoint", task, }); } -export function logUnknownScheduledTask() { +export function logScheduledTaskError( + task: ScheduledTask | "unknown", + ioObservedDurationMs: number, + error: unknown, +) { + logAppEvent( + "error", + "scheduled.task.error", + { + event: "scheduled.task.error", + ioObservedDurationMs, + outcome: "error", + source: "worker-entrypoint", + task, + }, + error, + ); +} + +export function logUnknownScheduledTask(ioObservedDurationMs?: number) { logAppEvent("warn", "scheduled.task.unknown", { event: "scheduled.task.unknown", + ...(ioObservedDurationMs === undefined ? {} : { ioObservedDurationMs }), + outcome: "unknown", source: "worker-entrypoint", }); } diff --git a/src/lib/metrics/analytics-engine.ts b/src/lib/metrics/analytics-engine.ts index b01913eba..3c3ef330f 100644 --- a/src/lib/metrics/analytics-engine.ts +++ b/src/lib/metrics/analytics-engine.ts @@ -1,5 +1,10 @@ import type { AppLocale } from "@/i18n/config"; -import { getCloudflareAnalyticsEngineDataset } from "@/lib/adapters/cloudflare-runtime"; +import { + getCloudflareAnalyticsEngineDataset, + getCloudflareRuntimeEnvInput, +} from "@/lib/adapters/cloudflare-runtime"; +import { emitLog } from "@/lib/log/app-log-emitter"; +import { getSafeErrorName } from "@/lib/log/safe-error-name"; import type { McpRequestSummary, McpResponsePhase, @@ -153,7 +158,13 @@ type CalendarFeedCacheAnalyticsInput = { }; type CalendarExportRebuildAnalyticsInput = { - status: "enqueued" | "ok" | "error"; + status: + | "enqueue_error" + | "enqueued" + | "error" + | "ok" + | "refresh_error" + | "store_error"; }; type GraphqlOperationAnalyticsInput = { @@ -239,13 +250,66 @@ function finiteNumber(value: number | undefined | null) { return Math.max(0, value); } +let analyticsBindingMissingLogged = false; +let analyticsWriteFailureLogged = new WeakSet(); + +function logAnalyticsDiagnostic( + message: string, + context: Record, +) { + try { + // This emitter intentionally does not call any Analytics Engine writer, so + // an unavailable binding cannot recurse back into this module. + emitLog("[analytics]", "error", { ...context, message }); + } catch { + // Diagnostics must not become a user-facing failure. + } +} + +function logMissingAnalyticsBinding() { + if ( + getCloudflareRuntimeEnvInput().NODE_ENV !== "production" || + analyticsBindingMissingLogged + ) { + return; + } + analyticsBindingMissingLogged = true; + logAnalyticsDiagnostic("analytics-engine.binding-missing", { + event: "analytics-engine.binding-missing", + source: "analytics-engine", + }); +} + +function logAnalyticsWriteFailure(dataset: object, error: unknown) { + if (analyticsWriteFailureLogged.has(dataset)) return; + analyticsWriteFailureLogged.add(dataset); + logAnalyticsDiagnostic("analytics-engine.write-failed", { + errorName: getSafeErrorName(error), + event: "analytics-engine.write-failed", + source: "analytics-engine", + }); +} + +/** Reset diagnostics state between isolated unit-test runtimes. */ +export function resetAnalyticsEngineDiagnosticsForTest() { + analyticsBindingMissingLogged = false; + analyticsWriteFailureLogged = new WeakSet(); +} + function writeAnalyticsDataPoint(input: { blobs: string[]; doubles: number[]; indexes: string[]; }) { const dataset = getCloudflareAnalyticsEngineDataset(); - if (!dataset) return; + if ( + !dataset || + typeof dataset !== "object" || + typeof dataset.writeDataPoint !== "function" + ) { + logMissingAnalyticsBinding(); + return; + } try { dataset.writeDataPoint({ @@ -253,8 +317,8 @@ function writeAnalyticsDataPoint(input: { blobs: input.blobs.map(boundedValue), doubles: input.doubles.map(finiteNumber), }); - } catch { - // Analytics Engine must never affect the user-facing request path. + } catch (error) { + logAnalyticsWriteFailure(dataset, error); } } diff --git a/src/worker.js b/src/worker.js index 396bf88da..7a5f7af6f 100644 --- a/src/worker.js +++ b/src/worker.js @@ -12,11 +12,11 @@ import { resolveCatalogListPublicSsrMode, } from "./features/catalog/lib/catalog-list-query"; import { cleanupStaleUploadPendingStorage } from "./features/uploads/server/upload-pending-cleanup"; -import { runWithCloudflareRuntimeEnv } from "./lib/adapters/cloudflare-runtime"; import { - AUDIT_LOG_WRITE_QUEUE_NAME, - handleAuditLogWriteBatch, -} from "./lib/audit/audit-log-queue"; + runWithCloudflareRuntimeEnv, + setCloudflareRequestContext, +} from "./lib/adapters/cloudflare-runtime"; +import { handleAuditLogWriteBatch } from "./lib/audit/audit-log-queue"; import { CATALOG_EDGE_CACHE_TAG } from "./lib/catalog-edge-cache-tag"; import { buildPublicNotFoundHtml, @@ -43,11 +43,18 @@ import { import { maintenancePrisma } from "./lib/db/maintenance-prisma"; import { prisma } from "./lib/db/prisma"; import { + INTERNAL_REQUEST_ID_HEADER, + logScheduledTaskError, logScheduledTaskFinish, logUnknownScheduledTask, + logWorkerFetchError, + logWorkerQueueError, + logWorkerQueueFinish, normalizePublicSsrObservedRoute, observedEdgeResponse, resolveEdgeCacheOutcome, + resolveWorkerQueue, + setTrustedRequestIdHeader, } from "./lib/log/worker-entrypoint-observability"; import { buildContentSecurityPolicy } from "./lib/security/csp"; import { CONTENT_SIGNAL } from "./lib/seo/content-signal"; @@ -77,6 +84,7 @@ function prepareCachedRepresentation(response) { headers.set("Cache-Tag", CATALOG_EDGE_CACHE_TAG); headers.delete("Vary"); headers.delete("Content-Length"); + headers.delete("x-request-id"); return new Response(response.body, { headers, status: response.status, @@ -152,17 +160,20 @@ function directRequest(request) { if ( !request.headers.has(PUBLIC_SSR_HEADER) && !request.headers.has(PUBLIC_SSR_LOCALE_HEADER) && - !request.headers.has(PUBLIC_SSR_MODE_HEADER) + !request.headers.has(PUBLIC_SSR_MODE_HEADER) && + !request.headers.has(INTERNAL_REQUEST_ID_HEADER) && + !request.headers.has("x-request-id") ) { return request; } - const headers = new Headers(request.headers); removePublicSsrHeaders(headers); + headers.delete(INTERNAL_REQUEST_ID_HEADER); + headers.delete("x-request-id"); return new Request(request, { headers }); } -function publicSsrRequest(request, mode, locale) { +function publicSsrRequest(request, mode, locale, requestId) { const url = new URL(request.url); const catalogListPath = isCatalogListPath(url.pathname); const canonicalQuery = catalogListPath @@ -185,6 +196,7 @@ function publicSsrRequest(request, mode, locale) { headers.set(PUBLIC_SSR_HEADER, "1"); headers.set(PUBLIC_SSR_LOCALE_HEADER, locale); headers.set(PUBLIC_SSR_MODE_HEADER, mode); + setTrustedRequestIdHeader(headers, requestId); return new Request(url, { headers, method: request.method, @@ -210,14 +222,14 @@ export class PublicSsr extends WorkerEntrypoint { } } -async function handleFetch(request, env, context) { +async function handleFetch(request, env, context, requestId) { const startMs = Date.now(); const finish = (response, requestClass, route, cacheOutcome = "bypass") => observedEdgeResponse({ cacheOutcome, request, requestClass, - requestId: crypto.randomUUID(), + requestId, response, route, startMs, @@ -334,7 +346,7 @@ async function handleFetch(request, env, context) { "public-not-found", ); } - const cachedRequest = publicSsrRequest(request, mode, locale); + const cachedRequest = publicSsrRequest(request, mode, locale, requestId); const cacheUrl = new URL(cachedRequest.url); const response = await context.exports .PublicSsr({ props: { locale, mode } }) @@ -351,49 +363,105 @@ async function handleFetch(request, env, context) { export default { async fetch(request, env, context) { - return runWithCloudflareRuntimeEnv( - env, - () => handleFetch(request, env, context), - context, - ); + const requestId = crypto.randomUUID(); + const startMs = Date.now(); + try { + const response = await runWithCloudflareRuntimeEnv( + env, + () => { + setCloudflareRequestContext({ + method: request.method, + requestId, + route: normalizePublicSsrObservedRoute( + new URL(request.url).pathname, + ), + }); + return handleFetch(request, env, context, requestId); + }, + context, + ); + return response; + } catch (error) { + logWorkerFetchError({ + error, + ioObservedDurationMs: Date.now() - startMs, + requestId, + }); + throw error; + } }, async queue(batch, env, context) { - await runWithCloudflareRuntimeEnv( - env, - () => - batch.queue === AUDIT_LOG_WRITE_QUEUE_NAME - ? handleAuditLogWriteBatch(batch) - : handleCalendarExportRebuildBatch(batch), - context, - ); + const startMs = Date.now(); + const queue = resolveWorkerQueue(batch.queue); + try { + await runWithCloudflareRuntimeEnv( + env, + () => { + if (queue === "audit") { + return handleAuditLogWriteBatch(batch); + } + if (queue === "calendar") { + return handleCalendarExportRebuildBatch(batch); + } + throw new Error("Unsupported queue"); + }, + context, + ); + logWorkerQueueFinish({ + ioObservedDurationMs: Date.now() - startMs, + messageCount: batch.messages.length, + queue, + }); + } catch (error) { + logWorkerQueueError({ + error, + ioObservedDurationMs: Date.now() - startMs, + messageCount: batch.messages.length, + queue, + }); + throw error; + } }, async scheduled(controller, env, context) { - await runWithCloudflareRuntimeEnv( - env, - async () => { - if (controller.cron === UPLOAD_PENDING_CLEANUP_CRON) { - const report = await cleanupStaleUploadPendingStorage(prisma); - logScheduledTaskFinish("upload-pending-cleanup", report); - return; - } + const startMs = Date.now(); + let task = "unknown"; + try { + await runWithCloudflareRuntimeEnv( + env, + async () => { + if (controller.cron === UPLOAD_PENDING_CLEANUP_CRON) { + task = "upload-pending-cleanup"; + const report = await cleanupStaleUploadPendingStorage(prisma); + logScheduledTaskFinish(task, report, Date.now() - startMs); + return; + } - if (controller.cron === AUTH_RECORD_CLEANUP_CRON) { - const [authRecords, auditLog, oauthUsage] = await Promise.all([ - cleanupExpiredAuthRecords(maintenancePrisma), - maintainAuditLogRetention(maintenancePrisma), - maintainOAuthGrantUsageRetention(maintenancePrisma), - ]); - logScheduledTaskFinish("auth-and-audit-retention", { - ...authRecords, - ...auditLog, - ...oauthUsage, - }); - return; - } + if (controller.cron === AUTH_RECORD_CLEANUP_CRON) { + task = "auth-and-audit-retention"; + const [authRecords, auditLog, oauthUsage] = await Promise.all([ + cleanupExpiredAuthRecords(maintenancePrisma), + maintainAuditLogRetention(maintenancePrisma), + maintainOAuthGrantUsageRetention(maintenancePrisma), + ]); + logScheduledTaskFinish( + task, + { + ...authRecords, + ...auditLog, + ...oauthUsage, + }, + Date.now() - startMs, + ); + return; + } - logUnknownScheduledTask(); - }, - context, - ); + logUnknownScheduledTask(Date.now() - startMs); + }, + context, + ); + } catch (error) { + logScheduledTaskError(task, Date.now() - startMs, error); + throw error; + } }, }; diff --git a/tests/unit/admin-page-auth.test.ts b/tests/unit/admin-page-auth.test.ts index 61ed4fa13..44b8e4f23 100644 --- a/tests/unit/admin-page-auth.test.ts +++ b/tests/unit/admin-page-auth.test.ts @@ -3,11 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const { findActiveSuspensionMock, getSessionFromHeadersMock, + logAppEventMock, resolveAuthoritativeRecentSessionMock, userFindUniqueMock, } = vi.hoisted(() => ({ findActiveSuspensionMock: vi.fn(), getSessionFromHeadersMock: vi.fn(), + logAppEventMock: vi.fn(), resolveAuthoritativeRecentSessionMock: vi.fn(), userFindUniqueMock: vi.fn(), })); @@ -32,10 +34,15 @@ vi.mock("@/lib/db/prisma", () => ({ }, })); +vi.mock("@/lib/log/app-logger", () => ({ + logAppEvent: logAppEventMock, +})); + describe("admin 页面认证", () => { afterEach(() => { findActiveSuspensionMock.mockReset(); getSessionFromHeadersMock.mockReset(); + logAppEventMock.mockReset(); resolveAuthoritativeRecentSessionMock.mockReset(); userFindUniqueMock.mockReset(); vi.resetModules(); @@ -75,6 +82,51 @@ describe("admin 页面认证", () => { request.headers, { expectedUserId: "admin-1" }, ); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "admin.authorization.denied", + expect.objectContaining({ reason: "recent_auth_required" }), + ); + }); + + it("记录未认证的管理页面访问拒绝", async () => { + getSessionFromHeadersMock.mockResolvedValue(null); + const { requireAdminPage } = await import( + "@/features/admin/server/admin-page-auth" + ); + const request = new Request("https://example.test/admin/users"); + + await expect(requireAdminPage(request)).rejects.toMatchObject({ + location: "/account/sign-in?callbackUrl=%2Fadmin%2Fusers", + status: 303, + }); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "admin.authorization.denied", + expect.objectContaining({ reason: "unauthenticated" }), + ); + }); + + it("记录非管理员的管理页面访问拒绝", async () => { + getSessionFromHeadersMock.mockResolvedValue({ user: { id: "user-1" } }); + userFindUniqueMock.mockResolvedValue({ + id: "user-1", + isAdmin: false, + name: "User", + username: "user", + }); + const { requireAdminPage } = await import( + "@/features/admin/server/admin-page-auth" + ); + + await expect( + requireAdminPage(new Request("https://example.test/admin/users")), + ).rejects.toMatchObject({ status: 404 }); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "admin.authorization.denied", + expect.objectContaining({ reason: "not_admin" }), + ); }); it("允许被暂停的管理员加载只读管理页面", async () => { @@ -130,5 +182,10 @@ describe("admin 页面认证", () => { { requireActive: true }, ), ).rejects.toMatchObject({ status: 403 }); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "admin.authorization.denied", + expect.objectContaining({ reason: "suspended" }), + ); }); }); diff --git a/tests/unit/admin-route-auth.test.ts b/tests/unit/admin-route-auth.test.ts index 8f9eccc90..49b11bcda 100644 --- a/tests/unit/admin-route-auth.test.ts +++ b/tests/unit/admin-route-auth.test.ts @@ -5,6 +5,7 @@ const { findActiveSuspensionMock, getSessionFromHeadersMock, hasActiveOAuthUserGrantMock, + logAppEventMock, resolveAuthoritativeRecentSessionMock, resolveAdminByUserIdMock, verifyAccessTokenJwtMock, @@ -12,6 +13,7 @@ const { findActiveSuspensionMock: vi.fn(), getSessionFromHeadersMock: vi.fn(), hasActiveOAuthUserGrantMock: vi.fn(), + logAppEventMock: vi.fn(), resolveAuthoritativeRecentSessionMock: vi.fn(), resolveAdminByUserIdMock: vi.fn(), verifyAccessTokenJwtMock: vi.fn(), @@ -47,10 +49,15 @@ vi.mock("@/lib/mcp/urls", () => ({ getOAuthTokenVerificationIssuers: () => ["https://life.example/api/auth"], })); +vi.mock("@/lib/log/app-logger", () => ({ + logAppEvent: logAppEventMock, +})); + describe("admin 路由认证", () => { beforeEach(() => { setCloudflareRuntimeEnv(undefined); hasActiveOAuthUserGrantMock.mockResolvedValue(true); + logAppEventMock.mockReset(); resolveAuthoritativeRecentSessionMock.mockResolvedValue({ ok: true, sessionId: "session-1", @@ -66,6 +73,7 @@ describe("admin 路由认证", () => { resolveAdminByUserIdMock.mockReset(); resolveAuthoritativeRecentSessionMock.mockReset(); verifyAccessTokenJwtMock.mockReset(); + logAppEventMock.mockReset(); vi.resetModules(); }); @@ -84,6 +92,15 @@ describe("admin 路由认证", () => { expect((response as Response).status).toBe(401); expect(getSessionFromHeadersMock).not.toHaveBeenCalled(); expect(resolveAdminByUserIdMock).not.toHaveBeenCalled(); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "admin.authorization.denied", + expect.objectContaining({ + event: "admin.authorization.denied", + reason: "unauthenticated", + source: "security", + }), + ); }); it("拒绝管理员用户持有的 Bearer 令牌,管理员 REST 仅接受站内会话", async () => { @@ -134,6 +151,11 @@ describe("admin 路由认证", () => { expect(response).toBeInstanceOf(Response); expect((response as Response).status).toBe(401); expect(resolveAdminByUserIdMock).toHaveBeenCalledWith("user-1"); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "admin.authorization.denied", + expect.objectContaining({ reason: "not_admin" }), + ); }); it("为有效的管理员会话 cookie 返回管理员会话", async () => { @@ -191,6 +213,11 @@ describe("admin 路由认证", () => { reason: "policy hold", }); expect(findActiveSuspensionMock).toHaveBeenCalledWith("admin-1"); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "admin.authorization.denied", + expect.objectContaining({ reason: "suspended" }), + ); }); it("暂停的管理员无法读取 moderation 队列", async () => { @@ -276,6 +303,11 @@ describe("admin 路由认证", () => { request.headers, { expectedUserId: "admin-1" }, ); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "admin.authorization.denied", + expect.objectContaining({ reason: "recent_auth_required" }), + ); }); it("recent-auth 有效时允许敏感管理员变更继续", async () => { @@ -330,6 +362,11 @@ describe("admin 路由认证", () => { "admin-1", ]), }); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "admin.authorization.denied", + expect.objectContaining({ reason: "rate_limited" }), + ); }); it("百分号编码的管理员路径仍使用规范资源预算", async () => { diff --git a/tests/unit/admin-user-routes.test.ts b/tests/unit/admin-user-routes.test.ts index 7db5e4fce..269de184b 100644 --- a/tests/unit/admin-user-routes.test.ts +++ b/tests/unit/admin-user-routes.test.ts @@ -5,10 +5,12 @@ const { listAdminSuspensionsMock, updateAdminUserMock, withAdminApiRouteMock, + logAdminSecurityEventMock, } = vi.hoisted(() => ({ createAdminSuspensionMock: vi.fn(), listAdminSuspensionsMock: vi.fn(), updateAdminUserMock: vi.fn(), + logAdminSecurityEventMock: vi.fn(), withAdminApiRouteMock: vi.fn( async ( _request: Request, @@ -33,6 +35,14 @@ vi.mock("@/lib/api/routes/admin-route-auth", () => ({ withAdminApiRoute: withAdminApiRouteMock, })); +vi.mock("@/lib/audit/write-audit-log", () => ({ + getAuditRequestMetadata: vi.fn(() => ({ requestId: "application-request" })), +})); + +vi.mock("@/lib/audit/security-events", () => ({ + logAdminSecurityEvent: logAdminSecurityEventMock, +})); + function jsonRequest(path: string, body: unknown) { return new Request(`https://example.test${path}`, { body: JSON.stringify(body), @@ -47,6 +57,7 @@ describe("admin 用户路由", () => { listAdminSuspensionsMock.mockReset(); updateAdminUserMock.mockReset(); withAdminApiRouteMock.mockClear(); + logAdminSecurityEventMock.mockReset(); vi.resetModules(); }); @@ -69,7 +80,7 @@ describe("admin 用户路由", () => { "admin-1", "user-1", { name: "User" }, - { channel: "rest", requestId: undefined }, + { channel: "rest", requestId: "application-request" }, ); expect(withAdminApiRouteMock).toHaveBeenCalledWith( expect.any(Request), @@ -97,6 +108,10 @@ describe("admin 用户路由", () => { error: "Admins cannot remove their own admin role", }); expect(response.status).toBe(400); + expect(logAdminSecurityEventMock).toHaveBeenCalledWith( + expect.any(Request), + "self_protection", + ); }); it("将移除最后管理员映射为公开 400 响应", async () => { @@ -140,6 +155,10 @@ describe("admin 用户路由", () => { error: "Admins cannot suspend themselves", }); expect(response.status).toBe(400); + expect(logAdminSecurityEventMock).toHaveBeenCalledWith( + expect.any(Request), + "self_protection", + ); }); it("创建封禁时返回 201 与资源位置", async () => { diff --git a/tests/unit/analytics-engine-runtime-events.test.ts b/tests/unit/analytics-engine-runtime-events.test.ts index 2ad873996..528cdea1c 100644 --- a/tests/unit/analytics-engine-runtime-events.test.ts +++ b/tests/unit/analytics-engine-runtime-events.test.ts @@ -7,6 +7,7 @@ import { recordAndLogMcpResponse } from "@/lib/api/routes/mcp-response-bookkeepi import { writeAuditLog } from "@/lib/audit/write-audit-log"; import { withBetterAuthOAuthDebug } from "@/lib/log/oauth-debug"; import { + resetAnalyticsEngineDiagnosticsForTest, writeOAuthEventAnalytics, writeWorkspaceOverviewStageAnalytics, writeWorkspaceRouteStageAnalytics, @@ -21,6 +22,14 @@ import { putStorageObject, } from "@/lib/storage/r2-object"; +const { emitLogMock } = vi.hoisted(() => ({ + emitLogMock: vi.fn(), +})); + +vi.mock("@/lib/log/app-log-emitter", () => ({ + emitLog: emitLogMock, +})); + function installAnalyticsBinding() { const writeDataPoint = vi.fn(); setCloudflareRuntimeEnv({ ANALYTICS: { writeDataPoint } }); @@ -47,12 +56,51 @@ function validatesSource(value: unknown) { describe("Cloudflare Analytics Engine runtime events", () => { afterEach(() => { setCloudflareRuntimeEnv(undefined); + resetAnalyticsEngineDiagnosticsForTest(); + emitLogMock.mockReset(); clearPublicRuntimeCache(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + it("reports a missing Analytics Engine binding through the existing logger", () => { + runWithCloudflareRuntimeEnv({ NODE_ENV: "production" }, () => { + writeOAuthEventAnalytics({ + event: "binding-check", + ioObservedDurationMs: 0, + }); + }); + + expect(emitLogMock).toHaveBeenCalledWith("[analytics]", "error", { + event: "analytics-engine.binding-missing", + message: "analytics-engine.binding-missing", + source: "analytics-engine", + }); + }); + + it("reports Analytics Engine write failures without raw error details", () => { + const writeDataPoint = vi.fn(() => { + throw new Error("private analytics detail"); + }); + setCloudflareRuntimeEnv({ ANALYTICS: { writeDataPoint } }); + + writeOAuthEventAnalytics({ + event: "write-check", + ioObservedDurationMs: 0, + }); + + expect(emitLogMock).toHaveBeenCalledWith("[analytics]", "error", { + errorName: "Error", + event: "analytics-engine.write-failed", + message: "analytics-engine.write-failed", + source: "analytics-engine", + }); + expect(JSON.stringify(emitLogMock.mock.calls)).not.toContain( + "private analytics detail", + ); + }); + it("writes fixed low-cardinality workspace overview stage datapoints", () => { const writeDataPoint = installAnalyticsBinding(); const stages = [ diff --git a/tests/unit/app-logger.test.ts b/tests/unit/app-logger.test.ts index 4a9f49c76..b3a3fa03c 100644 --- a/tests/unit/app-logger.test.ts +++ b/tests/unit/app-logger.test.ts @@ -151,8 +151,9 @@ describe("应用日志记录器", () => { const [payload] = errorSpy.mock.calls[0] ?? []; expect(JSON.parse(String(payload))).toMatchObject({ error: { - code: "P2010", + code: "42501", name: "PrismaClientKnownRequestError", + prismaCode: "P2010", }, }); expect(String(payload)).not.toContain("permission denied"); @@ -181,4 +182,73 @@ describe("应用日志记录器", () => { }, }); }); + + it("生产环境保留 P2039 wrapper 并优先记录嵌套 driver code", () => { + vi.stubEnv("NODE_ENV", "production"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const driverError = Object.assign(new Error("private database detail"), { + originalCode: "42P01", + name: "error", + }); + const prismaError = Object.assign(new Error("private wrapper detail"), { + cause: driverError, + code: "P2039", + name: "PrismaClientKnownRequestError", + }); + + logRouteFailure("Failed to load section", 500, prismaError); + + const [payload] = errorSpy.mock.calls[0] ?? []; + expect(JSON.parse(String(payload))).toMatchObject({ + error: { + code: "42P01", + name: "PrismaClientKnownRequestError", + prismaCode: "P2039", + }, + }); + expect(String(payload)).not.toContain("private database detail"); + expect(String(payload)).not.toContain("private wrapper detail"); + }); + + it("生产环境日志序列化不因 BigInt 或循环引用失败", () => { + vi.stubEnv("NODE_ENV", "production"); + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + const context: Record = { count: 1n }; + context.self = context; + + expect(() => + logAppEvent("info", "safe.serialization", context), + ).not.toThrow(); + + const [payload] = infoSpy.mock.calls[0] ?? []; + expect(JSON.parse(String(payload))).toMatchObject({ + self: { + count: "[BigInt]", + self: "[Circular]", + }, + }); + }); + + it("生产环境不执行异常 toJSON 且不会泄露其内容", () => { + vi.stubEnv("NODE_ENV", "production"); + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + const secret = "private-to-json-value"; + const payload = { + toJSON() { + throw new Error(secret); + }, + }; + + expect(() => + logAppEvent("info", "safe.serialization", { payload }), + ).not.toThrow(); + + const [serialized] = infoSpy.mock.calls[0] ?? []; + expect(JSON.parse(String(serialized))).toMatchObject({ + message: "safe.serialization", + payload: { toJSON: "[Function]" }, + }); + expect(String(serialized)).not.toContain(secret); + }); }); diff --git a/tests/unit/audit-log-queue.test.ts b/tests/unit/audit-log-queue.test.ts index cd31dc5ec..61f315efb 100644 --- a/tests/unit/audit-log-queue.test.ts +++ b/tests/unit/audit-log-queue.test.ts @@ -41,14 +41,17 @@ describe("audit log write queue", () => { ).toBeNull(); }); - it("acks valid writes and permanently discards malformed messages", async () => { + it("acks valid writes and retries malformed messages for the DLQ", async () => { writeAuditLogMock.mockResolvedValue(undefined); const valid = queueMessage({ auditId: "audit-1", type: "audit-log.write.v1", params: { action: "account_sign_in", subjectUserId: "user-1" }, }); - const invalid = queueMessage({ type: "unknown" }); + const invalid = queueMessage({ + type: "unknown", + secret: "must-not-be-logged", + }); await handleAuditLogWriteBatch({ messages: [valid, invalid] }); @@ -59,7 +62,21 @@ describe("audit log write queue", () => { }); expect(valid.ack).toHaveBeenCalledOnce(); expect(valid.retry).not.toHaveBeenCalled(); - expect(invalid.ack).toHaveBeenCalledOnce(); + expect(invalid.ack).not.toHaveBeenCalled(); + expect(invalid.retry).toHaveBeenCalledOnce(); + expect(logAppEventMock).toHaveBeenCalledWith( + "error", + "audit-log-write.invalid-message", + { + event: "audit-log-write.invalid-message", + phase: "consumer", + reason: "invalid_envelope", + source: "audit", + }, + ); + expect(JSON.stringify(logAppEventMock.mock.calls)).not.toContain( + "must-not-be-logged", + ); }); it("retries transient database failures without acknowledging them", async () => { @@ -81,6 +98,8 @@ describe("audit log write queue", () => { expect.objectContaining({ action: "comment_create", event: "audit-log-write.retry", + phase: "consumer", + reason: "database_write_failed", }), error, ); diff --git a/tests/unit/audit-request-metadata.test.ts b/tests/unit/audit-request-metadata.test.ts index 7be5945b5..b4ad5fd0d 100644 --- a/tests/unit/audit-request-metadata.test.ts +++ b/tests/unit/audit-request-metadata.test.ts @@ -1,37 +1,47 @@ import { describe, expect, it } from "vitest"; import { getAuditRequestMetadata } from "@/lib/audit/request-metadata"; +import { setApiRequestObservabilityContext } from "@/lib/log/api-observability-context"; describe("getAuditRequestMetadata", () => { - it("优先使用 Cloudflare 已验证的客户端地址与请求 ID", () => { + it("只使用 Cloudflare 已验证的客户端地址并生成应用请求 ID", () => { const request = new Request("https://example.test", { headers: { "user-agent": "vitest-agent", "cf-connecting-ip": "192.0.2.30", - "cf-ray": "ray-1", + "cf-ray": "edge-ray-1", "x-forwarded-for": "203.0.113.10", "x-real-ip": "198.51.100.20", }, }); - expect(getAuditRequestMetadata(request)).toEqual({ + expect(getAuditRequestMetadata(request)).toMatchObject({ ipAddress: "192.0.2.30", - requestId: "ray-1", userAgent: "vitest-agent", }); + expect(getAuditRequestMetadata(request).requestId).toMatch( + /^[0-9a-f-]{36}$/i, + ); }); - it("不信任调用方可伪造的代理地址并省略缺失标头", () => { + it("不信任调用方可伪造的代理地址或请求 ID", () => { const request = new Request("https://example.test", { headers: { "x-real-ip": "198.51.100.20", + "x-request-id": "client-controlled-id", + "cf-ray": "client-controlled-ray", }, }); - expect(getAuditRequestMetadata(request)).toEqual({ + expect(getAuditRequestMetadata(request)).toMatchObject({ ipAddress: undefined, - requestId: undefined, userAgent: undefined, }); + expect(getAuditRequestMetadata(request).requestId).not.toBe( + "client-controlled-id", + ); + expect(getAuditRequestMetadata(request).requestId).not.toBe( + "client-controlled-ray", + ); }); it("忽略 forwarded 地址并限制可变请求头长度", () => { @@ -39,14 +49,38 @@ describe("getAuditRequestMetadata", () => { headers: { "user-agent": "a".repeat(600), "x-forwarded-for": "203.0.113.10, 198.51.100.20", - "x-request-id": "r".repeat(200), + "x-request-id": "client-controlled-id", }, }); - expect(getAuditRequestMetadata(request)).toEqual({ + expect(getAuditRequestMetadata(request)).toMatchObject({ ipAddress: undefined, - requestId: "r".repeat(128), userAgent: "a".repeat(512), }); + expect(getAuditRequestMetadata(request).requestId).not.toBe( + "client-controlled-id", + ); + }); + + it("优先使用应用观测上下文中的可信 request ID", () => { + const request = new Request("https://example.test", { + headers: { "x-request-id": "client-controlled-id" }, + }); + setApiRequestObservabilityContext(request, { + requestId: "application-request-id", + startMs: 1, + }); + + expect(getAuditRequestMetadata(request).requestId).toBe( + "application-request-id", + ); + }); + + it("允许页面动作传入 hook 生成的可信 request ID", () => { + const request = new Request("https://example.test"); + + expect( + getAuditRequestMetadata(request, "locals-request-id").requestId, + ).toBe("locals-request-id"); }); }); diff --git a/tests/unit/audit-security-events.test.ts b/tests/unit/audit-security-events.test.ts new file mode 100644 index 000000000..3af022179 --- /dev/null +++ b/tests/unit/audit-security-events.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { logAppEventMock } = vi.hoisted(() => ({ + logAppEventMock: vi.fn(), +})); + +vi.mock("@/lib/log/app-logger", () => ({ + logAppEvent: logAppEventMock, +})); + +import { logAdminSecurityEvent } from "@/lib/audit/security-events"; + +describe("admin security events", () => { + beforeEach(() => { + logAppEventMock.mockReset(); + }); + + it("uses a fixed route class and excludes request secrets and metadata", () => { + const request = new Request( + "https://example.test/api/admin/users/user@example.com?token=secret-token", + { + headers: { + cookie: "better-auth.session_token=secret-cookie", + "cf-connecting-ip": "192.0.2.5", + "user-agent": "private-user-agent", + }, + method: "POST", + }, + ); + + logAdminSecurityEvent(request, "not_admin"); + + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "admin.authorization.denied", + { + event: "admin.authorization.denied", + method: "POST", + phase: "authorization", + reason: "not_admin", + requestId: expect.any(String), + route: "api_admin", + source: "security", + }, + ); + const serialized = JSON.stringify(logAppEventMock.mock.calls); + expect(serialized).not.toContain("secret-cookie"); + expect(serialized).not.toContain("192.0.2.5"); + expect(serialized).not.toContain("private-user-agent"); + expect(serialized).not.toContain("secret-token"); + expect(serialized).not.toContain("user@example.com"); + }); +}); diff --git a/tests/unit/audit-write-log.test.ts b/tests/unit/audit-write-log.test.ts index 5d2e433f7..a32594de3 100644 --- a/tests/unit/audit-write-log.test.ts +++ b/tests/unit/audit-write-log.test.ts @@ -60,6 +60,43 @@ describe("fireAuditLog", () => { type: "audit-log.write.v1", }); expect(prismaMock.auditLog.createMany).not.toHaveBeenCalled(); + expect(logAppEventMock).toHaveBeenCalledWith( + "info", + "audit-log.enqueue.success", + { + action: "comment_create", + event: "audit-log.enqueue.success", + outcome: "success", + phase: "enqueue", + source: "audit", + targetType: "comment", + }, + ); + }); + + it("records queue enqueue failures separately without changing route semantics", async () => { + const enqueueError = new Error("queue unavailable"); + getAuditQueueMock.mockReturnValue({ + send: vi.fn().mockRejectedValue(enqueueError), + }); + const { fireAuditLog } = await import("@/lib/audit/write-audit-log"); + + await expect(fireAuditLog(auditParams)).resolves.toBeUndefined(); + + expect(logAppEventMock).toHaveBeenCalledWith( + "error", + "audit-log.enqueue.failure", + { + action: "comment_create", + event: "audit-log.enqueue.failure", + outcome: "failure", + phase: "enqueue", + source: "audit", + targetType: "comment", + }, + enqueueError, + ); + expect(prismaMock.auditLog.createMany).not.toHaveBeenCalled(); }); it("treats a replayed producer ID as an idempotent audit write", async () => { @@ -115,12 +152,15 @@ describe("fireAuditLog", () => { expect(logAppEventMock).toHaveBeenCalledWith( "error", - "Audit log write failed", - { + "audit-log.write.failure", + expect.objectContaining({ + event: "audit-log.write.failure", + outcome: "failure", + phase: "database", action: "comment_create", source: "audit", targetType: "comment", - }, + }), writeError, ); }); diff --git a/tests/unit/calendar-export-cache.test.ts b/tests/unit/calendar-export-cache.test.ts index c62422c99..06065b133 100644 --- a/tests/unit/calendar-export-cache.test.ts +++ b/tests/unit/calendar-export-cache.test.ts @@ -82,14 +82,18 @@ describe("用户 iCal 导出缓存", () => { expect(JSON.stringify(writeDataPoint.mock.calls)).not.toContain("user-1"); }); - it("stale 导出立即返回且不通过 defer 后台重建", async () => { + it("stale 导出立即返回且通过 defer 跟踪 enqueue Promise", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-07T00:00:00.000Z")); const namespace = kvNamespace(); setCloudflareRuntimeEnv({ CALENDAR_EXPORTS: namespace }); const enqueued: unknown[] = []; - setCalendarExportRebuildSenderForTest(async (message) => { + let finishEnqueue: (() => void) | undefined; + setCalendarExportRebuildSenderForTest((message) => { enqueued.push(message); + return new Promise((resolve) => { + finishEnqueue = resolve; + }); }); const buildExport = vi.fn().mockResolvedValue(calendarExport); @@ -102,10 +106,12 @@ describe("用户 iCal 导出缓存", () => { expect(stale.status).toBe("stale"); expect(stale.calendar?.text).toBe(calendarExport.text); - expect(tasks).toHaveLength(0); + expect(tasks).toHaveLength(1); expect(buildExport).toHaveBeenCalledTimes(1); - await vi.waitFor(() => expect(enqueued).toHaveLength(1)); + expect(enqueued).toHaveLength(1); expect(enqueued[0]).toEqual({ type: "user", userId: "user-1" }); + finishEnqueue?.(); + await expect(tasks[0]).resolves.toBeUndefined(); // Without defer, stale still serves immediately and enqueues rebuild — // never rebuilds ICS on the request path. @@ -144,6 +150,28 @@ describe("用户 iCal 导出缓存", () => { await expect(tasks[0]).resolves.toBeUndefined(); }); + it("refresh 失败时记录 refresh_error 且保留失败结果", async () => { + const writeDataPoint = vi.fn(); + setCloudflareRuntimeEnv({ ANALYTICS: { writeDataPoint } }); + const refreshFailure = new Error("private refresh detail"); + + await expect( + getCachedUserCalendarExport( + "user-1", + vi.fn().mockRejectedValue(refreshFailure), + ), + ).rejects.toBe(refreshFailure); + + expect(writeDataPoint).toHaveBeenCalledWith({ + indexes: ["cache:calendar:user"], + blobs: ["calendar_feed_cache", "user", "refresh_error"], + doubles: [USER_CALENDAR_EXPORT_FRESH_TTL_MS, 0], + }); + expect(JSON.stringify(writeDataPoint.mock.calls)).not.toContain( + "private refresh detail", + ); + }); + it("合并同一 isolate 内的并发 miss", async () => { const namespace = kvNamespace(); setCloudflareRuntimeEnv({ CALENDAR_EXPORTS: namespace }); @@ -186,6 +214,74 @@ describe("用户 iCal 导出缓存", () => { expect(enqueued[0]).toEqual({ type: "user", userId: "user-1" }); }); + it("stale enqueue 失败时仍立即返回并暴露失败指标", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-07T00:00:00.000Z")); + const namespace = kvNamespace(); + const writeDataPoint = vi.fn(); + setCloudflareRuntimeEnv({ + ANALYTICS: { writeDataPoint }, + CALENDAR_EXPORTS: namespace, + }); + setCalendarExportRebuildSenderForTest(() => + Promise.reject(new Error("private user id")), + ); + const buildExport = vi.fn().mockResolvedValue(calendarExport); + + await getCachedUserCalendarExport("user-1", buildExport); + vi.advanceTimersByTime(USER_CALENDAR_EXPORT_FRESH_TTL_MS + 1); + const tasks: Promise[] = []; + const stale = await getCachedUserCalendarExport("user-1", buildExport, { + defer: (promise) => tasks.push(promise), + }); + + expect(stale.status).toBe("stale"); + expect(stale.calendar?.text).toBe(calendarExport.text); + expect(tasks).toHaveLength(1); + await expect(tasks[0]).rejects.toThrow("private user id"); + expect(writeDataPoint).toHaveBeenCalledWith({ + indexes: ["calendar_export_rebuild_enqueue_error"], + blobs: ["calendar_export_rebuild", "enqueue_error"], + doubles: [1], + }); + expect(JSON.stringify(writeDataPoint.mock.calls)).not.toContain( + "private user id", + ); + }); + + it("KV store 失败时不记录 refresh_success", async () => { + const writeDataPoint = vi.fn(); + const namespace = { + get: vi.fn().mockResolvedValue(null), + put: vi.fn().mockRejectedValue(new Error("private storage detail")), + }; + setCloudflareRuntimeEnv({ + ANALYTICS: { writeDataPoint }, + CALENDAR_EXPORTS: namespace, + }); + + await expect( + getCachedUserCalendarExport( + "user-1", + vi.fn().mockResolvedValue(calendarExport), + ), + ).rejects.toThrow("Calendar export cache persistence failed"); + + expect(writeDataPoint).toHaveBeenCalledWith({ + indexes: ["cache:calendar:user"], + blobs: ["calendar_feed_cache", "user", "store_error"], + doubles: [USER_CALENDAR_EXPORT_FRESH_TTL_MS, 1], + }); + expect(writeDataPoint).not.toHaveBeenCalledWith({ + indexes: ["cache:calendar:user"], + blobs: ["calendar_feed_cache", "user", "refresh_success"], + doubles: [USER_CALENDAR_EXPORT_FRESH_TTL_MS, 1], + }); + expect(JSON.stringify(writeDataPoint.mock.calls)).not.toContain( + "private storage detail", + ); + }); + it("KV 不可用时仍使用 isolate 内存缓存", async () => { const buildExport = vi.fn().mockResolvedValue(calendarExport); diff --git a/tests/unit/calendar-export-queue.test.ts b/tests/unit/calendar-export-queue.test.ts index 185e3f459..c867f8b6a 100644 --- a/tests/unit/calendar-export-queue.test.ts +++ b/tests/unit/calendar-export-queue.test.ts @@ -3,6 +3,7 @@ import { enqueueSectionCalendarExportRebuild, enqueueUserCalendarExportRebuild, parseCalendarExportRebuildMessage, + scheduleUserCalendarExportRebuild, setCalendarExportRebuildSenderForTest, } from "@/features/calendar/server/calendar-export-queue"; import { setCloudflareRuntimeEnv } from "@/lib/adapters/cloudflare-runtime"; @@ -59,6 +60,62 @@ describe("calendar export rebuild queue helpers", () => { expect(send).toHaveBeenCalledWith({ type: "user", userId: "user-2" }); }); + it("exposes queue send failures without message identifiers", async () => { + const writeDataPoint = vi.fn(); + const send = vi + .fn() + .mockRejectedValue(new Error("private queue transport detail")); + setCloudflareRuntimeEnv({ + ANALYTICS: { writeDataPoint }, + CALENDAR_EXPORT_REBUILD: { send }, + }); + + await expect( + enqueueUserCalendarExportRebuild("user-secret"), + ).rejects.toThrow("private queue transport detail"); + + expect(writeDataPoint).toHaveBeenCalledWith({ + indexes: ["calendar_export_rebuild_enqueue_error"], + blobs: ["calendar_export_rebuild", "enqueue_error"], + doubles: [1], + }); + expect(JSON.stringify(writeDataPoint.mock.calls)).not.toContain( + "user-secret", + ); + expect(JSON.stringify(writeDataPoint.mock.calls)).not.toContain( + "private queue transport detail", + ); + }); + + it("reports a missing queue binding instead of rebuilding in process", async () => { + const writeDataPoint = vi.fn(); + setCloudflareRuntimeEnv({ ANALYTICS: { writeDataPoint } }); + + await expect(enqueueSectionCalendarExportRebuild(7)).rejects.toThrow( + "CALENDAR_EXPORT_REBUILD binding is required", + ); + + expect(writeDataPoint).toHaveBeenCalledWith({ + indexes: ["calendar_export_rebuild_enqueue_error"], + blobs: ["calendar_export_rebuild", "enqueue_error"], + doubles: [1], + }); + }); + + it("tracks scheduled enqueue work with the caller defer", async () => { + const send = vi.fn().mockResolvedValue(undefined); + setCloudflareRuntimeEnv({ CALENDAR_EXPORT_REBUILD: { send } }); + const tasks: Promise[] = []; + + scheduleUserCalendarExportRebuild("user-2", (promise) => { + tasks.push(promise); + }); + + expect(tasks).toHaveLength(1); + await expect(tasks[0]).resolves.toBeUndefined(); + expect(send).toHaveBeenCalledWith({ type: "user", userId: "user-2" }); + }); + it("ignores empty user ids and non-positive section ids", async () => { const enqueued: unknown[] = []; setCalendarExportRebuildSenderForTest(async (message) => { diff --git a/tests/unit/calendar-export-rebuild.test.ts b/tests/unit/calendar-export-rebuild.test.ts index fb84ce217..633feae03 100644 --- a/tests/unit/calendar-export-rebuild.test.ts +++ b/tests/unit/calendar-export-rebuild.test.ts @@ -106,7 +106,7 @@ describe("calendar export rebuild fan-out", () => { expect(storeBuiltUserCalendarExportMock).toHaveBeenCalledTimes(2); }); - it("acks valid messages after a successful batch and drops invalid ones", async () => { + it("acks valid messages and sends invalid envelopes toward the DLQ", async () => { findManyMock.mockResolvedValue([]); getUserCalendarRecordMock.mockResolvedValue(null); @@ -123,10 +123,21 @@ describe("calendar export rebuild fan-out", () => { await handleCalendarExportRebuildBatch({ messages: [valid, invalid] }); - expect(invalid.ack).toHaveBeenCalledOnce(); - expect(invalid.retry).not.toHaveBeenCalled(); + expect(invalid.ack).not.toHaveBeenCalled(); + expect(invalid.retry).toHaveBeenCalledOnce(); expect(valid.ack).toHaveBeenCalledOnce(); expect(valid.retry).not.toHaveBeenCalled(); + expect(logAppEventMock).toHaveBeenCalledWith( + "error", + "calendar-export-rebuild.invalid-message", + { + event: "calendar-export-rebuild.invalid-message", + phase: "consumer", + reason: "invalid_envelope", + source: "calendar-export-rebuild", + }, + ); + expect(JSON.stringify(logAppEventMock.mock.calls)).not.toContain("nope"); }); it("logs safe batch context before retrying a failed batch", async () => { diff --git a/tests/unit/cloudflare-runtime-tracing.test.ts b/tests/unit/cloudflare-runtime-tracing.test.ts index 5d5ec5825..a9a4ba956 100644 --- a/tests/unit/cloudflare-runtime-tracing.test.ts +++ b/tests/unit/cloudflare-runtime-tracing.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getCloudflareNamedCache, + getCloudflareRequestContext, getCloudflareRuntimeTaskScheduler, registerCloudflareRuntimeCleanup, runCloudflareTraceSpan, runWithCloudflareRuntimeEnv, + setCloudflareRequestContext, } from "@/lib/adapters/cloudflare-runtime"; describe("Cloudflare runtime tracing", () => { @@ -161,8 +163,18 @@ describe("Cloudflare runtime tracing", () => { await runWithCloudflareRuntimeEnv( { OUTER: "present" }, async () => { + setCloudflareRequestContext({ + method: "PATCH", + requestId: "11111111-1111-4111-8111-111111111111", + route: "/api/workspace/subscriptions", + }); await runWithCloudflareRuntimeEnv(undefined, async () => { expect(getCloudflareRuntimeTaskScheduler()).toBeTypeOf("function"); + expect(getCloudflareRequestContext()).toEqual({ + method: "PATCH", + requestId: "11111111-1111-4111-8111-111111111111", + route: "/api/workspace/subscriptions", + }); getCloudflareRuntimeTaskScheduler()?.(Promise.resolve()); runCloudflareTraceSpan("nested", {}, () => undefined); }); diff --git a/tests/unit/comment-batch-delete-route.test.ts b/tests/unit/comment-batch-delete-route.test.ts index a9605d574..233e19af1 100644 --- a/tests/unit/comment-batch-delete-route.test.ts +++ b/tests/unit/comment-batch-delete-route.test.ts @@ -69,6 +69,7 @@ describe("deleteCommentBatchRoute", () => { auditMetadata: { channel: "rest", ipAddress: undefined, + requestId: expect.any(String), subjectUserId: "user-1", userAgent: undefined, userId: "user-1", @@ -80,6 +81,7 @@ describe("deleteCommentBatchRoute", () => { auditMetadata: { channel: "rest", ipAddress: undefined, + requestId: expect.any(String), subjectUserId: "user-1", userAgent: undefined, userId: "user-1", diff --git a/tests/unit/comment-read-model-pagination.test.ts b/tests/unit/comment-read-model-pagination.test.ts index bc5bed28d..9a58dfbc4 100644 --- a/tests/unit/comment-read-model-pagination.test.ts +++ b/tests/unit/comment-read-model-pagination.test.ts @@ -483,7 +483,7 @@ describe("loadCommentThread pagination", () => { "warn", "comment.reaction-summaries.failed", { - code: "P2010", + code: "42501", event: "comment.reaction-summaries.failed", source: "comments", }, diff --git a/tests/unit/graphql-homework-mutations.test.ts b/tests/unit/graphql-homework-mutations.test.ts index 9dd733035..3fb4fa728 100644 --- a/tests/unit/graphql-homework-mutations.test.ts +++ b/tests/unit/graphql-homework-mutations.test.ts @@ -92,7 +92,7 @@ describe("GraphQL homework mutation resolvers", () => { }, { channel: "graphql", - requestId: undefined, + requestId: expect.any(String), subjectUserId: "user-1", userId: "user-1", }, @@ -128,7 +128,7 @@ describe("GraphQL homework mutation resolvers", () => { expect(updateHomeworkMock).toHaveBeenCalledWith({ audit: { channel: "graphql", - requestId: undefined, + requestId: expect.any(String), subjectUserId: "user-1", userId: "user-1", }, @@ -173,7 +173,7 @@ describe("GraphQL homework mutation resolvers", () => { expect(deleteHomeworkMock).toHaveBeenCalledWith({ audit: { channel: "graphql", - requestId: undefined, + requestId: expect.any(String), subjectUserId: "user-1", userId: "user-1", }, diff --git a/tests/unit/graphql-remaining-mutations.test.ts b/tests/unit/graphql-remaining-mutations.test.ts index b3df9d5a2..5ce29595d 100644 --- a/tests/unit/graphql-remaining-mutations.test.ts +++ b/tests/unit/graphql-remaining-mutations.test.ts @@ -124,7 +124,7 @@ describe("remaining ordinary GraphQL mutations", () => { auditMetadata: { channel: "graphql", ipAddress: undefined, - requestId: undefined, + requestId: expect.any(String), source: "graphql", subjectUserId: "user-1", userAgent: "graphql-test", @@ -231,7 +231,7 @@ describe("remaining ordinary GraphQL mutations", () => { audit: { channel: "graphql", ipAddress: undefined, - requestId: undefined, + requestId: expect.any(String), source: "graphql", subjectUserId: "user-1", userAgent: "graphql-test", diff --git a/tests/unit/graphql-server.test.ts b/tests/unit/graphql-server.test.ts index 7e63fb00f..df8b81662 100644 --- a/tests/unit/graphql-server.test.ts +++ b/tests/unit/graphql-server.test.ts @@ -368,7 +368,7 @@ describe("GraphQL HTTP boundary", () => { auditMetadata: { channel: "graphql", ipAddress: "192.0.2.40", - requestId: "request-1", + requestId: expect.any(String), source: "graphql", subjectUserId: "session-user", userAgent: "graphql-unit-agent", diff --git a/tests/unit/page-request-lifecycle.test.ts b/tests/unit/page-request-lifecycle.test.ts index e6ab91e24..7b937a099 100644 --- a/tests/unit/page-request-lifecycle.test.ts +++ b/tests/unit/page-request-lifecycle.test.ts @@ -8,6 +8,7 @@ vi.mock("@/app-env", () => ({ })); import { handle, handleError } from "@/hooks.server"; +import { INTERNAL_REQUEST_ID_HEADER } from "@/lib/log/worker-entrypoint-observability"; function handleInput( resolve: Parameters[0]["resolve"], @@ -141,6 +142,24 @@ describe("SvelteKit page request lifecycle", () => { ); }); + it("uses only the internal worker correlation header", async () => { + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const requestId = "11111111-1111-4111-8111-111111111111"; + + const response = await handle( + handleInput(async () => Response.json({ ok: true }), { + headers: { + [INTERNAL_REQUEST_ID_HEADER]: requestId, + "x-request-id": "client-request-id", + }, + }), + ); + + expect(response.headers.get("x-request-id")).toBe(requestId); + expect(JSON.stringify(info.mock.calls)).toContain(requestId); + expect(JSON.stringify(info.mock.calls)).not.toContain("client-request-id"); + }); + it("records thrown redirects exactly once before preserving them", async () => { const info = vi.spyOn(console, "info").mockImplementation(() => {}); diff --git a/tests/unit/section-page-data-selection.test.ts b/tests/unit/section-page-data-selection.test.ts index a277f872a..0ab78ab38 100644 --- a/tests/unit/section-page-data-selection.test.ts +++ b/tests/unit/section-page-data-selection.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { runWithCloudflareRuntimeEnv } from "@/lib/adapters/cloudflare-runtime"; +import { resetPublicRuntimeCacheForTest } from "@/lib/public-runtime-cache"; const { sectionFindUnique } = vi.hoisted(() => ({ sectionFindUnique: vi.fn(), @@ -11,6 +12,10 @@ vi.mock("@/lib/db/prisma", () => ({ }), })); +vi.mock("@/lib/catalog-detail-cache-revision", () => ({ + getCatalogDetailCacheRevision: vi.fn(async () => "test-revision"), +})); + function prismaThenable(value: T): PromiseLike { return { // biome-ignore lint/suspicious/noThenProperty: Prisma queries intentionally return thenables. @@ -57,14 +62,32 @@ function sectionRecord() { }; } +function sectionCoreRecord() { + const { description: _description, ...core } = sectionRecord(); + return core; +} + describe("section page data selection", () => { beforeEach(() => { vi.clearAllMocks(); - sectionFindUnique.mockResolvedValue(sectionRecord()); + resetPublicRuntimeCacheForTest(); + sectionFindUnique.mockImplementation((query) => + Promise.resolve( + query.select.course + ? sectionCoreRecord() + : { description: sectionRecord().description, retiredAt: null }, + ), + ); }); - it("loads the stream page in one Prisma call with bounded related rows and description", async () => { - sectionFindUnique.mockReturnValue(prismaThenable(sectionRecord())); + it("caches static page core while loading the editable description separately", async () => { + sectionFindUnique.mockImplementation((query) => + prismaThenable( + query.select.course + ? sectionCoreRecord() + : { description: sectionRecord().description, retiredAt: null }, + ), + ); const { getSectionPage } = await import( "@/features/section-detail/server/section-page-data" ); @@ -107,37 +130,54 @@ describe("section page data selection", () => { }, ); - expect(sectionFindUnique).toHaveBeenCalledOnce(); - const select = sectionFindUnique.mock.calls[0]?.[0]?.select; - expect(select.description).toEqual({ - select: expect.objectContaining({ content: true, id: true }), + expect(sectionFindUnique).toHaveBeenCalledTimes(2); + const coreSelect = sectionFindUnique.mock.calls.find( + ([query]) => query?.select?.course, + )?.[0]?.select; + const descriptionSelect = sectionFindUnique.mock.calls.find( + ([query]) => query?.select?.description && !query?.select?.course, + )?.[0]?.select; + expect(coreSelect.description).toBe(false); + expect(descriptionSelect).toEqual({ + description: { + select: expect.objectContaining({ content: true, id: true }), + }, + retiredAt: true, }); - expect(select.course.select.sections).toMatchObject({ + expect(coreSelect.course.select.sections).toMatchObject({ take: 20, where: { jwId: { not: 30 }, retiredAt: null }, }); - expect(select.course.select._count).toEqual({ + expect(coreSelect.course.select._count).toEqual({ select: { sections: { where: { jwId: { not: 30 }, retiredAt: null }, }, }, }); - expect(select).not.toHaveProperty("_count"); - expect(select).not.toHaveProperty("dateTimePlaceText"); - expect(select.teachers.select.department).toBeDefined(); - expect(spans).toEqual([ - { - attributes: { "catalog.detail.kind": "section" }, - name: "catalog.detail.section.query", - }, - { - attributes: { "catalog.detail.kind": "section" }, - name: "catalog.detail.section.transform", - }, - ]); + expect(coreSelect).not.toHaveProperty("_count"); + expect(coreSelect).not.toHaveProperty("dateTimePlaceText"); + expect(coreSelect.teachers.select.department).toBeDefined(); + expect(spans).toEqual( + expect.arrayContaining([ + { + attributes: { "catalog.detail.kind": "section" }, + name: "catalog.detail.section.mutable.query", + }, + { + attributes: { "catalog.detail.kind": "section" }, + name: "catalog.detail.section.query", + }, + { + attributes: { "catalog.detail.kind": "section" }, + name: "catalog.detail.section.transform", + }, + ]), + ); expect(JSON.stringify(spans)).not.toContain("30"); - expect(nativePromiseSpans).toEqual(["catalog.detail.section.query"]); + expect(nativePromiseSpans).toEqual( + expect.arrayContaining(["catalog.detail.section.query"]), + ); expect(result).toMatchObject({ description: { content: "Section description", @@ -194,6 +234,7 @@ describe("section page data selection", () => { "teachers": [], }, ], + "retiredAt": null, "scheduleCount": 0, "schedules": [], "teachers": [], @@ -218,4 +259,62 @@ describe("section page data selection", () => { localizedNameSymbol, ); }); + + it("keeps edited descriptions fresh when the static page core is cached", async () => { + let descriptionContent = "First description"; + sectionFindUnique.mockImplementation((query) => { + if (query.select.course) { + return Promise.resolve(sectionCoreRecord()); + } + return Promise.resolve({ + description: { + ...sectionRecord().description, + content: descriptionContent, + }, + retiredAt: null, + }); + }); + const { getSectionPage } = await import( + "@/features/section-detail/server/section-page-data" + ); + + const first = await getSectionPage(30, "zh-cn"); + descriptionContent = "Edited description"; + const second = await getSectionPage(30, "zh-cn"); + + expect(first?.description.content).toBe("First description"); + expect(second?.description.content).toBe("Edited description"); + expect( + sectionFindUnique.mock.calls.filter(([query]) => query.select.course), + ).toHaveLength(1); + expect( + sectionFindUnique.mock.calls.filter( + ([query]) => query.select.description && !query.select.course, + ), + ).toHaveLength(2); + }); + + it("keeps section lifecycle state fresh when the static page core is cached", async () => { + let retiredAt: Date | null = null; + sectionFindUnique.mockImplementation((query) => + Promise.resolve( + query.select.course + ? sectionCoreRecord() + : { description: sectionRecord().description, retiredAt }, + ), + ); + const { getSectionPage } = await import( + "@/features/section-detail/server/section-page-data" + ); + + const active = await getSectionPage(30, "zh-cn"); + retiredAt = new Date("2026-08-26T00:00:00.000Z"); + const retired = await getSectionPage(30, "zh-cn"); + + expect( + sectionFindUnique.mock.calls.filter(([query]) => query.select.course), + ).toHaveLength(1); + expect(active?.section.retiredAt).toBeNull(); + expect(retired?.section.retiredAt).toBe("2026-08-26T00:00:00.000Z"); + }); }); diff --git a/tests/unit/settings-security-action.test.ts b/tests/unit/settings-security-action.test.ts index 240eba9bc..7a4c73daa 100644 --- a/tests/unit/settings-security-action.test.ts +++ b/tests/unit/settings-security-action.test.ts @@ -57,6 +57,7 @@ describe("settings calendar token rotation action", () => { rotateSettingsCalendarTokenAction({ locale: "en-us", request: inputRequest, + requestId: "request-1", url: new URL(inputRequest.url), }), ).rejects.toMatchObject({ @@ -82,6 +83,7 @@ describe("settings calendar token rotation action", () => { const result = await rotateSettingsCalendarTokenAction({ locale: "en-us", request: inputRequest, + requestId: "request-1", url: new URL(inputRequest.url), }); expect(result).toMatchObject({ diff --git a/tests/unit/worker-entrypoint-observability.test.ts b/tests/unit/worker-entrypoint-observability.test.ts index a2b15c217..af7d85935 100644 --- a/tests/unit/worker-entrypoint-observability.test.ts +++ b/tests/unit/worker-entrypoint-observability.test.ts @@ -9,11 +9,19 @@ vi.mock("@/lib/log/app-logger", () => ({ })); import { + getTrustedRequestId, + INTERNAL_REQUEST_ID_HEADER, + logScheduledTaskError, logScheduledTaskFinish, logUnknownScheduledTask, + logWorkerFetchError, + logWorkerQueueError, + logWorkerQueueFinish, normalizePublicSsrObservedRoute, observedEdgeResponse, resolveEdgeCacheOutcome, + resolveWorkerQueue, + setTrustedRequestIdHeader, } from "@/lib/log/worker-entrypoint-observability"; describe("worker entrypoint observability", () => { @@ -90,6 +98,113 @@ describe("worker entrypoint observability", () => { expect(JSON.stringify(logAppEventMock.mock.calls)).not.toContain("token"); }); + it("only accepts the internal UUID header and strips external request ids", () => { + const request = new Request("https://example.test/api/health", { + headers: { + [INTERNAL_REQUEST_ID_HEADER]: "external-value", + "x-request-id": "client-value", + }, + }); + expect(getTrustedRequestId(request)).toBeUndefined(); + + const headers = new Headers(request.headers); + setTrustedRequestIdHeader(headers, "11111111-1111-4111-8111-111111111111"); + const trusted = new Request(request, { headers }); + expect(getTrustedRequestId(trusted)).toBe( + "11111111-1111-4111-8111-111111111111", + ); + expect(trusted.headers.get("x-request-id")).toBeNull(); + }); + + it("classifies only the two configured queues", () => { + expect(resolveWorkerQueue("life-ustc-audit-log-write")).toBe("audit"); + expect(resolveWorkerQueue("life-ustc-calendar-export-rebuild")).toBe( + "calendar", + ); + expect(resolveWorkerQueue("unexpected-queue")).toBe("unknown"); + }); + + it("records queue and scheduled outcomes", () => { + logWorkerQueueFinish({ + ioObservedDurationMs: 34, + messageCount: 2, + queue: "audit", + }); + logScheduledTaskFinish("upload-pending-cleanup", { completed: 2 }, 56); + + expect(logAppEventMock).toHaveBeenNthCalledWith( + 1, + "info", + "worker.queue.finish", + expect.objectContaining({ + event: "worker.queue.finish", + messageCount: 2, + outcome: "success", + queue: "audit", + }), + ); + expect(logAppEventMock).toHaveBeenNthCalledWith( + 2, + "info", + "scheduled.task.finish", + expect.objectContaining({ + completed: 2, + event: "scheduled.task.finish", + ioObservedDurationMs: 56, + outcome: "success", + }), + ); + }); + + it("records failures without changing the original error", () => { + const error = new Error("private detail"); + logWorkerFetchError({ + error, + ioObservedDurationMs: 12, + requestId: "request-1", + }); + logWorkerQueueError({ + error, + ioObservedDurationMs: 34, + messageCount: 1, + queue: "unknown", + }); + logScheduledTaskError("unknown", 56, error); + + expect(logAppEventMock).toHaveBeenNthCalledWith( + 1, + "error", + "worker.fetch.error", + expect.objectContaining({ + event: "worker.fetch.error", + outcome: "error", + }), + error, + ); + expect(logAppEventMock).toHaveBeenNthCalledWith( + 2, + "error", + "worker.queue.error", + expect.objectContaining({ + event: "worker.queue.error", + outcome: "error", + queue: "unknown", + }), + error, + ); + expect(logAppEventMock).toHaveBeenNthCalledWith( + 3, + "error", + "scheduled.task.error", + expect.objectContaining({ + event: "scheduled.task.error", + outcome: "error", + task: "unknown", + }), + error, + ); + }); + it("logs scheduled outcomes without exposing cron expressions", () => { logScheduledTaskFinish("upload-pending-cleanup", { completed: 2, @@ -105,6 +220,7 @@ describe("worker entrypoint observability", () => { completed: 2, event: "scheduled.task.finish", failed: 0, + outcome: "success", source: "worker-entrypoint", task: "upload-pending-cleanup", }, @@ -115,6 +231,7 @@ describe("worker entrypoint observability", () => { "scheduled.task.unknown", { event: "scheduled.task.unknown", + outcome: "unknown", source: "worker-entrypoint", }, ); diff --git a/tests/unit/wrangler-rate-limit-config.test.ts b/tests/unit/wrangler-rate-limit-config.test.ts index 2fd7a3e68..94bf4a1c0 100644 --- a/tests/unit/wrangler-rate-limit-config.test.ts +++ b/tests/unit/wrangler-rate-limit-config.test.ts @@ -55,6 +55,7 @@ describe("Wrangler mutation rate-limit bindings", () => { }; preview_urls?: boolean; workers_dev?: boolean; + compatibility_date?: string; }; expect(config.preview_urls).toBe(false);