fix(shard): stop an undecryptable uo-link token 500ing 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 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>
This commit is contained in:
@@ -4,9 +4,15 @@ import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago, dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const { user } = useAuth()
|
||||
// PUT /admin/site-mode is adminOnly. The dashboard itself is staff-wide, so the
|
||||
// toggle needs its own gate — same rule the sidebar follows (AdminLayout: never
|
||||
// show a non-admin a control that would 403).
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const [tick, setTick] = useState(0)
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
|
||||
@@ -15,6 +21,7 @@ export default function Dashboard() {
|
||||
[tick],
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [modeError, setModeError] = useState('')
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the dashboard." />
|
||||
@@ -32,12 +39,21 @@ export default function Dashboard() {
|
||||
{ value: dash.counts?.users ?? 0, label: 'Users' },
|
||||
]
|
||||
|
||||
// The rejection was previously unhandled: a refused toggle surfaced only as an
|
||||
// unhandled promise rejection in the console while the button silently reverted.
|
||||
async function toggle() {
|
||||
setBusy(true)
|
||||
setModeError('')
|
||||
try {
|
||||
await api.admin.setSiteMode(isLive ? 'maintenance' : 'live')
|
||||
await refreshSite()
|
||||
reload()
|
||||
} catch (err) {
|
||||
setModeError(
|
||||
err.status === 403
|
||||
? 'Only an administrator can change the site mode.'
|
||||
: 'Could not change the site mode. Try again.',
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -78,15 +94,22 @@ export default function Dashboard() {
|
||||
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'}
|
||||
{changed.at ? ` · ${dateTime(changed.at)}` : ''}
|
||||
</div>
|
||||
{modeError && (
|
||||
<div className="sans" style={{ fontSize: '0.8rem', marginTop: 8, color: 'var(--danger, #d98b8b)' }}>
|
||||
{modeError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
className="sans"
|
||||
style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }}
|
||||
>
|
||||
{modeLabel}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
className="sans"
|
||||
style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }}
|
||||
>
|
||||
{modeLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-4" style={{ gap: 14, marginBottom: 28 }}>
|
||||
|
||||
@@ -40,14 +40,22 @@ function invalidateConfig() {
|
||||
// with a parseable JSON body. Non-2xx responses still return their status + body
|
||||
// so callers can distinguish 503 (shard restarting — transient) from 404.
|
||||
async function call(path, { method = 'GET', body } = {}) {
|
||||
const config = await resolveConfig()
|
||||
if (!config || !config.baseUrl) {
|
||||
return { ok: false, status: 0, error: 'uo-link is not configured' }
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
// resolveConfig() decrypts the stored auth token, and decryption THROWS when the
|
||||
// ciphertext can't be authenticated — SECRET_ENC_KEY was rotated, or a DB dump was
|
||||
// restored into an environment keyed differently. It must stay INSIDE the try: out
|
||||
// here it escaped `call()` entirely and 500'd every live-shard route (admin and
|
||||
// player character/roster/vendor lookups, GET /admin/uo-link/config) instead of
|
||||
// degrading to "shard unavailable". This module never throws — see the header.
|
||||
let configResolved = false
|
||||
try {
|
||||
const config = await resolveConfig()
|
||||
configResolved = true
|
||||
if (!config || !config.baseUrl) {
|
||||
return { ok: false, status: 0, error: 'uo-link is not configured' }
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-UOLink-Version': String(config.protocol || 1),
|
||||
@@ -75,6 +83,16 @@ async function call(path, { method = 'GET', body } = {}) {
|
||||
}
|
||||
return { ok: true, status: res.status, data }
|
||||
} catch (err) {
|
||||
// A failure before the config resolved is a misconfiguration, not a flaky
|
||||
// sidecar: log it loudly (and distinctly) so "the shard looks offline" doesn't
|
||||
// silently mean "the token can no longer be decrypted".
|
||||
if (!configResolved) {
|
||||
log.error('uo-link config unreadable — is SECRET_ENC_KEY the key the stored token was encrypted with?', {
|
||||
path,
|
||||
message: err.message,
|
||||
})
|
||||
return { ok: false, status: 0, error: 'uo-link config unreadable' }
|
||||
}
|
||||
log.warn('uo-link call failed', { path, message: err.message })
|
||||
return { ok: false, status: 0, error: err.message }
|
||||
} finally {
|
||||
|
||||
71
server/test/uoLinkClient.test.js
Normal file
71
server/test/uoLinkClient.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// Point the DB at a closed port BEFORE requiring the modules (they build the pool).
|
||||
// The uoLinkConfig model is monkeypatched so no query runs.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// The uo-link REST client's headline contract (see its module header and
|
||||
// CLAUDE.md): it NEVER throws — every call resolves to { ok, data, status, error }
|
||||
// so a public page or an admin poll degrades to "shard unavailable" instead of
|
||||
// 500ing. The regression these tests lock down: 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 under a different
|
||||
// key). It used to run OUTSIDE call()'s try, so that throw escaped the client and
|
||||
// 500'd every live-shard route.
|
||||
const uoLinkClient = require('../src/utils/uoLinkClient')
|
||||
const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const origGetWithToken = uoLinkConfig.getWithToken
|
||||
afterEach(() => {
|
||||
uoLinkConfig.getWithToken = origGetWithToken
|
||||
uoLinkClient.invalidateConfig() // drop the 5s config cache between cases
|
||||
})
|
||||
|
||||
test('an undecryptable stored token resolves to { ok: false } instead of throwing', async () => {
|
||||
uoLinkConfig.getWithToken = async () => {
|
||||
// Exactly what crypto's Decipheriv.final() raises on a bad key / tampered blob.
|
||||
throw new Error('Unsupported state or unable to authenticate data')
|
||||
}
|
||||
uoLinkClient.invalidateConfig()
|
||||
|
||||
const result = await uoLinkClient.health()
|
||||
|
||||
assert.equal(result.ok, false, 'must report failure, not throw')
|
||||
assert.equal(result.status, 0)
|
||||
assert.match(result.error, /unreadable/i, 'distinguishes config failure from a dead sidecar')
|
||||
})
|
||||
|
||||
test('every read helper stays on the { ok:false } contract when config is unreadable', async () => {
|
||||
uoLinkConfig.getWithToken = async () => {
|
||||
throw new Error('Unsupported state or unable to authenticate data')
|
||||
}
|
||||
uoLinkClient.invalidateConfig()
|
||||
|
||||
// The routes that regressed: character sheet, roster and vendor lookups, which
|
||||
// are reachable from both /admin/shard/* and the player-facing /player/shard/*.
|
||||
for (const call of [
|
||||
() => uoLinkClient.getCharBySerial('0x1'),
|
||||
() => uoLinkClient.getRoster('someacct'),
|
||||
() => uoLinkClient.getVendors('someacct'),
|
||||
]) {
|
||||
const result = await call()
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('a missing/blank config still reports "not configured" (unchanged behaviour)', async () => {
|
||||
uoLinkConfig.getWithToken = async () => null
|
||||
uoLinkClient.invalidateConfig()
|
||||
|
||||
const result = await uoLinkClient.health()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 0)
|
||||
assert.match(result.error, /not configured/i)
|
||||
})
|
||||
Reference in New Issue
Block a user