Release history
Changelog
Every new feature, fix and speed-up on s&box Explorer, newest first.
- Last 30 days
- 90
- Features shipped
- 233
- Fixes
- 218
- Days with changes
- 66
304 changes Clear filters
October 2026
2
-
Tests
3:32 AM
Make csrf_cors, mcp_security and js_referenced run on Windows
Details
csrf_cors and mcp_security spawned php -S with /dev/null for its output, which does not exist on Windows, so proc_open returned false and proc_terminate threw. They now use NUL there. js_referenced built its git ls-files command with single quotes, which cmd.exe does not understand, so the corpus was empty and every script in frontend/js looked unreferenced. It now uses double quotes.
All three were environment failures, not product bugs, and pass.
20d843c -
Maintenance
1:54 AM
Accept api/list baseline (63 endpoints)
Details
Adds ai/query, ai/chat/create, ai/chat/query (401 without a game session), shader/1/{crc} and package/dll/1/{ident}/{rev} (engine-token binaries). None are usable by the site.
1f62ceb
15
-
Docs
10:30 PM
Rewrite the README against the current code
Details
Corrects PHPStan level (1, not 5), the cache TTL table (from cache-constants.php), the service list (FacepunchApiService, no PackageService) and the test commands (only tests/Unit has Pest tests; the standalone runner and Vitest are documented). Adds data sources, player history, the two kinds of MCP key, the SALT option, the daily session cleanup and the cron jobs, and replaces the fork-and-PR section with the actual closed-source status and house style.
fceaafe -
Tests
6:08 PM
Run under the node environment
Details
Under vitest's jsdom environment on Node 24 the worker's response.blob() returns a Blob that jsdom's Response stringifies, so the offline-cache test failed with a JSON parse error on '[object Blob]'. The worker has no DOM dependency, so the file runs in the node environment.
2da9a7e -
Docs
5:50 PM
Correct the PHPStan note now that there is no CI
65a777e -
Maintenance
5:50 PM
Remove the GitHub Actions workflows and add npm run check
Details
The repository is private and Actions minutes are billed. The checks the workflows ran are unchanged and run locally with npm run check; AUDIT.md records the decision.
46b6053 -
Docs
5:43 PM
Rewrite AUDIT.md with final states, credential evidence, the CSP path and the advisory table
Details
Every finding now ends in Fixed, Partially Fixed, Deferred or Not Actionable. The summary counts are generated from the finding tables by tests/test_audit_counts.php, which runs with the hermetic suite so they cannot drift. Adds the Post-Audit Hardening section.
cac9227 -
Maintenance
5:27 PM
Track tools/.htaccess and tools/run-phpstan.sh
Details
/tools/* is ignored apart from a whitelist, so the deny file protecting the operator scripts and the PHPStan runner had not been committed; found by running the CI commands from a fresh clone.
307983d -
Maintenance
5:26 PM
Separate tests into hermetic, integration and live, and harden CI
Details
- tests/run-php.sh [hermetic|integration|live|all]: assertions forced on, APCu on; a test that cannot run says why and exits 2, which fails the run; sections skipped inside a passing test are counted and shown
- test_player_history exited 0 when no database was available and test_tool_auth exited 0 with the example CRON_KEY: both silent passes, now exit 2 with a reason
- tests/_support.php: requireDatabase / requireLiveApi / requireAssertions; tests/setup-integration.sh writes a config.php for integration runs
- ci.yml: frontend (lint, tests, builds), php (syntax, hermetic tests, pinned PHPStan) and integration (PostgreSQL service, real site) jobs; no secrets, no external APIs, concurrency and timeouts set
- audit.yml (npm advisories) and live.yml (Facepunch API) are scheduled/manual so third parties cannot fail a pull request
d9d67e4 -
Maintenance
5:23 PM
Run PHPStan at level 1 with no baseline, and fix what it found
Details
phpstan.neon carried ~90 ignore patterns that hid every class of error; it now runs level 1 (unknown functions, classes, methods, constants; possibly-undefined variables) over backend, auth and mcp with seven narrow, commented ignores. tools/run-phpstan.sh (npm run analyse:php) uses the version pinned in composer.lock.
Real bugs found:
- obscureIpAddress() did not exist, so the personal-data export fataled for anyone with session history
- cache-management-api called CacheManager::getInstance(), which does not exist (the class is static), so invalidating by tag or pattern fataled; pattern LIKE wildcards in the input are now escaped
- search-api sent Cache-Control: public for the signed-in user's search history and the admin analytics
- two CompressionMiddleware classes (pages and API) shared a name; the API one is ApiCompressionMiddleware
- backend/controllers/ duplicated BaseController and PackageController and nothing loaded it; backend/services/PackageService.php called functions that do not exist and had no callers
- an invalid MCP tool example repeated the 'action' key
6533174 -
Refactor
5:18 PM
Ensure claimed_orgs.is_public from deployed code, not per request
Details
The deploy has no migration step and the base schema is created by initializeDatabase() in config/config.php, which is never deployed, so existing installs can only be upgraded from code that is. ensureClaimedOrgsSchema() runs once per process and only when the catalogue lacks the column, replacing the unconditional ALTER; it is also called by getUserClaimedOrgs(), which selects the column and silently returned nothing on a fresh install until the claims API had been hit. config.example.php creates the column for new installs. tests/test_org_claims_schema.php covers existing and fresh installs.
2197b97 -
Maintenance
5:17 PM
Remove five frontend scripts nothing loads, and guard against new ones
Details
cache-management.js, dependency-graph.js, notification-bell.js, homepage-categories.js (an ES module the non-bundling build could never have run) and its only importer homepage-utils.js. Checked against templates, the core script list, imports and dynamic loads, the service worker, build config and tests. tests/test_js_referenced.php fails if a script in frontend/js is not referenced by another tracked file.
fbe3f30 -
Maintenance
5:14 PM
Take the in-range fixes for all 15 npm advisories
Details
npm update within the existing semver ranges (package.json unchanged): postcss, esbuild, vitest/vite, browserslist, nanoid, js-yaml, svgo, undici, fast-uri, brace-expansion, colord and their transitive dependencies. npm audit now reports 0 with and without dev dependencies; the CSS bundle is byte-identical and lint, tests and builds pass. npm 10.9.4's resolver crashes on this tree, so the update was run with npm 11 via npx; the lockfile format is unchanged and npm ci accepts it.
c391e49 -
Docs
5:03 PM
List the live-API tests
3c96eab -
Docs
5:02 PM
Add AUDIT.md
c5fbe62 -
Maintenance
5:01 PM
Add a hermetic PHP test runner and CI, drop unused npm packages
Details
- tests/run-php.sh (npm run test:php) runs the 32 standalone tests that need no config, database or network, with assertions forced on: test_global_stats_grid relies on assert() and passed vacuously under the default zend.assertions=-1
- .github/workflows/ci.yml runs ESLint, Stylelint, Vitest, the CSS build, a PHP syntax check and the PHP runner
- remove tabbable and fast-check, which nothing imports
e30bdc1 -
Style
12:58 PM
Use single-quoted escape map keys
42bedf2
September 2026
1
-
Maintenance
9:39 AM
Drop unused getVersionedLeaderboard
Details
Nothing called it; the versioned per-user leaderboard endpoints need a signed-in Steam user ID, which Explorer has no flow for.
c8a18a8
1
-
Style
12:53 AM
Give the lists a hierarchy and cut the post card down
Details
Every level of text on these pages was dimmed with opacity rather than given a colour, so a thread title, its author line and its view count all landed on roughly the same washed grey and nothing led the eye. Sixteen of those are named colours now - titles at 16.4:1, body at 9.7:1, meta at 5.8:1, three distinct levels instead of one - and the only opacity left is on things that are meant to be faded: the staff watermark, a disabled pager step, the pager ellipsis.
Forum and thread rows had no hover and no padding of their own, so they read as text stacked in a card rather than as rows you can click. They have inner padding, a rounded hover, a focus-visible state that matches, and the forum icons sit in a tinted chip instead of floating at 70% opacity.
The post card was 247px tall for a single line of text. Measured against the screenshot: the header bar was 47px for one 12px line, and the author column stacked avatar, name, badges and score down the side at 152px, which set the height of the whole row no matter how short the post was. The badges and score share one line now, the avatar is 44px, and the bar and body padding came down. Same post: 146px, bar 30px, column 85px. The point was never to make it dense, it was that a one-line reply should not look like a wall.
Also: the permalink and pager steps were 19px and 22px, under the 24px touch target; both clear it now.
Verified at 1400, 820 and 390 across all three forum views: no horizontal overflow, no heading-level skips, every piece of text at or above 4.5:1.
be89488
3
-
Docs
9:39 AM
Point the API section at a base path that answers
Details
Every endpoint in llms.txt was documented under /api/. That prefix only resolves single-word names that happen to match a physical file in backend/api; the .htaccess rule for anything with a slash in it falls through to the HTML front controller. So /api/packages, /api/package?ident=, /api/news, /api/status, /api/search/unified and /api/player - most of the file
- returned a web page rather than JSON to anyone who followed it. The working
router is /api.php/, which is what the site's own JavaScript calls, and every route listed here was re-checked against production through it.
The legacy single-word aliases are mentioned but not recommended: /api/health and /api/themes currently die with a fatal error, and /api/leaderboard and /api/activity-stream answer 503 because Redis is off in production. activity-stream is dropped rather than documented as something that never works.
Corrections found while checking the rest:
- similar-packages takes ?package=, not ?ident=.
- player-history takes ?ident= with range 24h, 7d, 30d, 90d, 1y or all, and its /global variant accepts only the first three. Both are now listed alongside /summary.
- There is no texture or particle package type; those are vtex and vpcf. The list also omitted clothing, vsnd, tmat and sent, which outrank most of the types it did name - clothing alone has over 1,500 packages. The upstream API reports 89 types, so the file now points at /api.php/packages/types for the live list.
- The cache TTLs were inverted: player data is 30 minutes and API responses are 5, not the other way round. Stale upstream responses are served as X-Cache: STALE, which was undocumented.
Game jams shipped without ever reaching the page list; both routes are added. Sixteen live endpoints that nothing documented are added too, and the MCP section now says how many tools there are and where the reference lives.
7a3b6bd -
Docs
9:28 AM
Correct the counts that had drifted, and cut it by a third
Details
The README disagreed with itself and with the code. It claimed 27 themes in the feature list and 24 in the theme table; there are 37. It documented PHPStan at level 2, which is 5. It described mcp/tools as "5 consolidated tool definitions" while listing sixteen tools and a six-file tree; there are seventeen files backing those sixteen tools. It pointed at bundle-css.bat, which no longer exists, and at /api-doc.php rather than /api-doc. It said "50+" scripts in tools/, where there are 81, and listed one file in docs/, where there are three. Languages were given as both 30 and "30+"; it is 30.
The Deployment section described "git pull origin master" on the server. That is not how this site has deployed for some time - pushing master to the cPanel remote runs .cpanel.yml, which builds, rsyncs against .deployignore, prunes files deleted since the last release and stamps the commit into config/build.php. That is now written down, without the host details.
Game jams, award winners, hidden gems, code search, storage explorer and package comparison all shipped without ever reaching the feature list. The list is now grouped by what a reader is looking for rather than being a flat run of twenty-three bullets.
The size came from two tables that existed only to go stale: every theme by filename, and thirty-five of the tools/ scripts. Both are now a sentence and a pointer, since themes are discovered from the directory at runtime and the script list changes weekly. 574 lines to 312.
e5c7d31 -
Refactor
9:04 AM
Cut the rail card back to what is about this package
Details
Theme, entry count and prize pool are facts about the jam, not about the package being looked at, and all three are already on the jam page a click away. The card is now the placing, any category won, and when the next deadline lands - six rows down to two.
It also no longer renders for a finished jam, where it had nothing left to say that the winner flair beside the title does not already say, and that flair now links to the jam anyway.
0e3171c
1
-
Refactor
6:30 PM
One version of each page, no switches
Details
Removes the old package page and org page that ?v2=0 still served, and the Explorer 2027 beta theme with its ?beta=1 override, beta homepage and cookies. Everyone gets the current package page (package-detail-v2) and org page (org-v2); anyone who had picked Explorer 2027 falls back to Default, which the theme manager already does for a theme that no longer exists.
Deleted: views/package-detail.php, package-detail-sections.php, package-detail-sidebar.php, the sections only they used (media-stage, media-gallery, loading-screen, reviews-teaser, rail-reception), frontend/org-page.php, views/beta-home.php, includes/explorer-2027.php, css/ee/sbox-explorer-2027.css and css/features/beta-home.css. The hooks in index.php, bootstrap.php, head.php, package-list.php and theme-manager.js go with them.
test_package_tabs.php now checks the current package page, keeps every check on the shared tab and stage scripts, and pins that neither switch comes back. Shared stylesheets and scripts stay: each is still in use by the current pages.
640d45e
2
-
Refactor
5:41 AM
One shared runtime, and the defects a full-site audit found
Details
A global script layer already existed, but most of the runtime had drifted into a partial that exactly one page includes, so 23 standalone pages ran without theming, modals, lazy loading or keyboard shortcuts - and 20 of them had pasted theme-manager.js back in to compensate. One shared partial now carries what every page type needs. Modal runtime went from 9 of 32 routes to 32, the announcement banner from 4 to 32, and vendor 404s from 21 a page load to none: 20 of those 21 libraries had no consumer at all.
Removed 15 files and 4 API endpoints that nothing referenced. Three of the endpoints had returned 500 for nine months; deleting the fourth repaired /api/display/preferences, which the published spec documents and which a dead file had been shadowing.
sboxskins.gg was flooding error_log and hanging page loads. Failures are remembered for ten minutes rather than one, single items are negative-cached like the catalogue already was, and the per-attempt connect noise is dropped while the caller's line is kept. Cold failure 4.0s, warm 0.00s, two log lines where there were nine.
A crawl of every public route then found: disabled pagination arrows were anchors carrying href="#", and the CSS that should dim them was written as :disabled, which never matches an anchor; /t/<type> pages rendered "Browse Games" as their h1 but fell through to the bare site name in the tab; org news author links opened internal profiles in a new tab.
And the changelog page was invisible. One .date-group wraps all 15,331px of it and a scroll-reveal asked for threshold 0.1 - 1,533px visible at once, which no ordinary viewport satisfies - so it set opacity 0 and never fired. Its commit bodies also double-broke every newline and inherited the accent colour.
12b9a78 -
Refactor
5:41 AM
Clear the stylesheet debt and stop routing to pages that do not exist
Details
The slow, low-contrast pages were carrying stylesheet debt behind them - rules that had been added over the top of each other rather than replaced. Cleared so the design work that follows has a level surface to build on.
Routing was resolving URLs to pages that were never there: a request for a name the site does not serve rendered a shell rather than a 404.
Also corrects claims the site made about itself that were no longer true, and removes the running commentary from the sources.
7542065
11
-
Maintenance
9:36 AM
Keep the stylelint config out of the webroot
Details
.stylelintignore was already excluded; the rc file it pairs with was not, and .json is not covered by the .htaccess FilesMatch deny list, so it would have been publicly fetchable dev clutter.
dc7ca62 -
Tests
4:50 AM
Lock in the invariants this refactor established
Details
22 checks over the admin tree: every nav link resolves, every page is reachable from the nav, the shell opens and closes exactly once per page, no page hand-rolls a head or footer, every POST handler validates CSRF through the shared helper, no native confirm(), no bare button variant, no inline <style>, no bare setInterval, and everything parses.
Writing it caught three more: mcp-knowledge's five row actions and the manifest review modal's close button were still missing the base .btn class, and analytics.php had a second <style> block buried in the body that the first extraction pass missed.
4655cac -
Maintenance
4:48 AM
Stop ignoring tracked tests and drop the dead footer.version string
Details
tests/ was in .gitignore while twenty of its files were committed, so every new test needed git add -f. Nothing on disk there is untracked, so removing the entry changes no file, only the friction.
footer.version ('Perpetual betaa' and its 29 translations) is referenced nowhere: the badge renders APP_VERSION and the deploy's commit hash, and takes its aria-label from footer.versionLabel. Removed one line per locale; all 30 files still parse.
8c8c183 -
Maintenance
4:46 AM
Restore the stylelint config and clear 3,148 ESLint errors
Details
npm run lint:css could not run at all: .stylelintrc.json lives on the server but was gitignored, so it was never in the repo. Pulled the server copy verbatim (it is the authoritative one) and un-ignored it; the lint scripts now cover admin/assets too, and the admin CSS is clean.
eslint --fix takes frontend/js from 3,163 errors to 15, all of it quote and indent formatting. Verified by node --check on all 57 changed files plus the vitest suite. Of the rest, 57 were no-undef against vendor globals (Swal, Chart, tippy and friends load as plain script tags) which the config now declares, and admin/assets/*.js is linted for the first time.
What is left is real, not formatting: 7 empty catch blocks, 2 useless assignments and 1 rethrow without a cause. frontend/css still reports 2,601 style-guide errors; those are auto-fixable but rewriting every site stylesheet is well outside an admin audit.
0e545c9 -
Refactor
4:27 AM
Move all 31 pages onto the shared page shell
Details
Each page rendered its own <head>, stylesheet list and footer by hand. Five of them (analytics-advanced, manifest-removals, org-claims-full, reputation-management, webhook-settings) never closed .admin-content, <main> or .admin-layout at all, and another five called renderAdminFooter() in the middle of the page with content after it.
Page CSS (1,673 lines that were inlined into PHP and re-sent on every request) now lives in admin/assets/pages/*.css and is cached and lintable. Titles are normalised to '<Page> - Admin - s&box Explorer'; the two pages that pulled theme-manager.js, and with it a dynamic load of 21 vendor scripts none of them used, no longer do.
deeea32 -
Style
4:00 AM
Drop the last package section heading icon
Details
config-details carried a tune icon and leaderboards a military_tech one; the other nine sections have none. All eleven headings are plain text now.
29b64b5 -
Refactor
3:28 AM
Move APP_VERSION into constants.php
Details
It was defined in config/config.php, which holds the database credentials and CRON_KEY, is gitignored, and is excluded from deployment. So the one string on the site that changes most often was the one string git could never touch: bumping it meant a hand edit on the live server, which is how 1.0.0 got there.
constants.php is tracked and deploys normally, so a version bump is now an ordinary commit.
Guarded with its own defined() check rather than folding it into the block below, because bootstrap loads constants.php before config.php: an install whose config.php still defines APP_VERSION would otherwise hit a redefinition warning instead of quietly keeping its own value.
The live server was patched by hand to the identical content, verified byte-for-byte against this commit, so the next deploy is a no-op for both files and the footer never lost the version.
90e8177 -
Revert
3:24 AM
Keep .htaccess in the deployed set
Details
Excluding it was based on two wrong readings of the outage.
The cPanel-generated handler block is not required. The site is serving now, PHP executing normally, with an .htaccess that does not contain it, so the AddHandler is configured at server level and that block is belt-and-braces cPanel writes in. The outage was entirely the document root dropping to mode 700 under rsync -p; restoring .htaccess from the backup was chasing the wrong cause and did not fix anything.
The five /api/* rewrites are also not redundant. I tested those routes after the repo version of .htaccess had been put back on the server, so they passed because those very rules were live, and I credited an upstream fallback that does not exist.
.htaccess is a normal tracked file and deploys like any other.
c87be7a -
Maintenance
3:20 AM
Ship 1.0.0
Details
The footer badge had read 'beta 0.0.3' for about a year. The site has a full feature set, a public API, an MCP server and 30 locales; the beta label stopped being accurate a long time ago.
APP_VERSION lives in config/config.php, which is gitignored and excluded from deployment, so the live value was changed on the server directly. This updates the template so a fresh install does not start at 0.0.1 again.
950b1cb -
Maintenance
2:59 AM
Add the rsync exclusion list for cPanel deployment
Details
Names every path the live site owns and git must never write over: the SQLite databases and their write-ahead logs, everything under data/ (chart_history is appended to on ordinary page views), the caches, the logs, .env, config/config.php, and the config files the site rewrites at runtime. Also drops development-only files that have no business being publicly readable.
Committed ahead of .cpanel.yml on purpose. cPanel deploys on push as soon as it sees .cpanel.yml, so the exclusion list goes up first and gets verified with a dry run before anything is armed.
b80ff07 -
Maintenance
2:35 AM
Clean up the repository root and unbreak the lint tooling
Details
Audit of the 27 root-level files. Most were fine; these were not.
Dead ignore files ----------------- .eslintignore has been unsupported since ESLint 9, and package.json pins eslint ^10 with a flat config. Its four patterns move into an ignores block in eslint.config.js, which is what actually keeps frontend/js/dist out of the lint run: lint:js globs frontend/js/**/*.js, so dist was in scope.
.prettierignore had nothing to ignore for. There is no prettier dependency, script or config anywhere in the repo; formatting is php-cs-fixer, eslint and stylelint.
composer.lock ------------- The blanket *.lock rule was excluding it, so PHP dependency versions were not reproducible. This is an application, not a library, so the lockfile belongs in git. Narrowed the rule and generated the lock.
Database dumps -------------- A 38MB pg_dump of the production database was sitting in the webroot with no reference to it anywhere, kept out of git only by .git/info/exclude, which is per-clone and not shared. Moved out of the tree, and now ignored by name so the next one cannot be committed by someone whose clone lacks that local exclude.
Apache was not denying it either: the block list covered env, ini, log, sh, sql, bak and config, but not dump. Added.
Docs ---- facepunch.md and MCP_TOOLS_AUDIT.md move into docs/, which already exists and held one file.
facepunch.md also documented three things that are not true, which is what sent global-stats looking for live games in the wrong place. Verified against the live API and corrected: sort only works as a token inside q, not as a query parameter; downloads and rating come back in exactly the same order as an invented token, so they are not honoured; an unrecognised token silently falls back to popular rather than erroring; and take caps at 500, with 501 returning HTTP 400.
Also removed: SCMM.json, a 420KB OpenAPI spec for a service that shut down. Its consumer went with the skins rebuild and skins-retired.php now says as much on the page. It was never tracked.
d063b01
Generated from the project's commit history whenever the site is deployed. Times are server time.