fix(shard): stop an undecryptable uo-link token 500ing every live-shard route #107

Merged
whitlocktech merged 1 commits from fix/uolink-client-throw-and-sitemode-gate into main 2026-07-28 05:33:35 +00:00
Member

What & why

Two fixes found by a live smoke test of the site — all 200 routes in routes.manifest.json exercised at every access level (anonymous / player / moderator / editor / admin), plus a browser pass over the public site, player portal and admin panel.

uoLinkClient.call() resolved the uo-link config outside its try/catch. resolveConfig() decrypts the stored auth token, and secretBox.decrypt throws when the ciphertext can't be authenticated — SECRET_ENC_KEY rotated, or a DB dump restored into an environment keyed differently. That throw escaped the client entirely, breaking its documented "never throws / always returns { ok, data, status }" contract (module header, ARCHITECTURE.md, CLAUDE.md) and turning a misconfiguration into a hard 500 on every route that does a live sidecar round-trip:

Route Before After
GET /admin/uo-link/config 500 200
GET /{admin,player}/shard/char/:serial 500 503
GET /{admin,player}/shard/roster/:account 500 503
GET /{admin,player}/shard/vendors/:account 500 503

Public shard routes were unaffected — they read the DB via getSafe(), which never decrypts.

The fix moves resolveConfig() inside the try and logs the config-resolution failure at ERROR with a distinct message. Previously a wrong key looked identical to "the shard is offline", with nothing pointing at the real cause. Note GET /admin/uo-link/config returning 200 again matters for recovery: it is the screen an admin needs to re-enter the token, and having it 500 locked them out of the fix.

2. The admin Dashboard offered editors a control they can't use

PUT /admin/site-mode is adminOnly, but the site-mode toggle rendered for every staff role, and toggle() had a try/finally with no catch. An editor clicking it got an unhandled promise rejection (ApiError: Forbidden in the console) and zero UI feedback — the button just said "Saving…" and reverted.

Server-side enforcement was always correct, so this is UX, not a security hole. Gated on role === 'admin' — the rule AdminLayout already documents ("never show a non-admin a link that would 403") — and the rejection is now caught and surfaced.

Docs PR: RunicGateway/docs#(fix/uolink-client-contract-and-sitemode-gate)

How it was tested

  • Root cause isolated, not guessed: with the token re-saved so it decrypts, the same routes returned the correct graceful 503 with the sidecar still unreachable — the only variable was whether decrypt threw.
  • New regression test server/test/uoLinkClient.test.js — verified it fails against the unfixed client (2 of 3 cases) and passes after.
  • npm test (server): 437 pass / 0 fail. npm test (client): 43 pass / 0 fail. npm run build: clean.
  • Live re-verification: corrupted auth_token_enc in the DB to reproduce the original condition, restarted the server, confirmed the four routes now return 200/503 with zero stack traces in the log and the new uo-link config unreadable line present.
  • Live UI check: signed in as editor — the site-mode toggle is gone and the panel is read-only; signed in as admin — the toggle is still there.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why Two fixes found by a live smoke test of the site — all 200 routes in `routes.manifest.json` exercised at every access level (anonymous / player / moderator / editor / admin), plus a browser pass over the public site, player portal and admin panel. ### 1. An undecryptable uo-link token 500'd every live-shard route `uoLinkClient.call()` resolved the uo-link config **outside** its `try`/`catch`. `resolveConfig()` decrypts the stored auth token, and `secretBox.decrypt` throws when the ciphertext can't be authenticated — `SECRET_ENC_KEY` rotated, or a DB dump restored into an environment keyed differently. That throw escaped the client entirely, breaking its documented *"never throws / always returns `{ ok, data, status }`"* contract (module header, `ARCHITECTURE.md`, CLAUDE.md) and turning a misconfiguration into a hard 500 on every route that does a live sidecar round-trip: | Route | Before | After | |---|---|---| | `GET /admin/uo-link/config` | 500 | 200 | | `GET /{admin,player}/shard/char/:serial` | 500 | 503 | | `GET /{admin,player}/shard/roster/:account` | 500 | 503 | | `GET /{admin,player}/shard/vendors/:account` | 500 | 503 | Public shard routes were unaffected — they read the DB via `getSafe()`, which never decrypts. The fix moves `resolveConfig()` inside the `try` and logs the config-resolution failure at `ERROR` with a **distinct** message. Previously a wrong key looked identical to "the shard is offline", with nothing pointing at the real cause. Note `GET /admin/uo-link/config` returning 200 again matters for recovery: it is the screen an admin needs to re-enter the token, and having it 500 locked them out of the fix. ### 2. The admin Dashboard offered editors a control they can't use `PUT /admin/site-mode` is `adminOnly`, but the site-mode toggle rendered for **every** staff role, and `toggle()` had a `try`/`finally` with no `catch`. An editor clicking it got an unhandled promise rejection (`ApiError: Forbidden` in the console) and **zero** UI feedback — the button just said "Saving…" and reverted. Server-side enforcement was always correct, so this is UX, not a security hole. Gated on `role === 'admin'` — the rule `AdminLayout` already documents ("never show a non-admin a link that would 403") — and the rejection is now caught and surfaced. Docs PR: RunicGateway/docs#(fix/uolink-client-contract-and-sitemode-gate) ## How it was tested - **Root cause isolated, not guessed:** with the token re-saved so it decrypts, the same routes returned the correct graceful `503` *with the sidecar still unreachable* — the only variable was whether decrypt threw. - **New regression test** `server/test/uoLinkClient.test.js` — verified it **fails** against the unfixed client (2 of 3 cases) and passes after. - `npm test` (server): **437 pass / 0 fail**. `npm test` (client): **43 pass / 0 fail**. `npm run build`: clean. - **Live re-verification:** corrupted `auth_token_enc` in the DB to reproduce the original condition, restarted the server, confirmed the four routes now return 200/503 with zero stack traces in the log and the new `uo-link config unreadable` line present. - **Live UI check:** signed in as `editor` — the site-mode toggle is gone and the panel is read-only; signed in as `admin` — the toggle is still there. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-07-28 05:24:56 +00:00
fix(shard): stop an undecryptable uo-link token 500ing every live-shard route
All checks were successful
PR Checks / bot-install (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 44s
a6fd5659c4
`uoLinkClient.call()` resolved the uo-link config OUTSIDE its try/catch.
resolveConfig() decrypts the stored auth token, and secretBox.decrypt throws
when the ciphertext can't be authenticated — SECRET_ENC_KEY rotated, or a DB
dump restored into an environment keyed differently. That throw escaped the
client entirely, breaking its documented "never throws / always returns
{ ok, data, status }" contract and turning a misconfiguration into a 500 on
every route that does a live sidecar round-trip:

  GET /admin/uo-link/config
  GET /{admin,player}/shard/char/:serial
  GET /{admin,player}/shard/roster/:account
  GET /{admin,player}/shard/vendors/:account

Found by a live smoke test of all 200 routes at every access level. Public
shard routes were unaffected because they read the DB via getSafe(), which
never decrypts.

Move resolveConfig() inside the try so the failure returns the standard
{ ok: false } shape, and log it at ERROR with a distinct message: a wrong key
previously looked identical to "the shard is offline", with no clue why.
Those routes now degrade to 503, and GET /admin/uo-link/config returns 200
again — it is the screen an admin needs to re-enter the token and recover, so
having it 500 locked them out of the fix.

Also gate the admin Dashboard's site-mode toggle. PUT /admin/site-mode is
adminOnly, but the button rendered for every staff role, and toggle() had a
try/finally with no catch — so an editor clicking it got an unhandled promise
rejection and zero UI feedback. Gate the control on role === 'admin' (the rule
AdminLayout already documents: never show a non-admin a control that would 403)
and surface a message if the call is refused anyway.

Adds server/test/uoLinkClient.test.js, which fails against the unfixed client.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-07-28 05:32:29 +00:00
whitlocktech merged commit f6611231c4 into main 2026-07-28 05:33:35 +00:00
whitlocktech deleted branch fix/uolink-client-throw-and-sitemode-gate 2026-07-28 05:33:35 +00:00
Sign in to join this conversation.
No description provided.