Merge pull request 'fix(shard): stop an undecryptable uo-link token 500ing every live-shard route' (#107) from fix/uolink-client-throw-and-sitemode-gate into main
Reviewed-on: #107 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -4,9 +4,15 @@ import { useAsync } from '../../../lib/useAsync.js'
|
|||||||
import { ago, dateTime } from '../../../lib/format.js'
|
import { ago, dateTime } from '../../../lib/format.js'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { refresh: refreshSite } = useSite()
|
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 [tick, setTick] = useState(0)
|
||||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
|
|
||||||
@@ -15,6 +21,7 @@ export default function Dashboard() {
|
|||||||
[tick],
|
[tick],
|
||||||
)
|
)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [modeError, setModeError] = useState('')
|
||||||
|
|
||||||
if (loading) return <Loading />
|
if (loading) return <Loading />
|
||||||
if (error) return <ErrorState message="Could not load the dashboard." />
|
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' },
|
{ 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() {
|
async function toggle() {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
setModeError('')
|
||||||
try {
|
try {
|
||||||
await api.admin.setSiteMode(isLive ? 'maintenance' : 'live')
|
await api.admin.setSiteMode(isLive ? 'maintenance' : 'live')
|
||||||
await refreshSite()
|
await refreshSite()
|
||||||
reload()
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setModeError(
|
||||||
|
err.status === 403
|
||||||
|
? 'Only an administrator can change the site mode.'
|
||||||
|
: 'Could not change the site mode. Try again.',
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -78,15 +94,22 @@ export default function Dashboard() {
|
|||||||
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'}
|
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'}
|
||||||
{changed.at ? ` · ${dateTime(changed.at)}` : ''}
|
{changed.at ? ` · ${dateTime(changed.at)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
{modeError && (
|
||||||
|
<div className="sans" style={{ fontSize: '0.8rem', marginTop: 8, color: 'var(--danger, #d98b8b)' }}>
|
||||||
|
{modeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
{isAdmin && (
|
||||||
onClick={toggle}
|
<button
|
||||||
disabled={busy}
|
onClick={toggle}
|
||||||
className="sans"
|
disabled={busy}
|
||||||
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' }}
|
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>
|
{modeLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid-4" style={{ gap: 14, marginBottom: 28 }}>
|
<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
|
// with a parseable JSON body. Non-2xx responses still return their status + body
|
||||||
// so callers can distinguish 503 (shard restarting — transient) from 404.
|
// so callers can distinguish 503 (shard restarting — transient) from 404.
|
||||||
async function call(path, { method = 'GET', body } = {}) {
|
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 controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
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 {
|
try {
|
||||||
|
const config = await resolveConfig()
|
||||||
|
configResolved = true
|
||||||
|
if (!config || !config.baseUrl) {
|
||||||
|
return { ok: false, status: 0, error: 'uo-link is not configured' }
|
||||||
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-UOLink-Version': String(config.protocol || 1),
|
'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 }
|
return { ok: true, status: res.status, data }
|
||||||
} catch (err) {
|
} 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 })
|
log.warn('uo-link call failed', { path, message: err.message })
|
||||||
return { ok: false, status: 0, error: err.message }
|
return { ok: false, status: 0, error: err.message }
|
||||||
} finally {
|
} 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