Merge pull request 'feat(public): version/health surfacing + typed brand block' (#77) from feat/public-version-and-brand into main
All checks were successful
Build container images / build (push) Successful in 1m2s
Build container images / deploy (push) Successful in 33s

Reviewed-on: #77
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-19 17:38:20 +00:00
7 changed files with 549 additions and 10 deletions

View File

@@ -0,0 +1,21 @@
// ── Public API version / identity ──────────────────────────────────────────
//
// A tiny, dependency-free descriptor of this backend, surfaced on GET
// /public/status and GET /public/version. A client (notably the Android app)
// uses it to:
// • positively recognize a Runic Gateway backend on first-run (the `service`
// tag), instead of guessing from an incidental response shape; and
// • run a version-mismatch guard — compare `api` against the contract the
// client was built against and surface a clear "update required" state
// rather than mis-parsing a future, changed response.
//
// `api` is the coarse contract version (bumped only on a breaking re-shape, which
// would be a v2 mount); `server` is the informational package version.
const pkg = require('../../package.json')
module.exports = {
service: 'runic-gateway', // stable backend identifier for first-run detection
api: 'v1', // API contract version (matches the /api/v1 mount)
server: pkg.version || '0.0.0', // server package version (informational)
}

View File

@@ -3,6 +3,7 @@ const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model') const settings = require('../../../model/settings/settings.model')
const pages = require('../../../model/pages/pages.model') const pages = require('../../../model/pages/pages.model')
const mailer = require('../../../utils/mailer') const mailer = require('../../../utils/mailer')
const version = require('../../../config/version')
const { getUserFromRequest } = require('../../../utils/auth') const { getUserFromRequest } = require('../../../utils/auth')
const token = require('../../../auth/token') const token = require('../../../auth/token')
@@ -29,12 +30,22 @@ async function getStatus(req, res) {
return res.json({ return res.json({
mode: (await settings.get('site_mode')) || 'live', mode: (await settings.get('site_mode')) || 'live',
status_message: (await settings.get('status_message')) || '', status_message: (await settings.get('status_message')) || '',
// Identity + version, so a client's first-run probe recognizes a Runic
// Gateway backend and can run its version-mismatch guard in this one call.
version,
}) })
} catch (err) { } catch (err) {
return res.status(500).json({ message: 'Internal Server Error' }) return res.status(500).json({ message: 'Internal Server Error' })
} }
} }
// Lightweight, DB-free identity/version endpoint — the canonical target for a
// client's first-run recognition and its periodic version-mismatch guard, without
// touching settings or the database. Also serves as a cheap liveness check.
function getVersion(req, res) {
return res.json(version)
}
async function getPosts(req, res) { async function getPosts(req, res) {
const { category } = req.params const { category } = req.params
if (!posts.isValidUrlCategory(category)) { if (!posts.isValidUrlCategory(category)) {
@@ -153,6 +164,7 @@ async function contact(req, res) {
module.exports = { module.exports = {
getSettings, getSettings,
getStatus, getStatus,
getVersion,
getPosts, getPosts,
getPost, getPost,
getWikiCategories, getWikiCategories,

View File

@@ -13,19 +13,27 @@ const publicRouter = express.Router()
publicRouter.get( publicRouter.get(
'/settings', '/settings',
// #swagger.tags = ['Public'] // #swagger.tags = ['Public']
// #swagger.summary = 'Public site settings' // #swagger.summary = 'Public site settings + branding'
// #swagger.description = 'Whitelisted, non-sensitive settings the client needs to render the site.' // #swagger.description = 'Whitelisted, non-sensitive settings plus the per-shard brand block (name/colors/logo/hero/favicon) a client themes itself from, and derived registration / game-account-signup availability flags.'
/* #swagger.responses[200] = { description: 'Key/value settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[200] = { description: 'Public settings + branding', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicSettings" } } } } */
ctrl.getSettings, ctrl.getSettings,
) )
publicRouter.get( publicRouter.get(
'/status', '/status',
// #swagger.tags = ['Public'] // #swagger.tags = ['Public']
// #swagger.summary = 'Site mode / status' // #swagger.summary = 'Site mode / status'
// #swagger.description = 'Current site mode (live or maintenance) so the client can show the maintenance page.' // #swagger.description = 'Current site mode (live or maintenance) so the client can show the maintenance page, plus a version block (service id + API/server versions) for a client first-run probe and version-mismatch guard.'
/* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicStatus" } } } } */ /* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicStatus" } } } } */
ctrl.getStatus, ctrl.getStatus,
) )
publicRouter.get(
'/version',
// #swagger.tags = ['Public']
// #swagger.summary = 'Backend identity + version'
// #swagger.description = 'Lightweight, DB-free descriptor of this backend: a stable service id and the API/server versions. A client uses it to recognize a Runic Gateway backend on first-run and to run a version-mismatch guard. Doubles as a cheap liveness check.'
/* #swagger.responses[200] = { description: 'Backend version', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicVersion" } } } } */
ctrl.getVersion,
)
publicRouter.post( publicRouter.post(
'/contact', '/contact',
// #swagger.tags = ['Public'] // #swagger.tags = ['Public']

View File

@@ -1636,16 +1636,15 @@
"tags": [ "tags": [
"Public" "Public"
], ],
"summary": "Public site settings", "summary": "Public site settings + branding",
"description": "Whitelisted, non-sensitive settings the client needs to render the site.", "description": "Whitelisted, non-sensitive settings plus the per-shard brand block (name/colors/logo/hero/favicon) a client themes itself from, and derived registration / game-account-signup availability flags.",
"responses": { "responses": {
"200": { "200": {
"description": "Key/value settings", "description": "Public settings + branding",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "$ref": "#/components/schemas/PublicSettings"
"additionalProperties": true
} }
} }
} }
@@ -1662,7 +1661,7 @@
"Public" "Public"
], ],
"summary": "Site mode / status", "summary": "Site mode / status",
"description": "Current site mode (live or maintenance) so the client can show the maintenance page.", "description": "Current site mode (live or maintenance) so the client can show the maintenance page, plus a version block (service id + API/server versions) for a client first-run probe and version-mismatch guard.",
"responses": { "responses": {
"200": { "200": {
"description": "Site status", "description": "Site status",
@@ -1680,6 +1679,27 @@
} }
} }
}, },
"/api/v1/public/version": {
"get": {
"tags": [
"Public"
],
"summary": "Backend identity + version",
"description": "Lightweight, DB-free descriptor of this backend: a stable service id and the API/server versions. A client uses it to recognize a Runic Gateway backend on first-run and to run a version-mismatch guard. Doubles as a cheap liveness check.",
"responses": {
"200": {
"description": "Backend version",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicVersion"
}
}
}
}
}
}
},
"/api/v1/public/contact": { "/api/v1/public/contact": {
"post": { "post": {
"tags": [ "tags": [
@@ -13907,11 +13927,352 @@
"example": "" "example": ""
} }
} }
},
"version": {
"$ref": "#/components/schemas/PublicVersion"
} }
} }
} }
} }
}, },
"PublicVersion": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Backend identity + version (GET /public/version; also embedded in /public/status)."
},
"properties": {
"type": "object",
"properties": {
"service": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "runic-gateway"
},
"description": {
"type": "string",
"example": "Stable backend identifier for first-run recognition."
}
}
},
"api": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "v1"
},
"description": {
"type": "string",
"example": "API contract version (matches the /api/v1 mount)."
}
}
},
"server": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "1.0.0"
},
"description": {
"type": "string",
"example": "Server package version (informational)."
}
}
}
}
}
}
},
"Brand": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Per-shard branding (BRAND_* env, with admin overrides for name/contactEmail). A client themes itself from this — one instance runs as any shard. Asset fields (logo/hero/favicon) may be site-relative paths; resolve them against the site base URL."
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Runic Gateway"
}
}
},
"shortName": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Runic Gateway"
}
}
},
"tagline": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "an independent private Ultima Online shard"
}
}
},
"description": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"contactEmail": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": ""
}
}
},
"url": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": ""
}
}
},
"accent": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "#7f99bd"
},
"description": {
"type": "string",
"example": "Seed/accent color (hex) for theming."
}
}
},
"logo": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": ""
},
"description": {
"type": "string",
"example": "Logo URL or site-relative path; empty = no logo."
}
}
},
"hero": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "/assets/img/runic-emblem.png"
},
"description": {
"type": "string",
"example": "Hero image URL or site-relative path."
}
}
},
"favicon": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "/assets/img/favicon.ico"
},
"description": {
"type": "string",
"example": "Favicon URL or site-relative path."
}
}
}
}
}
}
},
"PublicSettings": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Public site settings + branding (GET /public/settings). Whitelisted string settings, plus derived availability flags and the brand block a client themes from. Additional whitelisted keys may appear."
},
"properties": {
"type": "object",
"properties": {
"site_title": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Runic Gateway"
}
}
},
"status_message": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": ""
}
}
},
"maintenance_message": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": ""
}
}
},
"registration": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"password": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
}
}
},
"sso": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
}
}
}
}
}
}
},
"gameAccountSignup": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"brand": {
"$ref": "#/components/schemas/Brand"
}
}
},
"additionalProperties": {
"type": "boolean",
"example": true
}
}
},
"DeletedId": { "DeletedId": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -575,8 +575,52 @@ const doc = {
properties: { properties: {
mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' }, mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' },
status_message: { type: 'string', example: '' }, status_message: { type: 'string', example: '' },
version: { $ref: '#/components/schemas/PublicVersion' },
}, },
}, },
PublicVersion: {
type: 'object',
description: 'Backend identity + version (GET /public/version; also embedded in /public/status).',
properties: {
service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' },
api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' },
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
},
},
Brand: {
type: 'object',
description:
'Per-shard branding (BRAND_* env, with admin overrides for name/contactEmail). A client themes itself from this — one instance runs as any shard. Asset fields (logo/hero/favicon) may be site-relative paths; resolve them against the site base URL.',
properties: {
name: { type: 'string', example: 'Runic Gateway' },
shortName: { type: 'string', example: 'Runic Gateway' },
tagline: { type: 'string', example: 'an independent private Ultima Online shard' },
description: { type: 'string' },
contactEmail: { type: 'string', example: '' },
url: { type: 'string', example: '' },
accent: { type: 'string', example: '#7f99bd', description: 'Seed/accent color (hex) for theming.' },
logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo.' },
hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path.' },
favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path.' },
},
},
PublicSettings: {
type: 'object',
description:
'Public site settings + branding (GET /public/settings). Whitelisted string settings, plus derived availability flags and the brand block a client themes from. Additional whitelisted keys may appear.',
properties: {
site_title: { type: 'string', example: 'Runic Gateway' },
status_message: { type: 'string', example: '' },
maintenance_message: { type: 'string', example: '' },
registration: {
type: 'object',
properties: { password: { type: 'boolean' }, sso: { type: 'boolean' } },
},
gameAccountSignup: { type: 'boolean', example: false },
brand: { $ref: '#/components/schemas/Brand' },
},
additionalProperties: true,
},
// Delete/mutation acknowledgements — each echoes the affected resource key // Delete/mutation acknowledgements — each echoes the affected resource key
// or a boolean flag rather than a { message } string. // or a boolean flag rather than a { message } string.
DeletedId: { DeletedId: {

View File

@@ -0,0 +1,57 @@
// Point the DB at a closed port before the pool is built; getPublic() is fully
// monkeypatched below so no query runs, and db.close() releases the pool at the
// end so the process exits cleanly.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
// Lock the /public/settings brand contract the mobile app themes itself from
// (§8.6 of the Android plan). Exercises settings.getPublic() against an in-memory
// fake by monkeypatching settings.db — no DB. The brand block is sourced from the
// BRAND_* config defaults, with admin site_title / contact_email overriding.
const settingsDb = require('../src/model/settings/settings.db')
const settings = require('../src/model/settings/settings.model')
const brand = require('../src/config/brand')
const db = require('../src/utils/db')
after(() => db.close())
let savedGetAll
beforeEach(() => {
savedGetAll = settingsDb.getAll
settingsDb.getAll = async () => [] // no stored settings → pure BRAND_* defaults
})
afterEach(() => {
settingsDb.getAll = savedGetAll
})
const THEMING_FIELDS = ['name', 'shortName', 'tagline', 'description', 'contactEmail', 'url', 'accent', 'logo', 'hero', 'favicon']
test('getPublic exposes the full brand theming contract the app depends on', async () => {
const pub = await settings.getPublic()
assert.ok(pub.brand, 'brand block present')
for (const key of THEMING_FIELDS) {
assert.ok(key in pub.brand, `brand.${key} present`)
}
// Defaults flow from BRAND_* config when nothing is stored.
assert.equal(pub.brand.name, brand.name)
assert.equal(pub.brand.accent, brand.accent)
assert.equal(pub.brand.logo, brand.logo)
assert.equal(pub.brand.hero, brand.hero)
assert.equal(pub.brand.favicon, brand.favicon)
// Never leak the Discord-only integer accent form to a public client.
assert.equal(pub.brand.accentInt, undefined)
})
test('admin site_title / contact_email override the brand defaults', async () => {
settingsDb.getAll = async () => [
{ key: 'site_title', value: 'My Shard' },
{ key: 'contact_email', value: 'hi@shard.tld' },
]
const pub = await settings.getPublic()
assert.equal(pub.brand.name, 'My Shard') // site_title overrides brand.name
assert.equal(pub.brand.contactEmail, 'hi@shard.tld') // contact_email overrides
assert.equal(pub.brand.accent, brand.accent) // colors still from config
})

View File

@@ -0,0 +1,36 @@
// Point the DB at a closed port BEFORE requiring the router (some public handlers
// build the pool). The /version endpoint itself is DB-free, so it answers without
// a connection; this just guarantees no stray query holds the process open.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const { startApp } = require('./_helper')
const version = require('../src/config/version')
const publicRouter = require('../src/router/v1/public/public.routes')
const db = require('../src/utils/db')
after(() => db.close())
test('version config carries the service id + api/server versions', () => {
assert.equal(version.service, 'runic-gateway') // stable first-run identifier
assert.equal(version.api, 'v1') // API contract version (matches /api/v1)
assert.equal(typeof version.server, 'string')
assert.ok(version.server.length > 0)
})
test('GET /public/version returns the identity block (DB-free, 200)', async () => {
const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
try {
const res = await fetch(app.url + '/api/v1/public/version')
assert.equal(res.status, 200)
const body = await res.json()
assert.equal(body.service, 'runic-gateway')
assert.equal(body.api, 'v1')
assert.equal(body.server, version.server)
} finally {
await app.close()
}
})