diff --git a/.env.example b/.env.example
index 9fcf0dd..1d629c5 100644
--- a/.env.example
+++ b/.env.example
@@ -1,16 +1,17 @@
# Discord bot
DISCORD_TOKEN=YOUR_DISCORD_TOKEN
-# GitHub issue routing (webserver)
+# GitHub issue routing (webserver): both data and website reports use REPO_DEV.
+# The production token must have issue-write access to this public repository.
GITHUB_TOKEN=YOUR_GITHUB_TOKEN
-REPO_DEV=orga/repo
-REPO_DATA_REPORT=orga/repo
+REPO_DEV=tarkovtracker-org/TarkovTracker
+# Legacy REPO_DATA_REPORT settings are ignored while the overlay is private.
# Web server
PORT=3000
# Comma-separated list of allowed origins for the report forms.
# Leave blank to disable origin checking (not recommended for production).
-ALLOWED_ORIGINS=https://tarkovtracker.org,https://www.tarkovtracker.org
+ALLOWED_ORIGINS=https://tarkovtracker.org,https://www.tarkovtracker.org,https://trackerbot.nivmizz7.dev,https://www.trackerbot.nivmizz7.dev
# Discord channel IDs for automated posts
WELCOME_CHANNEL_ID=WELCOME_CHANNEL_ID
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5a7c56e..367b34c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,22 +32,11 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Audit production dependencies
+ run: npm run audit:prod
+
- name: Syntax check all JS files
- run: |
- set -e
- fail=0
- while IFS= read -r f; do
- if ! node --check "$f" 2>/dev/null; then
- echo "::error file=$f::Syntax error in $f"
- node --check "$f" || true
- fail=1
- fi
- done < <(find . -name "*.js" -not -path "./node_modules/*")
- if [ "$fail" -ne 0 ]; then
- echo "::error::One or more JS files failed syntax check"
- exit 1
- fi
- echo "All JS files passed syntax check."
+ run: npm run build
- name: Run tests
run: npm test
diff --git a/README.md b/README.md
index 39301f6..f07bf81 100755
--- a/README.md
+++ b/README.md
@@ -22,6 +22,92 @@ TrackerBot runs two services side by side:
| **Discord bot** | `bot.js` | Slash commands, reaction roles, welcome automation, and ticket creation. |
| **Bug intake web server** | `webserver.js` | Serves the public issue forms and forwards submissions to GitHub. |
+### Reporting limits and errors
+
+Both report forms currently create issues in `REPO_DEV`, configured as
+`tarkovtracker-org/TarkovTracker`. The data overlay repository is private, so
+`REPO_DATA_REPORT` is no longer required and any existing value is ignored.
+Data reports retain their category-prefixed title, category, reference and details.
+Both forms link to the public TarkovTracker issue list for delivery checks and
+explicitly warn that Discord handles, reference links and report details are public.
+
+`POST /data` and `POST /issue` share one reporting policy for each client IP
+(with the existing IPv6 `/56` grouping). The allowance is **20 delivered reports
+in a rolling 12 hours**, with **at least 10 minutes between deliveries**. A
+delivery exactly 12 hours old leaves the allowance; a new report is admitted
+exactly at the 10-minute boundary. IPv4-mapped addresses use the same key as IPv4,
+including dotted and hexadecimal IPv6 representations. Switching forms does not
+reset either limit. Users sharing an IP or IPv6 prefix share these limits.
+
+The server reserves a slot before awaiting GitHub, so concurrent requests cannot
+both pass. Confirmed GitHub issue creation (`201`) consumes the allowance and
+starts the cooldown at delivery completion. Validation, origin/parser errors,
+honeypot submissions and proven delivery rejections do not count as delivered
+reports. A separate shared **five-attempt rolling minute** budget runs before
+body parsing and includes rejected/invalid requests. Denials return the longest
+active attempt/window/cooldown wait rather than an earlier partial reset.
+
+Timeouts, connection resets, unexpected provider responses and GitHub `5xx`
+responses are **unconfirmed delivery**, not proof that no issue was created.
+They conservatively reserve allowance and cooldown. A hash of the same report is
+held against automatic repetition for 12 hours, bounded by the same retention
+window; this is not a durable idempotency guarantee. Check the published issue
+list or contact support before repeating it. Different reports may be sent after
+the ordinary cooldown if allowance remains. No automatic GitHub retry occurs.
+
+Both forms preserve their entered fields on failure, disable double submission,
+and display safe typed server errors. Unknown network/proxy/success responses
+keep the draft and stop automatic repetition in that page. The displayed wait is
+advisory; every new request is checked by the server. Length limits are visible
+and enforced without silently truncating evidence: title 200, Discord handle 64,
+category 64, reference 500, details 8,000 characters; request bodies are capped at
+32 KiB. Successful submission resets the form. Drafts remain in the open page,
+not persistent browser storage; copy them before reloading.
+
+Errors use `{ error: { code, message }, retryAfterSeconds?, resetAt? }`.
+Admission errors use HTTP `429` (`report_cooldown`, `report_window_limit`,
+`attempt_limit`, `report_in_progress`), capacity uses `503`, and uncertain repeat
+delivery uses `409`. Wait errors include numeric `Retry-After` seconds and an ISO
+`resetAt`. Validation/parser/origin errors use `400`/`413`/`415`/`403`. Proven provider
+rejections/connectivity failures use `502`/`503`; unconfirmed delivery uses `504`.
+Provider throttling honors full `Retry-After` and exhausted-primary
+`x-ratelimit-reset` waits, taking the longer when both apply. A shared process-local
+provider hold prevents every client on both routes, including staged uploads, from
+calling GitHub again before that reset. Local denials include any longer provider
+wait, while known uncertain-report denials keep their check-before-repeating warning.
+Secondary throttles without timing headers use GitHub's minimum one-minute guidance.
+Neither server nor browser truncates a valid provider wait. Known client parser failures,
+including parameter overflow, excessive nesting and unsupported formats, return
+retryable typed `4xx` errors; they are not uncertain GitHub delivery.
+See [GitHub rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit)
+and [body-parser errors](https://expressjs.com/en/resources/middleware/body-parser/#errors).
+Provider bodies, tokens, IPs
+and report descriptions are never included in error output or delivery logs.
+
+**Rollout limitation: these controls are process-local memory, not durable or
+shared across workers/hosts.** A restart clears allowances, cooldowns,
+uncertain-report holds and the shared provider throttle. There is no existing
+persistent limiter store or hosting manifest in this repository. State is capped
+at 10,000 clients and stale entries are removed; at capacity, expiry cleanup runs
+at most once per second. New clients
+receive a safe busy response when the remaining entries still hold active limits;
+active attempt budgets are not evicted to make room. Otherwise rotating keys could
+bypass the five-attempt allowance. Sustained traffic from 10,000 distinct active
+client keys can still exhaust this bounded table; operator-side abuse protection
+and the trusted-proxy boundary remain necessary. Strict enforcement across restarts
+or multiple processes needs an operator-approved persistent/shared store after
+hosting is established.
+This change adds no infrastructure or production settings and is not evidence
+that the deployed topology is ready for strict enforcement.
+
+`trust proxy = 1` remains unchanged. Before rollout, operators must verify that
+the origin is reachable only through the intended trusted proxy and that it
+overwrites client forwarding headers, and confirm the actual process topology.
+Otherwise clients may spoof the limiter key or unrelated users may share one.
+Do not fix that by trusting arbitrary forwarded headers. The deployed form origin
+must also match the existing `ALLOWED_ORIGINS` setting. Credential, proxy-trust and
+production configuration changes require separate approval.
+
Requires **Node.js 18.17+**.
## Features
@@ -39,20 +125,49 @@ Requires **Node.js 18.17+**.
### 1. Install dependencies
```bash
-npm install
+npm ci
+npm run build
```
+This project has no bundle or generated build output. `npm run build` syntax-checks
+all JavaScript files without starting either service. CI runs the same command,
+the tests, and `npm run audit:prod`; production dependency advisories fail CI.
+
### 2. Configure environment variables
Copy `.env.example` to `.env` and fill in every value:
- **Discord bot basics:** `DISCORD_TOKEN`, `ADMIN_ROLE_ID` (comma-separated Discord role IDs granting admin-only commands such as `/wipe`)
-- **GitHub issue routing:** `GITHUB_TOKEN`, `REPO_DEV`, `REPO_DATA_REPORT`
+- **GitHub issue routing:** `GITHUB_TOKEN`, `REPO_DEV=tarkovtracker-org/TarkovTracker` for both forms. Legacy `REPO_DATA_REPORT` is ignored.
- **Channel IDs for automated posts:** `WELCOME_CHANNEL_ID`; optional `HONEYPOT_CHANNEL_ID` (a hidden channel — anyone who posts in it is banned on sight, with a kick fallback; messages older than 16h are swept hourly)
- **Role IDs:** `PANEL_ADMIN_ROLE_ID`, `AUTO_ROLE_ID_1`–`AUTO_ROLE_ID_4` (auto-roles for new members); optional `ALLOWED_COMMAND_ROLE_IDS` (comma-separated role IDs allowed to use member commands, falls back to a built-in default)
- **Web server:** `PORT`, `ALLOWED_ORIGINS` (comma-separated origins, exact match)
-> `GITHUB_TOKEN` needs `repo` scope (or equivalent granular access) to create issues in the configured repositories. For production, prefer a fine-grained PAT scoped to `issues:write` on the two target repos only.
+> The deployed `GITHUB_TOKEN` needs issue-write access to `tarkovtracker-org/TarkovTracker`. Prefer a fine-grained PAT with **Issues: write** on that repository only. Redirecting data reports removes the private-overlay dependency, but does not grant permissions to a token that cannot create website issues.
+
+### Production rollout checklist
+
+- Use `npm ci`, `npm run build`, and `npm start` in the hosting configuration.
+ `npm start` launches one bot and one web process per invocation; its launcher is
+ a runtime dependency, so `npm ci --omit=dev` is also supported. Verify the actual
+ replica count and deployment overlap rather than inferring it from the command.
+- Set `REPO_DEV=tarkovtracker-org/TarkovTracker`. Verify the deployed token's
+ issue-write access without sharing credentials. Both report routes use that destination.
+- Correct the operator's origin-list typos (`https//` is not `https://`). A valid
+ list for the reported domains is:
+ `https://tarkovtracker.org,https://www.tarkovtracker.org,https://trackerbot.nivmizz7.dev,https://www.trackerbot.nivmizz7.dev`.
+ A preview hostname must be added separately if its forms are tested. Do not
+ disable origin checking just to make a preview pass.
+- Verify that Node is not directly Internet-accessible, that the trusted proxy
+ overwrites forwarding headers, and that distinct visitors retain distinct limiter keys.
+ Leave `trust proxy = 1` unchanged until the real proxy path is established.
+- Confirm a single serving web process, including during deployments, and explicitly
+ accept that restarts clear limits, uncertain-delivery holds and provider throttles.
+ If strict limits across replicas or restarts are required, approve a shared/persistent
+ store before rollout.
+- A successful `/health` response only proves the HTTP process is running, not
+ GitHub delivery or correct proxy isolation. Coolify configuration and production
+ credentials cannot be verified from repository tests.
### 3. Run the services locally
diff --git a/package-lock.json b/package-lock.json
index 0118c0f..d81d2bf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,14 +10,12 @@
"license": "MIT",
"dependencies": {
"axios": "^1.20.0",
+ "concurrently": "9.2.4",
"discord.js": "^14.26.4",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-rate-limit": "^8.5.2"
},
- "devDependencies": {
- "concurrently": "^9.2.4"
- },
"engines": {
"node": ">=18.17"
}
@@ -242,7 +240,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -252,7 +249,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -283,18 +279,18 @@
}
},
"node_modules/body-parser": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
- "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
+ "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
- "content-type": "^2.0.0",
+ "content-type": "^2.1.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
- "iconv-lite": "^0.7.2",
+ "iconv-lite": "^0.7.3",
"on-finished": "^2.4.1",
- "qs": "^6.15.2",
+ "qs": "^6.16.1",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
@@ -307,9 +303,9 @@
}
},
"node_modules/body-parser/node_modules/content-type": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
- "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -361,7 +357,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
@@ -378,7 +373,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -391,7 +385,6 @@
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
- "dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
@@ -406,7 +399,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -419,7 +411,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -438,7 +429,6 @@
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
"integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
@@ -597,7 +587,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -657,7 +646,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -758,9 +746,9 @@
}
},
"node_modules/follow-redirects": {
- "version": "1.16.0",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
- "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
+ "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
@@ -843,7 +831,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -902,7 +889,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -981,9 +967,9 @@
}
},
"node_modules/iconv-lite": {
- "version": "0.7.2",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
- "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -1002,9 +988,9 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ip-address": {
- "version": "10.7.2",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
- "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
+ "version": "10.7.4",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
+ "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -1022,7 +1008,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1186,9 +1171,9 @@
}
},
"node_modules/qs": {
- "version": "6.16.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
- "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "version": "6.16.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
+ "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
@@ -1228,7 +1213,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -1253,7 +1237,6 @@
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
@@ -1328,7 +1311,6 @@
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -1421,7 +1403,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -1436,7 +1417,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -1449,7 +1429,6 @@
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -1473,7 +1452,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
- "dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
@@ -1522,9 +1500,9 @@
}
},
"node_modules/undici": {
- "version": "6.28.1",
- "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz",
- "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==",
+ "version": "6.29.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",
+ "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@@ -1556,7 +1534,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -1600,7 +1577,6 @@
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
@@ -1610,7 +1586,6 @@
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
@@ -1629,7 +1604,6 @@
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
diff --git a/package.json b/package.json
index d54ba6b..1c9e7a5 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,8 @@
"main": "bot.js",
"scripts": {
"test": "node --test",
+ "build": "node scripts/check-syntax.js",
+ "audit:prod": "npm audit --omit=dev --audit-level=low",
"start:bot": "node bot.js",
"start:web": "node webserver.js",
"start": "concurrently -n bot,web \"npm:start:bot\" \"npm:start:web\""
@@ -24,17 +26,18 @@
},
"dependencies": {
"axios": "^1.20.0",
+ "concurrently": "9.2.4",
"discord.js": "^14.26.4",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-rate-limit": "^8.5.2"
},
"overrides": {
- "undici": "^6.28.0",
+ "undici": "6.29.1",
"ws": "^8.21.0",
- "shell-quote": "1.12.0"
- },
- "devDependencies": {
- "concurrently": "^9.2.4"
+ "shell-quote": "1.12.0",
+ "follow-redirects": "1.16.1",
+ "ip-address": "10.7.4",
+ "body-parser": "2.4.0"
}
}
diff --git a/scripts/check-syntax.js b/scripts/check-syntax.js
new file mode 100644
index 0000000..a9110af
--- /dev/null
+++ b/scripts/check-syntax.js
@@ -0,0 +1,33 @@
+import { readdir } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { spawnSync } from "node:child_process";
+
+const root = fileURLToPath(new URL("../", import.meta.url));
+const excludedDirectories = new Set(["node_modules", ".git"]);
+
+async function javaScriptFiles(directory) {
+ const files = [];
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const filename = path.join(directory, entry.name);
+ if (entry.isDirectory() && !excludedDirectories.has(entry.name)) {
+ files.push(...await javaScriptFiles(filename));
+ } else if (entry.isFile() && entry.name.endsWith(".js")) {
+ files.push(filename);
+ }
+ }
+ return files;
+}
+
+const files = (await javaScriptFiles(root)).sort();
+if (!files.length) throw new Error("No JavaScript files found for syntax checking.");
+for (const filename of files) {
+ const result = spawnSync(process.execPath, ["--check", filename], { encoding: "utf8" });
+ if (result.error) throw result.error;
+ if (result.status !== 0) {
+ console.error(`Syntax check failed: ${path.relative(root, filename)}`);
+ console.error(result.stderr);
+ process.exitCode = 1;
+ }
+}
+if (!process.exitCode) console.log(`Syntax checked ${files.length} JavaScript files.`);
diff --git a/src/config/env.js b/src/config/env.js
index bc4d633..fec088e 100644
--- a/src/config/env.js
+++ b/src/config/env.js
@@ -21,4 +21,13 @@ export function ensureEnvVars(vars = requiredEnvVars) {
vars.forEach(getRequiredEnv);
}
-export { requiredEnvVars };
+const requiredWebEnvVars = ["GITHUB_TOKEN", "REPO_DEV"];
+
+export function ensureWebEnvVars(env = process.env) {
+ const missing = requiredWebEnvVars.filter(name => !env[name]?.trim());
+ if (missing.length) {
+ throw new Error(`Missing required environment variables for webserver: ${missing.join(", ")}`);
+ }
+}
+
+export { requiredEnvVars, requiredWebEnvVars };
diff --git a/src/web/report-app.js b/src/web/report-app.js
new file mode 100644
index 0000000..d486ed1
--- /dev/null
+++ b/src/web/report-app.js
@@ -0,0 +1,182 @@
+import express from "express";
+import { ipKeyGenerator } from "express-rate-limit";
+import axios from "axios";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { createHash } from "node:crypto";
+import { createReportPolicy } from "./report-policy.js";
+
+const webDirectory = fileURLToPath(new URL("../../web", import.meta.url));
+export const FIELD_LIMITS = { title: 200, discord: 64, category: 64, reference: 500, description: 8000 };
+const providerRateMessage = "GitHub is temporarily limiting reports. Your report was not created. Keep your draft and wait before retrying.";
+const parserErrors = new Map([
+ ["entity.too.large", [413, "report_too_large", "This report exceeds the 32 KiB request limit. Shorten the details while keeping the key evidence."]],
+ ["parameters.too.many", [413, "report_too_large", "The request contains too many fields. Keep your draft and reload the report form."]],
+ ["entity.parse.failed", [400, "invalid_request", "The report request could not be read. Keep your draft and reload the form before retrying."]],
+ ["querystring.parse.rangeError", [400, "invalid_request", "The request contains too many nested fields. Keep your draft and reload the report form."]],
+ ["request.aborted", [400, "invalid_request", "The report request was interrupted before delivery. Keep your draft and reload the report form."]],
+ ["request.size.invalid", [400, "invalid_request", "The report request size is invalid. Keep your draft and reload the report form."]],
+ ["charset.unsupported", [415, "unsupported_request", "The report format is unsupported. Keep your draft and reload the report form."]],
+ ["encoding.unsupported", [415, "unsupported_request", "The report format is unsupported. Keep your draft and reload the report form."]],
+ ["entity.verify.failed", [403, "invalid_request", "The report request could not be verified. Keep your draft and reload the report form."]]
+]);
+
+function clientKey(ip) {
+ // Normalize hexadecimal mapped IPv4 before the existing IPv6 /56 grouping.
+ const mapped = ipKeyGenerator(ip, 128).match(/^::ffff:([\da-f]+):([\da-f]+)\/128$/i);
+ if (!mapped) return ipKeyGenerator(ip);
+ const high = Number.parseInt(mapped[1], 16);
+ const low = Number.parseInt(mapped[2], 16);
+ return [high >> 8, high & 255, low >> 8, low & 255].join(".");
+}
+
+function sendError(res, status, code, message, details = {}) {
+ if (details.retryAfterSeconds != null) res.set("Retry-After", String(details.retryAfterSeconds));
+ return res.status(status).json({ error: { code, message }, ...details });
+}
+
+function sendAdmissionError(res, admission) {
+ const status = admission.code === "delivery_uncertain" ? 409 : ["report_capacity", "provider_rate_limit"].includes(admission.code) ? 503 : 429;
+ return sendError(res, status, admission.code, admission.message, { retryAfterSeconds: admission.retryAfterSeconds, resetAt: admission.resetAt });
+}
+
+function validateFields(body, kind) {
+ const required = kind === "data" ? ["title", "discord", "category", "description"] : ["title", "discord", "description"];
+ const fields = {};
+ for (const [name, max] of Object.entries(FIELD_LIMITS)) {
+ const value = body?.[name];
+ if (value != null && typeof value !== "string") return { error: `${name} must be text.` };
+ fields[name] = (value ?? "").trim();
+ if (fields[name].length > max) return { error: `${name} must be ${max} characters or fewer.` };
+ if (required.includes(name) && !fields[name]) return { error: `Please provide ${name}.` };
+ }
+ return { fields };
+}
+
+function buildIssue(kind, fields) {
+ if (kind === "issue") {
+ return { title: fields.title, body: `**Discord handle:** ${fields.discord}\n\n**Description:**\n${fields.description}` };
+ }
+ const lines = [`**Discord handle:** ${fields.discord}`, `**Category:** ${fields.category}`];
+ if (fields.reference) lines.push(`**Reference:** ${fields.reference}`);
+ lines.push("", "**Details:**", fields.description);
+ return { title: `[${fields.category}] ${fields.title}`, body: lines.join("\n") };
+}
+
+function safeWait(seconds, time) {
+ const wait = Math.ceil(seconds);
+ return Number.isSafeInteger(wait) && wait >= 0 && Number.isFinite(new Date(time + wait * 1000).getTime()) ? wait : null;
+}
+
+function providerWait(headers, time) {
+ const retryHeader = headers["retry-after"];
+ const retry = Number(retryHeader);
+ const retrySeconds = retryHeader == null || String(retryHeader).trim() === "" ? null
+ : safeWait(Number.isFinite(retry) ? retry : Math.max(0, (Date.parse(retryHeader) - time) / 1000), time);
+ const resetHeader = headers["x-ratelimit-reset"];
+ const resetSeconds = String(headers["x-ratelimit-remaining"]) === "0" && resetHeader != null && String(resetHeader).trim() !== ""
+ ? safeWait(Math.max(0, (Number(resetHeader) * 1000 - time) / 1000), time) : null;
+ const waits = [retrySeconds, resetSeconds].filter(Number.isFinite);
+ return waits.length ? Math.max(...waits) : 60;
+}
+
+function deliveryError(err, time) {
+ const status = err?.response?.status;
+ const headers = err?.response?.headers ?? {};
+ const message = err?.response?.data?.message;
+ const secondary = typeof message === "string" && /secondary rate limit/i.test(message);
+ if (status === 429 || (status === 403 && (headers["retry-after"] || String(headers["x-ratelimit-remaining"]) === "0" || secondary))) {
+ const seconds = providerWait(headers, time);
+ return { status: 503, code: "provider_rate_limit", message: providerRateMessage, retryAfterSeconds: seconds, resetAt: new Date(time + seconds * 1000).toISOString(), failed: true };
+ }
+ if ([400, 401, 403, 404, 405, 410, 422].includes(status)) {
+ return { status: 502, code: "delivery_rejected", message: "The report service could not deliver this report. It was not created. Keep your draft and contact support.", failed: true };
+ }
+ if (["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"].includes(err?.code)) {
+ return { status: 503, code: "delivery_unavailable", message: "The report service could not connect to GitHub. Your report was not created. Keep your draft and wait one minute before retrying.", retryAfterSeconds: 60, failed: true };
+ }
+ return { status: 504, code: "delivery_uncertain", message: "Delivery could not be confirmed. Check the issue list or contact support before repeating this report; it may already have been created.", failed: false };
+}
+
+export function createReportApp({ env = process.env, githubPost = axios.post, now = Date.now, logger = console } = {}) {
+ const app = express();
+ // Preserve existing trust. Operators must verify a single trusted proxy and
+ // no direct origin access; do not trust a client-supplied IP header here.
+ app.set("trust proxy", 1);
+ const policy = createReportPolicy({ now });
+ const origins = new Set((env.ALLOWED_ORIGINS || "").split(",").map(origin => origin.trim().replace(/\/+$/, "")).filter(Boolean));
+ const githubHeaders = { Authorization: `token ${env.GITHUB_TOKEN}`, Accept: "application/vnd.github+json", "User-Agent": "tarkovtracker-webserver" };
+ // One shared token serves both routes and every client in this process.
+ let providerBlockedUntil = 0;
+ function withProviderWait(admission, time) {
+ if (time >= providerBlockedUntil) return admission;
+ const wait = { retryAfterSeconds: Math.ceil((providerBlockedUntil - time) / 1000), resetAt: new Date(providerBlockedUntil).toISOString() };
+ if (admission.ok === false && Date.parse(admission.resetAt) >= providerBlockedUntil) return admission;
+ if (admission.code === "delivery_uncertain") return { ...admission, ...wait };
+ return { ok: false, code: "provider_rate_limit", message: providerRateMessage, ...wait };
+ }
+ // This separate budget counts attempts, including invalid/rejected requests.
+ // It is shared across routes and runs before decoding the body.
+ app.post(["/data", "/issue"], (req, res, next) => {
+ const admission = policy.attempt(clientKey(req.ip));
+ if (!admission.ok) return sendAdmissionError(res, withProviderWait(admission, now()));
+ next();
+ });
+ app.use(express.urlencoded({ extended: true, limit: "32kb" }));
+ app.use(express.json({ limit: "32kb" }));
+ app.use(express.static(path.join(webDirectory)));
+ app.get("/health", (req, res) => res.json({ status: "ok" }));
+
+ async function submit(req, res, kind) {
+ const origin = req.headers.origin || req.headers.referer || "";
+ if (origins.size && ![...origins].some(allowed => origin === allowed || origin.startsWith(`${allowed}/`))) {
+ return sendError(res, 403, "forbidden_origin", "This report form is not allowed to submit from this address. Keep your draft and contact support.");
+ }
+ const company = req.body?.company;
+ if (company != null && typeof company !== "string") {
+ return sendError(res, 400, "invalid_report", "company must be text.");
+ }
+ if (company?.trim()) return res.json({ ok: true });
+ const validated = validateFields(req.body, kind);
+ if (validated.error) return sendError(res, 400, "invalid_report", validated.error);
+ const issue = buildIssue(kind, validated.fields);
+ const fingerprint = createHash("sha256").update(JSON.stringify([kind, issue])).digest("hex");
+ const reservation = policy.reserve(clientKey(req.ip), fingerprint);
+ const admission = withProviderWait(reservation, now());
+ if (!admission.ok) {
+ // No asynchronous work occurs before releasing this temporary reservation.
+ // Inspecting the local denial first preserves known uncertain-report warnings.
+ if (reservation.ok) reservation.settle("failed");
+ return sendAdmissionError(res, admission);
+ }
+ try {
+ // Temporary shared destination: the overlay is private. Ignore any
+ // legacy REPO_DATA_REPORT setting, but retain data-specific formatting.
+ const repo = env.REPO_DEV;
+ const response = await githubPost(`https://api.github.com/repos/${repo}/issues`, issue, { headers: githubHeaders, timeout: 10_000 });
+ if (response?.status !== 201) throw new Error("Unexpected issue creation status");
+ admission.settle("accepted");
+ return res.json({ ok: true });
+ } catch (err) {
+ const failure = deliveryError(err, now());
+ if (failure.code === "provider_rate_limit") {
+ providerBlockedUntil = Math.max(providerBlockedUntil, Date.parse(failure.resetAt));
+ }
+ admission.settle(failure.failed ? "failed" : "uncertain");
+ logger.error("Report delivery failed", { route: kind, status: Number.isInteger(err?.response?.status) ? err.response.status : "no-response", code: failure.code });
+ const details = failure.retryAfterSeconds != null ? { retryAfterSeconds: failure.retryAfterSeconds } : {};
+ if (failure.resetAt) details.resetAt = failure.resetAt;
+ return sendError(res, failure.status, failure.code, failure.message, details);
+ }
+ }
+ app.post("/data", (req, res) => submit(req, res, "data"));
+ app.post("/issue", (req, res) => submit(req, res, "issue"));
+ app.use((err, req, res, next) => {
+ if (res.headersSent) return next(err);
+ const parserError = parserErrors.get(err.type);
+ if (parserError) return sendError(res, ...parserError);
+ logger.error("Report request failed", { type: "internal_error" });
+ return sendError(res, 500, "internal_error", "The report service encountered an error. Keep your draft and contact support before repeating a submitted report.");
+ });
+ return app;
+}
diff --git a/src/web/report-policy.js b/src/web/report-policy.js
new file mode 100644
index 0000000..3f43ee9
--- /dev/null
+++ b/src/web/report-policy.js
@@ -0,0 +1,110 @@
+export const REPORT_WINDOW_MS = 12 * 60 * 60 * 1000;
+export const REPORT_INTERVAL_MS = 10 * 60 * 1000;
+export const REPORT_LIMIT = 20;
+const ATTEMPT_WINDOW_MS = 60_000;
+const ATTEMPT_LIMIT = 5;
+
+function denied(code, message, resetAt, now) {
+ return {
+ ok: false, code, message,
+ retryAfterSeconds: Math.max(1, Math.ceil((resetAt - now) / 1000)),
+ resetAt: new Date(resetAt).toISOString()
+ };
+}
+
+// One Node process only. Reservation and settlement are synchronous, including
+// reservation before asynchronous delivery; there is no persistent store.
+export function createReportPolicy({ now = Date.now, maxClients = 10_000 } = {}) {
+ const clients = new Map();
+ let lastSweep = -Infinity;
+ function prune(state, time) {
+ state.events = state.events.filter(event => event.time > time - REPORT_WINDOW_MS);
+ state.attempts = state.attempts.filter(attempt => attempt > time - ATTEMPT_WINDOW_MS);
+ }
+ function sweep(time, interval) {
+ if (time - lastSweep < interval) return;
+ lastSweep = time;
+ for (const [key, state] of clients) {
+ prune(state, time);
+ if (!state.pending && !state.events.length && !state.attempts.length) clients.delete(key);
+ }
+ }
+ function stateFor(key, time) {
+ // Reclaim expired entries promptly at capacity, but never evict an active
+ // attempt budget, delivered allowance, pending reservation or uncertain hold.
+ sweep(time, clients.size >= maxClients ? 1000 : 60_000);
+ let state = clients.get(key);
+ if (!state) {
+ if (clients.size >= maxClients) {
+ return denied("report_capacity", "The report service is busy. Keep your draft and wait one minute.", time + 60_000, time);
+ }
+ state = { events: [], attempts: [], pending: null };
+ clients.set(key, state);
+ }
+ prune(state, time);
+ return state;
+ }
+ function blocked(state, time, fingerprint) {
+ const previous = state.events.at(-1);
+ const cooldownEnd = previous ? previous.time + REPORT_INTERVAL_MS : time;
+ const windowEnd = state.events.length >= REPORT_LIMIT ? state.events[0].time + REPORT_WINDOW_MS : time;
+ const pendingEnd = state.pending ? time + 10_000 : time;
+ const reset = Math.max(cooldownEnd, windowEnd, pendingEnd);
+ const uncertain = fingerprint && state.events.find(event => event.outcome === "uncertain" && event.fingerprint === fingerprint);
+ if (uncertain) {
+ return denied("delivery_uncertain", "Delivery of this report could not be confirmed. Check the issue list or contact support before repeating it; automatic retries could create duplicates.", Math.max(uncertain.time + REPORT_WINDOW_MS, reset), time);
+ }
+ if (state.events.length >= REPORT_LIMIT) {
+ return denied("report_window_limit", "The shared limit of 20 reports in a rolling 12-hour window has been reached. The displayed wait also includes any remaining 10-minute cooldown.", reset, time);
+ }
+ if (state.pending) {
+ return denied("report_in_progress", "A report is already being submitted from this connection. Wait for its result.", reset, time);
+ }
+ if (previous && time < previous.time + REPORT_INTERVAL_MS) {
+ return denied("report_cooldown", "Wait 10 minutes between reports. Both report forms share this cooldown.", reset, time);
+ }
+ return null;
+ }
+ function attempt(key) {
+ const time = now();
+ const state = stateFor(key, time);
+ if (state.ok === false) return state;
+ const reportingBlock = blocked(state, time);
+ const exhausted = state.attempts.length >= ATTEMPT_LIMIT;
+ if (!exhausted) state.attempts.push(time);
+ // An eligible fifth attempt still passes. A rejected fifth attempt must
+ // advertise the wait its newly exhausted attempt budget imposes on retry.
+ return withAttemptWait(reportingBlock ?? (exhausted ? attemptBlock(state, time) : null), state, time) ?? { ok: true };
+ }
+ function attemptBlock(state, time) {
+ if (state.attempts.length < ATTEMPT_LIMIT) return null;
+ return denied("attempt_limit", "Too many submission attempts. Keep your draft and wait one minute.", state.attempts[0] + ATTEMPT_WINDOW_MS, time);
+ }
+ // Use the same calculation before and after body parsing. Never charge an
+ // additional attempt here or turn an otherwise eligible reservation away.
+ function withAttemptWait(rejection, state, time) {
+ if (!rejection) return null;
+ const attempts = attemptBlock(state, time);
+ if (!attempts || Date.parse(attempts.resetAt) <= Date.parse(rejection.resetAt)) return rejection;
+ // Keep the check-before-repeating warning even when another wait is longer.
+ if (rejection.code === "delivery_uncertain") {
+ return { ...rejection, retryAfterSeconds: attempts.retryAfterSeconds, resetAt: attempts.resetAt };
+ }
+ return attempts;
+ }
+ function reserve(key, fingerprint) {
+ const time = now();
+ const state = stateFor(key, time);
+ if (state.ok === false) return state;
+ const rejection = blocked(state, time, fingerprint);
+ if (rejection) return withAttemptWait(rejection, state, time);
+ const reservation = { time, fingerprint };
+ state.pending = reservation;
+ return { ok: true, settle(outcome) {
+ if (state.pending !== reservation) return;
+ state.pending = null;
+ if (outcome !== "failed") state.events.push({ ...reservation, time: now(), outcome });
+ } };
+ }
+ return { attempt, reserve };
+}
diff --git a/test/build.test.js b/test/build.test.js
new file mode 100644
index 0000000..5a44ce7
--- /dev/null
+++ b/test/build.test.js
@@ -0,0 +1,58 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+
+async function fixture(t) {
+ const directory = await mkdtemp(path.join(tmpdir(), "trackerbot-build-test-"));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
+ await writeFile(path.join(directory, "package.json"), JSON.stringify({ type: "module", scripts: { build: manifest.scripts.build } }));
+ await mkdir(path.join(directory, "scripts"));
+ await writeFile(path.join(directory, "scripts/check-syntax.js"), await readFile(new URL("../scripts/check-syntax.js", import.meta.url), "utf8"));
+ await writeFile(path.join(directory, "bot.js"), "export const bot = true;\n");
+ await writeFile(path.join(directory, "webserver.js"), "export const web = true;\n");
+ return { directory, build() {
+ return spawnSync("npm", ["run", "build", "--silent"], { cwd: directory, encoding: "utf8", timeout: 10000 });
+ } };
+}
+
+test("the hosting build command checks valid source without starting services", async t => {
+ const api = await fixture(t);
+ await mkdir(path.join(api.directory, "src/web"), { recursive: true });
+ await writeFile(path.join(api.directory, "src/web/example.js"), "throw new Error('must not execute');\n");
+ const result = api.build();
+ assert.ifError(result.error);
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Syntax checked 4 JavaScript files/);
+});
+
+test("the build fails on syntax errors in nested source files", async t => {
+ const api = await fixture(t);
+ await mkdir(path.join(api.directory, "src/web"), { recursive: true });
+ await writeFile(path.join(api.directory, "src/web/broken.js"), "export const = ;\n");
+ const result = api.build();
+ assert.ifError(result.error);
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /Syntax check failed: src\/web\/broken.js/);
+});
+
+test("the build ignores installed dependencies and Git metadata", async t => {
+ const api = await fixture(t);
+ for (const ignored of ["node_modules", ".git"]) {
+ await mkdir(path.join(api.directory, ignored));
+ await writeFile(path.join(api.directory, ignored, "broken.js"), "export const = ;\n");
+ }
+ const result = api.build();
+ assert.ifError(result.error);
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Syntax checked 3 JavaScript files/);
+});
+
+test("the service launcher remains available in production-only installs", async () => {
+ const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
+ assert.match(manifest.scripts.start, /^concurrently\s/);
+ assert.ok(manifest.dependencies?.concurrently, "npm start must not rely on a dev-only launcher");
+});
diff --git a/test/env.test.js b/test/env.test.js
index e295928..a0c2761 100644
--- a/test/env.test.js
+++ b/test/env.test.js
@@ -53,6 +53,21 @@ test("ensureEnvVars passes when all vars are set", async () => {
assert.doesNotThrow(() => ensureEnvVars());
});
+test("web intake requires only the shared website destination and token", async () => {
+ const { ensureWebEnvVars, requiredWebEnvVars } = await importEnv();
+ assert.deepEqual(requiredWebEnvVars, ["GITHUB_TOKEN", "REPO_DEV"]);
+ assert.doesNotThrow(() => ensureWebEnvVars({ GITHUB_TOKEN: "test-only", REPO_DEV: "tarkovtracker-org/TarkovTracker" }));
+});
+
+for (const name of ["GITHUB_TOKEN", "REPO_DEV"]) {
+ for (const value of [undefined, "", " "]) {
+ test(`web intake rejects missing or blank ${name}: ${JSON.stringify(value)}`, async () => {
+ const { ensureWebEnvVars } = await importEnv();
+ assert.throws(() => ensureWebEnvVars({ GITHUB_TOKEN: "test-only", REPO_DEV: "example/dev", [name]: value }), new RegExp(`Missing required environment variables for webserver: ${name}`));
+ });
+ }
+}
+
test("ensureEnvVars throws when a required var is missing", async () => {
delete process.env.DISCORD_TOKEN;
const { ensureEnvVars } = await importEnv();
diff --git a/test/ip-security.test.js b/test/ip-security.test.js
new file mode 100644
index 0000000..46ab29d
--- /dev/null
+++ b/test/ip-security.test.js
@@ -0,0 +1,33 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { createRequire } from "node:module";
+import { ipKeyGenerator } from "express-rate-limit";
+
+// Exercise the limiter's actual dependency, including a nested installation.
+const limiterRequire = createRequire(createRequire(import.meta.url).resolve("express-rate-limit"));
+const { Address6 } = limiterRequire("ip-address");
+
+// Run potentially blocking validation outside the test runner. The vulnerable
+// version takes many seconds on this input; the patch rejects it in milliseconds.
+test("oversized IPv6 zones with line terminators are rejected without stalling", () => {
+ const result = spawnSync(process.execPath, ["-e", `
+ const assert = require('node:assert/strict');
+ const { Address6 } = require(${JSON.stringify(limiterRequire.resolve("ip-address"))});
+ for (const terminator of ['\\n', '\\r', '\\u2028', '\\u2029']) {
+ assert.equal(Address6.isValid('%'.repeat(200000) + terminator), false);
+ }
+ console.log('rejected all oversized zones');
+ `], { cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8", timeout: 2000 });
+ assert.ifError(result.error);
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(result.signal, null);
+ assert.match(result.stdout, /rejected all oversized zones/);
+});
+
+test("IPv6 zone parsing and existing limiter address grouping remain compatible", () => {
+ assert.equal(new Address6("fe80::1%eth0").zone, "%eth0");
+ assert.equal(ipKeyGenerator("::ffff:192.0.2.1"), "192.0.2.1");
+ assert.equal(ipKeyGenerator("2001:db8:abcd:1200::1"), ipKeyGenerator("2001:db8:abcd:12ff::2"));
+});
diff --git a/test/redirect-security.test.js b/test/redirect-security.test.js
new file mode 100644
index 0000000..c9bc64d
--- /dev/null
+++ b/test/redirect-security.test.js
@@ -0,0 +1,64 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import http from "node:http";
+import { once } from "node:events";
+import { createRequire } from "node:module";
+
+// Exercise Axios's actual dependency, even when it is nested rather than hoisted.
+const axiosRequire = createRequire(createRequire(import.meta.url).resolve("axios"));
+const followRedirects = axiosRequire("follow-redirects");
+
+async function redirectedHeaders(t, headers, sensitiveHeaders = []) {
+ let received;
+ const target = http.createServer((req, res) => {
+ received = req.headers;
+ res.end("ok");
+ });
+ target.listen(0, "127.0.0.1");
+ await once(target, "listening");
+ const origin = http.createServer((req, res) => {
+ req.resume();
+ res.writeHead(302, { Location: `http://127.0.0.1:${target.address().port}/` });
+ res.end();
+ });
+ origin.listen(0, "127.0.0.1");
+ await once(origin, "listening");
+ t.after(async () => {
+ for (const server of [origin, target]) {
+ server.closeAllConnections();
+ await new Promise(resolve => server.close(resolve));
+ }
+ });
+ if (Array.isArray(headers)) {
+ headers = ["Host", `127.0.0.1:${origin.address().port}`, ...headers];
+ }
+ await new Promise((resolve, reject) => {
+ const request = followRedirects.http.get({
+ protocol: "http:", hostname: "127.0.0.1", port: origin.address().port,
+ path: "/", headers, sensitiveHeaders
+ }, response => {
+ response.resume();
+ response.on("end", resolve);
+ response.on("error", reject);
+ });
+ request.on("error", reject);
+ });
+ assert.ok(received, "the redirected target must receive a request");
+ return received;
+}
+
+test("redirects strip array-form credentials across origins", { timeout: 5000 }, async t => {
+ const headers = await redirectedHeaders(t, ["Authorization", "test-marker", "Cookie", "test-cookie", "Proxy-Authorization", "test-proxy"]);
+ for (const name of ["authorization", "cookie", "proxy-authorization"]) {
+ assert.equal(headers[name], undefined, `${name} must not cross origins`);
+ }
+});
+
+test("redirects strip sensitive header names containing regex metacharacters", { timeout: 5000 }, async t => {
+ const headers = await redirectedHeaders(t, {
+ "X-Api^Key": "test-marker", "X-Token|Secret": "test-marker", "X-Control": "preserved"
+ }, ["X-Api^Key", "X-Token|Secret"]);
+ assert.equal(headers["x-api^key"], undefined);
+ assert.equal(headers["x-token|secret"], undefined);
+ assert.equal(headers["x-control"], "preserved");
+});
diff --git a/test/report-app.test.js b/test/report-app.test.js
new file mode 100644
index 0000000..7123796
--- /dev/null
+++ b/test/report-app.test.js
@@ -0,0 +1,446 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { once } from "node:events";
+import { request } from "node:http";
+import { createReportApp } from "../src/web/report-app.js";
+
+const fields = { title: "Test report", discord: "Test reporter", category: "Other", description: "Test observation", reference: "" };
+async function fixture(t, githubPost = async () => ({ status: 201 }), env = {}) {
+ let time = 0;
+ const calls = [];
+ const logs = [];
+ const app = createReportApp({
+ env: { GITHUB_TOKEN: "test-only-key", REPO_DEV: "example/dev", REPO_DATA_REPORT: "example/private-overlay", ALLOWED_ORIGINS: "https://example.test", ...env },
+ now: () => time,
+ logger: { error(...args) { logs.push(args); } },
+ githubPost(...args) { calls.push(args); return githubPost(...args); }
+ });
+ const server = app.listen(0, "127.0.0.1");
+ await once(server, "listening");
+ t.after(async () => {
+ server.closeAllConnections();
+ await new Promise(resolve => server.close(resolve));
+ });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ return {
+ app, server, base, calls, logs, setTime(value) { time = value; },
+ async post(route = "data", body = fields, extra = {}) {
+ const response = await fetch(`${base}/${route}`, {
+ method: "POST", headers: { "Content-Type": "application/json", Origin: "https://example.test", ...extra },
+ body: typeof body === "string" ? body : JSON.stringify(body)
+ });
+ return { status: response.status, headers: response.headers, body: await response.json() };
+ }
+ };
+}
+
+function stagedUpload(api, report = fields) {
+ const body = JSON.stringify(report);
+ let upload;
+ const result = new Promise((resolve, reject) => {
+ upload = request(`${api.base}/data`, { method: "POST", headers: {
+ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), Origin: "https://example.test"
+ } }, response => {
+ let text = "";
+ response.setEncoding("utf8");
+ response.on("data", chunk => { text += chunk; });
+ response.on("end", () => {
+ try { resolve({ status: response.statusCode, headers: response.headers, body: JSON.parse(text) }); } catch (error) { reject(error); }
+ });
+ });
+ upload.on("error", reject);
+ upload.flushHeaders();
+ });
+ result.catch(() => {}); // Teardown may close an upload after an assertion fails.
+ return { result, complete() { upload.end(body); } };
+}
+
+test("both routes share delivered-report policy and route to the website repository", async t => {
+ const api = await fixture(t);
+ assert.equal((await api.post()).status, 200);
+ const blocked = await api.post("issue");
+ assert.equal(blocked.body.error.code, "report_cooldown");
+ assert.equal(blocked.headers.get("Retry-After"), "600");
+ api.setTime(600000);
+ assert.equal((await api.post("issue")).status, 200);
+ assert.equal(api.calls[0][0], "https://api.github.com/repos/example/dev/issues");
+ assert.equal(api.calls[1][0], "https://api.github.com/repos/example/dev/issues");
+ assert.equal(api.calls[0][1].title, "[Other] Test report");
+ assert.match(api.calls[0][1].body, /\*\*Category:\*\* Other/);
+});
+
+for (const route of ["data", "issue", "DATA/", "ISSUE/"]) {
+ test(`${route} delivers without a private-overlay setting`, async t => {
+ const api = await fixture(t, undefined, { REPO_DATA_REPORT: undefined });
+ assert.equal((await api.post(route)).status, 200);
+ assert.equal(api.calls[0][0], "https://api.github.com/repos/example/dev/issues");
+ });
+}
+
+for (const route of ["data", "issue"]) {
+ for (const company of [{ toString: null }, {}, [], 0, false]) {
+ test(`${route}: non-text honeypot ${JSON.stringify(company)} is rejected before delivery`, async t => {
+ const api = await fixture(t);
+ const result = await api.post(route, { ...fields, company });
+ assert.equal(result.status, 400);
+ assert.equal(result.body.error.code, "invalid_report");
+ assert.equal(result.body.error.message, "company must be text.");
+ assert.equal(api.calls.length, 0);
+ assert.equal(api.logs.length, 0);
+ assert.equal((await api.post(route)).status, 200);
+ });
+ }
+}
+
+test("HTTP 20th/21st and rolling boundary across mixed routes", async t => {
+ const api = await fixture(t);
+ for (let index = 0; index < 20; index++) {
+ api.setTime(index * 600000);
+ assert.equal((await api.post(index % 2 ? "issue" : "data")).status, 200);
+ }
+ api.setTime(12000000);
+ const rejected = await api.post();
+ assert.equal(rejected.status, 429);
+ assert.equal(rejected.body.error.code, "report_window_limit");
+ assert.equal(rejected.headers.get("Retry-After"), "31200");
+ assert.equal(api.calls.length, 20);
+ api.setTime(43200000);
+ assert.equal((await api.post()).status, 200);
+});
+
+test("all three budgets overlap: header and reset use the longest wait", async t => {
+ const api = await fixture(t);
+ for (let index = 0; index < 19; index++) {
+ api.setTime(index * 600000);
+ await api.post();
+ }
+ api.setTime(42900000);
+ await api.post();
+ for (let index = 0; index < 6; index++) {
+ const result = await api.post("issue");
+ assert.equal(result.body.error.code, "report_window_limit");
+ assert.equal(result.headers.get("Retry-After"), "600");
+ assert.equal(Date.parse(result.body.resetAt), 43500000);
+ }
+});
+
+test("a pending GitHub call admits only one concurrent report", async t => {
+ let release;
+ let started;
+ const waiting = new Promise(resolve => { started = resolve; });
+ const api = await fixture(t, () => { started(); return new Promise(resolve => { release = resolve; }); });
+ const first = api.post();
+ await waiting;
+ const second = await api.post("issue");
+ assert.equal(second.body.error.code, "report_in_progress");
+ assert.equal(api.calls.length, 1);
+ release({ status: 201 });
+ assert.equal((await first).status, 200);
+});
+
+test("validation, parser and honeypot rejections consume no report allowance", async t => {
+ const api = await fixture(t);
+ assert.equal((await api.post("data", { ...fields, title: "" })).body.error.code, "invalid_report");
+ assert.equal((await api.post("issue", { ...fields, title: { unsafe: true } })).status, 400);
+ assert.equal((await api.post("issue", "{invalid")).body.error.code, "invalid_request");
+ assert.equal((await api.post("data", { ...fields, company: "bot" })).status, 200);
+ assert.equal((await api.post()).status, 200);
+ assert.equal(api.calls.length, 1);
+});
+
+test("invalid attempts are bounded across both routes", async t => {
+ const api = await fixture(t);
+ for (let index = 0; index < 5; index++) {
+ assert.equal((await api.post(index % 2 ? "issue" : "data", {})).status, 400);
+ }
+ const blocked = await api.post();
+ assert.equal(blocked.body.error.code, "attempt_limit");
+ assert.equal(blocked.headers.get("Retry-After"), "60");
+ api.setTime(60000);
+ assert.equal((await api.post()).status, 200);
+});
+
+test("length errors replace truncation and oversized parser errors are safe", async t => {
+ const api = await fixture(t);
+ const long = await api.post("data", { ...fields, description: "x".repeat(8001) });
+ assert.equal(long.body.error.code, "invalid_report");
+ assert.match(long.body.error.message, /8000/);
+ assert.equal((await api.post("data", { ...fields, description: "x".repeat(33000) })).body.error.code, "report_too_large");
+ assert.equal((await api.post("data", { ...fields, description: "x".repeat(8000) })).status, 200);
+});
+
+test("origin checks and single-hop trust remain unchanged", async t => {
+ const api = await fixture(t);
+ assert.equal(api.app.get("trust proxy"), 1);
+ assert.equal((await api.post("data", fields, { Origin: "https://example.test.evil.invalid" })).body.error.code, "forbidden_origin");
+ assert.equal(api.calls.length, 0);
+ assert.equal((await api.post()).status, 200);
+});
+
+test("IP normalization covers mapped IPv4, IPv6 notation and existing /56 grouping", async t => {
+ const api = await fixture(t);
+ assert.equal((await api.post("data", fields, { "X-Forwarded-For": "192.0.2.1" })).status, 200);
+ assert.equal((await api.post("issue", fields, { "X-Forwarded-For": "::ffff:c000:201" })).body.error.code, "report_cooldown");
+ assert.equal((await api.post("data", fields, { "X-Forwarded-For": "2001:db8:abcd:1200::1" })).status, 200);
+ assert.equal((await api.post("issue", fields, { "X-Forwarded-For": "2001:0db8:abcd:12ff:0:0:0:2" })).body.error.code, "report_cooldown");
+});
+
+for (const status of [401, 403, 404, 422, 429]) {
+ test(`confirmed GitHub ${status} rejection releases report allowance`, async t => {
+ let reject = true;
+ const api = await fixture(t, async () => {
+ if (reject) throw { response: { status, headers: { "retry-after": "120" }, data: "PRIVATE_PROVIDER_BODY" }, message: "PRIVATE_PROVIDER_MESSAGE" };
+ return { status: 201 };
+ });
+ const failure = await api.post();
+ assert.notEqual(failure.body.error.code, "delivery_uncertain");
+ if (status === 429 || status === 403) assert.equal(failure.headers.get("Retry-After"), "120");
+ reject = false;
+ if (status === 429 || status === 403) api.setTime(120000);
+ assert.equal((await api.post()).status, 200);
+ assert.doesNotMatch(JSON.stringify([failure, api.logs]), /PRIVATE_PROVIDER/);
+ });
+}
+
+for (const failure of [{ code: "ECONNABORTED" }, { code: "ECONNRESET" }, ...[500, 502, 503, 504].map(status => ({ response: { status } }))]) {
+ test(`ambiguous ${failure.code ?? failure.response.status} cannot automatically repeat a report`, async t => {
+ const api = await fixture(t, async () => { throw { ...failure, message: "PRIVATE_DESCRIPTION", config: { headers: { Authorization: "PRIVATE_SECRET" } } }; });
+ const result = await api.post();
+ assert.equal(result.body.error.code, "delivery_uncertain");
+ api.setTime(600000);
+ assert.equal((await api.post()).status, 409);
+ assert.equal(api.calls.length, 1);
+ assert.doesNotMatch(JSON.stringify([result, api.logs]), /PRIVATE_DESCRIPTION|PRIVATE_SECRET|Test observation|127\.0\.0\.1/);
+ });
+}
+
+test("proven connection failure releases quota and supplies safe retry guidance", async t => {
+ let fail = true;
+ const api = await fixture(t, async () => {
+ if (fail) throw { code: "ENOTFOUND" };
+ return { status: 201 };
+ });
+ assert.equal((await api.post()).headers.get("Retry-After"), "60");
+ fail = false;
+ assert.equal((await api.post()).status, 200);
+});
+
+test("unexpected successful provider status is not acknowledged as issue creation", async t => {
+ const api = await fixture(t, async () => ({ status: 200 }));
+ assert.equal((await api.post()).body.error.code, "delivery_uncertain");
+});
+
+test("null provider errors release pending state into uncertainty, not a permanent lock", async t => {
+ const api = await fixture(t, async () => { throw null; });
+ assert.equal((await api.post()).body.error.code, "delivery_uncertain");
+ api.setTime(600000);
+ assert.equal((await api.post()).status, 409);
+});
+
+test("case and trailing slash aliases preserve data formatting and the shared destination", async t => {
+ const api = await fixture(t);
+ assert.equal((await api.post("DATA/")).status, 200);
+ assert.equal(api.calls[0][0], "https://api.github.com/repos/example/dev/issues");
+ assert.equal(api.calls[0][1].title, "[Other] Test report");
+ api.setTime(600000);
+ assert.equal((await api.post("ISSUE/")).status, 200);
+ assert.equal(api.calls[1][0], "https://api.github.com/repos/example/dev/issues");
+});
+
+test("uncertain dedupe also covers equivalent route spellings", async t => {
+ const api = await fixture(t, async () => { throw { code: "ECONNABORTED" }; });
+ await api.post();
+ api.setTime(600000);
+ assert.equal((await api.post("DATA/")).status, 409);
+ assert.equal(api.calls.length, 1);
+});
+
+for (const scenario of [
+ { name: "primary reset", status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "3600" }, seconds: 3600 },
+ { name: "full Retry-After", status: 429, headers: { "retry-after": "7200" }, seconds: 7200 },
+ { name: "explicit zero Retry-After", status: 429, headers: { "retry-after": "0" }, seconds: 0 },
+ { name: "longest provider reset", status: 403, headers: { "retry-after": "120", "x-ratelimit-remaining": "0", "x-ratelimit-reset": "3600" }, seconds: 3600 },
+ { name: "secondary throttle without header", status: 403, headers: {}, message: "You have exceeded a secondary rate limit. PRIVATE_PROVIDER_DETAIL", seconds: 60 }
+]) {
+ test(`GitHub ${scenario.name} returns full safe provider wait`, async t => {
+ const api = await fixture(t, async () => { throw { response: { status: scenario.status, headers: scenario.headers, data: { message: scenario.message } } }; });
+ const result = await api.post();
+ assert.equal(result.body.error.code, "provider_rate_limit");
+ assert.equal(result.headers.get("Retry-After"), String(scenario.seconds));
+ assert.equal(result.body.retryAfterSeconds, scenario.seconds);
+ assert.equal(Date.parse(result.body.resetAt), scenario.seconds * 1000);
+ assert.doesNotMatch(JSON.stringify([result, api.logs]), /PRIVATE_PROVIDER_DETAIL/);
+ });
+}
+
+test("provider throttling blocks every client and both routes until the exact reset", async t => {
+ let fail = true;
+ const api = await fixture(t, async () => {
+ if (fail) throw { response: { status: 429, headers: { "retry-after": "120" } } };
+ return { status: 201 };
+ });
+ assert.equal((await api.post()).body.error.code, "provider_rate_limit");
+ fail = false;
+ for (const route of ["data", "issue", "DATA/", "ISSUE/"]) {
+ const result = await api.post(route, fields, { "X-Forwarded-For": `192.0.2.${route.length}` });
+ assert.equal(result.status, 503);
+ assert.equal(result.body.error.code, "provider_rate_limit");
+ assert.equal(result.headers.get("Retry-After"), "120");
+ }
+ assert.equal(api.calls.length, 1);
+ api.setTime(119999);
+ assert.equal((await api.post()).headers.get("Retry-After"), "1");
+ assert.equal(api.calls.length, 1);
+ api.setTime(120000);
+ assert.equal((await api.post()).status, 200);
+ assert.equal(api.calls.length, 2);
+});
+
+test("zero provider wait permits an immediate retry without consuming report allowance", async t => {
+ let fail = true;
+ const api = await fixture(t, async () => {
+ if (fail) throw { response: { status: 429, headers: { "retry-after": "0" } } };
+ return { status: 201 };
+ });
+ assert.equal((await api.post()).body.retryAfterSeconds, 0);
+ fail = false;
+ assert.equal((await api.post()).status, 200);
+});
+
+test("provider waits combine with local admission waits and retain uncertainty warnings", async t => {
+ const api = await fixture(t, async (_url, issue) => {
+ if (issue.title.includes("uncertain")) throw { code: "ECONNABORTED" };
+ throw { response: { status: 429, headers: { "retry-after": "50000" } } };
+ });
+ const key = { "X-Forwarded-For": "192.0.2.1" };
+ await api.post("data", { ...fields, title: "uncertain" }, key);
+ await api.post("issue", fields, { "X-Forwarded-For": "192.0.2.2" });
+ const cooldown = await api.post("data", fields, key);
+ assert.equal(cooldown.body.error.code, "provider_rate_limit");
+ assert.equal(cooldown.body.retryAfterSeconds, 50000);
+ api.setTime(600000);
+ const repeated = await api.post("data", { ...fields, title: "uncertain" }, key);
+ assert.equal(repeated.body.error.code, "delivery_uncertain");
+ assert.equal(repeated.body.retryAfterSeconds, 49400);
+ assert.match(repeated.body.error.message, /could not be confirmed/);
+ assert.equal(api.calls.length, 2);
+});
+
+test("a staged body admitted before provider throttling does not deliver during its hold", { timeout: 10000 }, async t => {
+ const api = await fixture(t, async () => {
+ throw { response: { status: 429, headers: { "retry-after": "120" } } };
+ });
+ const admitted = once(api.server, "request");
+ const upload = stagedUpload(api);
+ await admitted;
+ await api.post("issue", fields, { "X-Forwarded-For": "192.0.2.3" });
+ upload.complete();
+ const result = await upload.result;
+ assert.equal(result.status, 503);
+ assert.equal(result.body.error.code, "provider_rate_limit");
+ assert.equal(result.headers["retry-after"], "120");
+ assert.equal(api.calls.length, 1);
+});
+
+test("fifth cooldown rejection advertises the newly exhausted attempt wait", async t => {
+ const api = await fixture(t);
+ await api.post();
+ api.setTime(599000);
+ for (let index = 0; index < 4; index++) {
+ assert.equal((await api.post()).headers.get("Retry-After"), "1");
+ }
+ const fifth = await api.post();
+ assert.equal(fifth.body.error.code, "attempt_limit");
+ assert.equal(fifth.headers.get("Retry-After"), "60");
+ assert.equal(Date.parse(fifth.body.resetAt), 659000);
+ api.setTime(659000);
+ assert.equal((await api.post()).status, 200);
+});
+
+test("staged uploads combine reservation and attempt waits after body parsing", { timeout: 10000 }, async t => {
+ let rejectFirst;
+ let started;
+ const providerStarted = new Promise(resolve => { started = resolve; });
+ let deliveries = 0;
+ const api = await fixture(t, () => {
+ if (++deliveries > 1) return Promise.resolve({ status: 201 });
+ started();
+ return new Promise((resolve, reject) => { rejectFirst = reject; });
+ });
+ const admitted = new Promise(resolve => {
+ let headersSeen = 0;
+ // Express handles each request synchronously through admission, then waits
+ // for its body. No body completes before all five request events arrive.
+ api.server.on("request", () => { if (++headersSeen === 5) resolve(); });
+ });
+ const uploads = Array.from({ length: 5 }, () => stagedUpload(api));
+ await admitted;
+ assert.equal(api.calls.length, 0);
+ uploads[0].complete();
+ await providerStarted;
+ for (const upload of uploads.slice(1)) upload.complete();
+ const denials = await Promise.all(uploads.slice(1).map(upload => upload.result));
+ for (const denial of denials) {
+ assert.equal(denial.headers["retry-after"], "60");
+ assert.equal(Date.parse(denial.body.resetAt), 60000);
+ }
+ assert.equal(api.calls.length, 1);
+ rejectFirst({ response: { status: 422 } });
+ assert.equal((await uploads[0].result).status, 502);
+ api.setTime(10000);
+ assert.equal((await api.post()).headers.get("Retry-After"), "50");
+ api.setTime(60000);
+ assert.equal((await api.post()).status, 200);
+});
+
+test("pending different delivery cannot hide a known uncertain-report hold", { timeout: 10000 }, async t => {
+ let rejectPending;
+ let started;
+ const pendingStarted = new Promise(resolve => { started = resolve; });
+ let deliveries = 0;
+ const api = await fixture(t, () => {
+ if (++deliveries === 1) return Promise.reject({ code: "ECONNABORTED" });
+ started();
+ return new Promise((resolve, reject) => { rejectPending = reject; });
+ });
+ assert.equal((await api.post()).body.error.code, "delivery_uncertain");
+ api.setTime(600000);
+ const admitted = new Promise(resolve => {
+ let headersSeen = 0;
+ api.server.on("request", () => { if (++headersSeen === 2) resolve(); });
+ });
+ const different = stagedUpload(api, { ...fields, title: "Different report" });
+ const repeated = stagedUpload(api);
+ await admitted;
+ different.complete();
+ await pendingStarted;
+ repeated.complete();
+ const denied = await repeated.result;
+ assert.equal(denied.body.error.code, "delivery_uncertain");
+ assert.equal(denied.headers["retry-after"], "42600");
+ assert.equal(api.calls.length, 2);
+ rejectPending({ response: { status: 422 } });
+ assert.equal((await different.result).status, 502);
+ api.setTime(610000);
+ assert.equal((await api.post()).headers.get("Retry-After"), "42590");
+ assert.equal(api.calls.length, 2);
+});
+
+for (const scenario of [
+ { name: "parameter overflow", body: Array.from({ length: 1001 }, (_, index) => `p${index}=x`).join("&"), headers: { "Content-Type": "application/x-www-form-urlencoded" }, status: 413, code: "report_too_large" },
+ { name: "unsupported JSON charset", body: "{}", headers: { "Content-Type": "application/json; charset=shift_jis" }, status: 415, code: "unsupported_request" },
+ { name: "unsupported form charset", body: "title=test", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=shift_jis" }, status: 415, code: "unsupported_request" },
+ { name: "unsupported encoding", body: "{}", headers: { "Content-Encoding": "compress" }, status: 415, code: "unsupported_request" },
+ { name: "excessive form nesting", body: `a${"[b]".repeat(40)}=x`, headers: { "Content-Type": "application/x-www-form-urlencoded" }, status: 400, code: "invalid_request" }
+]) {
+ test(`parser ${scenario.name} is a retryable client error without delivery`, async t => {
+ const api = await fixture(t);
+ assert.ok(Buffer.byteLength(scenario.body) < 32768);
+ const result = await api.post("data", scenario.body, scenario.headers);
+ assert.equal(result.status, scenario.status);
+ assert.equal(result.body.error.code, scenario.code);
+ assert.equal(api.calls.length, 0);
+ assert.equal(api.logs.length, 0);
+ assert.equal((await api.post()).status, 200);
+ });
+}
diff --git a/test/report-form.test.js b/test/report-form.test.js
new file mode 100644
index 0000000..5b53f15
--- /dev/null
+++ b/test/report-form.test.js
@@ -0,0 +1,188 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import vm from "node:vm";
+
+async function loadForm(kind, fetch, search = "", transformHtml = html => html) {
+ const html = transformHtml(await readFile(new URL(`../web/${kind}/index.html`, import.meta.url), "utf8"));
+ const button = { disabled: false };
+ const status = { textContent: "", className: "" };
+ const form = {
+ resetCount: 0,
+ addEventListener(name, callback) { this.submit = callback; },
+ reset() { this.resetCount++; },
+ querySelector() { return button; },
+ querySelectorAll() { return Array.from(fields.values()).filter(field => field.maxLength); },
+ dataset: { endpoint: `/${kind}` }
+ };
+ const fields = new Map();
+ let time = 0;
+ class Clock extends Date { static now() { return time; } }
+ const context = vm.createContext({
+ document: { getElementById(id) {
+ if (id.endsWith("Form")) return form;
+ if (id.endsWith("Status")) {
+ return html.includes(`id="${id}"`) ? status : null;
+ }
+ if (!fields.has(id)) fields.set(id, { value: "", options: [] });
+ return fields.get(id);
+ } },
+ window: { location: { search } },
+ URLSearchParams, Date: Clock, console: { error() {} },
+ FormData: class { entries() { return [["title", "Retained draft"]]; } },
+ fetch
+ });
+ for (const script of html.matchAll(/])(?:\s+src="([^"]+)")?[^>]*>([\s\S]*?)<\/script\s*>/gi)) {
+ const code = script[1]
+ ? await readFile(new URL(`../web${script[1]}`, import.meta.url), "utf8")
+ : script[2];
+ vm.runInContext(code, context);
+ }
+ return { form, status, button, fields, html, setTime(value) { time = value; }, submit: () => form.submit({ preventDefault() {} }) };
+}
+
+for (const kind of ["data", "issue"]) {
+ test(`${kind}: displays the server's cooldown and retains fields`, async () => {
+ const ui = await loadForm(kind, async () => ({
+ ok: false, status: 429,
+ headers: { get() { return "600"; } },
+ async json() { return { error: { code: "report_cooldown", message: "Wait 10 minutes before submitting another report." }, retryAfterSeconds: 600 }; }
+ }));
+ await ui.submit();
+ assert.match(ui.status.textContent, /10 minutes/);
+ assert.equal(ui.form.resetCount, 0);
+ });
+ test(`${kind}: prevents double submission while delivery is pending`, async () => {
+ let release;
+ let calls = 0;
+ const ui = await loadForm(kind, () => { calls++; return new Promise(resolve => { release = resolve; }); });
+ const first = ui.submit();
+ assert.equal(ui.button.disabled, true);
+ assert.equal(ui.status.className, "status pending");
+ assert.equal(ui.status.textContent, "Submitting report...");
+ const css = await readFile(new URL("../web/styles.css", import.meta.url), "utf8");
+ const pendingStyle = css.match(/\.status\.pending\s*\{([^}]+)\}/)?.[1];
+ assert.ok(pendingStyle, "pending has an explicit CSS rule");
+ assert.match(pendingStyle, /display:\s*block/);
+ await ui.submit();
+ assert.equal(calls, 1);
+ release({ ok: true, async json() { return { ok: true }; } });
+ await first;
+ assert.equal(ui.form.resetCount, 1);
+ assert.equal(ui.status.className, "status success");
+ });
+
+ for (const code of ["invalid_report", "invalid_request", "unsupported_request", "report_too_large", "forbidden_origin", "provider_rate_limit", "delivery_rejected", "delivery_unavailable", "report_window_limit"]) {
+ test(`${kind}: preserves draft and presents typed ${code} error`, async () => {
+ const message = `Safe error for ${code}`;
+ const ui = await loadForm(kind, async () => ({ ok: false, status: 400, headers: { get() { return null; } }, async json() { return { error: { code, message } }; } }));
+ await ui.submit();
+ assert.match(ui.status.textContent, new RegExp(message));
+ assert.equal(ui.form.resetCount, 0);
+ assert.equal(ui.button.disabled, false);
+ });
+ }
+
+ for (const type of ["network", "non-json", "malformed-success", "delivery_uncertain"]) {
+ test(`${kind}: ${type} keeps draft and blocks automatic repeat`, async () => {
+ let calls = 0;
+ const ui = await loadForm(kind, async () => {
+ calls++;
+ if (type === "network") throw new Error("PRIVATE_CONNECTION_DETAIL");
+ return { ok: type !== "delivery_uncertain", status: 504, headers: { get() { return null; } }, async json() {
+ if (type === "non-json") throw new Error("PRIVATE_PROXY_HTML");
+ return type === "delivery_uncertain" ? { error: { code: type, message: "Delivery could not be confirmed. Check before repeating." } } : {};
+ } };
+ });
+ await ui.submit();
+ ui.setTime(43200000);
+ await ui.submit();
+ assert.equal(calls, 1);
+ assert.equal(ui.form.resetCount, 0);
+ assert.equal(ui.button.disabled, true);
+ assert.match(ui.status.textContent, /could not be confirmed/);
+ assert.doesNotMatch(ui.status.textContent, /PRIVATE_/);
+ });
+ }
+
+ test(`${kind}: expired client wait still requires fresh server authorization`, async () => {
+ let calls = 0;
+ const ui = await loadForm(kind, async () => {
+ calls++;
+ return { ok: false, status: 429, headers: { get() { return "600"; } }, async json() { return { error: { code: "report_cooldown", message: "Wait 10 minutes." }, retryAfterSeconds: 600 }; } };
+ });
+ await ui.submit();
+ ui.setTime(599999);
+ await ui.submit();
+ assert.equal(calls, 1);
+ ui.setTime(600000);
+ await ui.submit();
+ assert.equal(calls, 2);
+ assert.equal(ui.form.resetCount, 0);
+ assert.match(ui.status.textContent, /server will check the limits again/);
+ });
+
+ test(`${kind}: length validation catches programmatically filled overlong values`, async () => {
+ let calls = 0;
+ const ui = await loadForm(kind, async () => { calls++; });
+ ui.fields.set("description", { name: "description", value: "x".repeat(8001), maxLength: 8000 });
+ await ui.submit();
+ assert.equal(calls, 0);
+ assert.equal(ui.form.resetCount, 0);
+ assert.match(ui.status.textContent, /8000/);
+ assert.match(ui.html, /maxlength="8000"/);
+ assert.match(ui.html, /aria-live="polite"/);
+ assert.match(ui.html, /per client IP/);
+ assert.match(ui.html, /Your report, including your Discord handle, reference links and details, is published as a public GitHub issue\./);
+ });
+}
+
+test("data reports link to the public website issue list, not the private overlay", async () => {
+ const ui = await loadForm("data", async () => {});
+ assert.match(ui.html, /href="https:\/\/github\.com\/tarkovtracker-org\/TarkovTracker\/issues"/);
+ assert.doesNotMatch(ui.html, /href="[^"]*tarkov-data-overlay/);
+});
+
+test("browser retains the full provider wait beyond the reporting window", async () => {
+ let calls = 0;
+ const ui = await loadForm("issue", async () => {
+ calls++;
+ return { ok: false, status: 503, headers: { get() { return "50000"; } }, async json() { return { error: { code: "provider_rate_limit", message: "Wait for the provider." }, retryAfterSeconds: 50000 }; } };
+ });
+ await ui.submit();
+ ui.setTime(43200000);
+ await ui.submit();
+ assert.equal(calls, 1);
+ assert.equal(ui.form.resetCount, 0);
+ assert.equal(ui.button.disabled, false);
+});
+
+test("data prefills remain intact, including reference and leading newlines", async () => {
+ const search = new URLSearchParams({ title: "Prefilled task", reference: "https://example.test/proof", description: "\n\nObserved details" }).toString();
+ const ui = await loadForm("data", async () => {}, `?${search}`);
+ assert.equal(ui.fields.get("title").value, "Prefilled task");
+ assert.equal(ui.fields.get("reference").value, "https://example.test/proof");
+ assert.equal(ui.fields.get("description").value, "\n\nObserved details");
+});
+
+test("fixture loads case-insensitive script tags with closing whitespace", async () => {
+ const ui = await loadForm("issue", async () => ({
+ ok: false, status: 400, headers: { get() { return null; } },
+ async json() { return { error: { code: "invalid_report", message: "Keep the draft." } }; }
+ }), "", html => html.replaceAll(""));
+ await ui.submit();
+ assert.match(ui.status.textContent, /Keep the draft/);
+ assert.equal(ui.form.resetCount, 0);
+});
+
+test("fixture ignores script-like tag names in HTML and JavaScript strings", async () => {
+ const ui = await loadForm("issue", async () => ({
+ ok: false, status: 400, headers: { get() { return null; } },
+ async json() { return { error: { code: "invalid_report", message: "Keep the draft." } }; }
+ }), "", html => html
+ .replace("