fix(shard): stop an undecryptable uo-link token 500ing every live-shard route #107
@@ -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