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)
+})