diff --git a/client/src/routes/admin/views/Dashboard.jsx b/client/src/routes/admin/views/Dashboard.jsx index e4eeafd..2ea25da 100644 --- a/client/src/routes/admin/views/Dashboard.jsx +++ b/client/src/routes/admin/views/Dashboard.jsx @@ -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 if (error) return @@ -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)}` : ''} + {modeError && ( +
+ {modeError} +
+ )} - + {isAdmin && ( + + )}
diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js index e0526c0..0a44099 100644 --- a/server/src/utils/uoLinkClient.js +++ b/server/src/utils/uoLinkClient.js @@ -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 { diff --git a/server/test/uoLinkClient.test.js b/server/test/uoLinkClient.test.js new file mode 100644 index 0000000..2817324 --- /dev/null +++ b/server/test/uoLinkClient.test.js @@ -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) +})