From 90c8eae20f5321c248a8e707d8eb05a2f4815a92 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sun, 19 Jul 2026 11:47:26 -0500 Subject: [PATCH 1/2] feat(public): version/health surfacing for the app first-run probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose a small backend identity/version descriptor (§8.4 of the Android plan) so a client can positively recognize a Runic Gateway backend on first-run and run a version-mismatch guard, instead of inferring from an incidental shape. - New config/version.js: { service: 'runic-gateway', api: 'v1', server: }. - GET /public/status now includes a `version` block (the app already calls this on first-run, so it gets identity + version in one round trip). - New GET /public/version: a lightweight, DB-free identity endpoint — the canonical target for the version guard and a cheap liveness check. - Swagger: PublicVersion schema + version on PublicStatus; /version annotated. - test/publicVersion.test.js covers the config shape and the DB-free 200. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr --- server/src/config/version.js | 21 ++++ .../src/router/v1/public/public.controller.js | 12 +++ server/src/router/v1/public/public.routes.js | 10 +- server/swagger/swagger-output.json | 95 ++++++++++++++++++- server/swagger/swagger.js | 10 ++ server/test/publicVersion.test.js | 36 +++++++ 6 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 server/src/config/version.js create mode 100644 server/test/publicVersion.test.js diff --git a/server/src/config/version.js b/server/src/config/version.js new file mode 100644 index 0000000..0f2c4f9 --- /dev/null +++ b/server/src/config/version.js @@ -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) +} diff --git a/server/src/router/v1/public/public.controller.js b/server/src/router/v1/public/public.controller.js index 041a423..11a6a64 100644 --- a/server/src/router/v1/public/public.controller.js +++ b/server/src/router/v1/public/public.controller.js @@ -3,6 +3,7 @@ const wiki = require('../../../model/wiki/wiki.model') const settings = require('../../../model/settings/settings.model') const pages = require('../../../model/pages/pages.model') const mailer = require('../../../utils/mailer') +const version = require('../../../config/version') const { getUserFromRequest } = require('../../../utils/auth') const token = require('../../../auth/token') @@ -29,12 +30,22 @@ async function getStatus(req, res) { return res.json({ mode: (await settings.get('site_mode')) || 'live', 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) { 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) { const { category } = req.params if (!posts.isValidUrlCategory(category)) { @@ -153,6 +164,7 @@ async function contact(req, res) { module.exports = { getSettings, getStatus, + getVersion, getPosts, getPost, getWikiCategories, diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index a11504e..3455ccf 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -22,10 +22,18 @@ publicRouter.get( '/status', // #swagger.tags = ['Public'] // #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" } } } } */ 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( '/contact', // #swagger.tags = ['Public'] diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index ddbeb4d..ae82e82 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1662,7 +1662,7 @@ "Public" ], "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": { "200": { "description": "Site status", @@ -1680,6 +1680,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": { "post": { "tags": [ @@ -13907,6 +13928,78 @@ "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)." + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index bd95200..a8930de 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -575,6 +575,16 @@ const doc = { properties: { mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' }, 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).' }, }, }, // Delete/mutation acknowledgements — each echoes the affected resource key diff --git a/server/test/publicVersion.test.js b/server/test/publicVersion.test.js new file mode 100644 index 0000000..49a88bf --- /dev/null +++ b/server/test/publicVersion.test.js @@ -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() + } +}) From c35509e8b33d8566740ebcae9cce77cf0953d534 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sun, 19 Jul 2026 11:58:01 -0500 Subject: [PATCH 2/2] feat(public): type the brand block so mobile clients get typed theming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branding is already returned by GET /public/settings (the `brand` block: name/colors/logo/hero/favicon, per-shard from BRAND_*). §8.6 of the Android plan asks to confirm it — this makes it a first-class part of the contract so the app's OpenAPI codegen produces typed branding instead of an untyped map. - Swagger: add Brand + PublicSettings schemas; /public/settings now references PublicSettings (was additionalProperties:true). Brand documents that asset fields may be site-relative paths (resolve against the base URL). - test/publicBrand.test.js locks the brand theming contract the app depends on (all fields present; BRAND_* defaults; admin site_title/contact_email overrides; accentInt never leaked). No behavior change to the response — it already carried `brand`; this types and guards it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr --- server/src/router/v1/public/public.routes.js | 6 +- server/swagger/swagger-output.json | 278 ++++++++++++++++++- server/swagger/swagger.js | 34 +++ server/test/publicBrand.test.js | 57 ++++ 4 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 server/test/publicBrand.test.js diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index 3455ccf..247f28b 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -13,9 +13,9 @@ const publicRouter = express.Router() publicRouter.get( '/settings', // #swagger.tags = ['Public'] - // #swagger.summary = 'Public site settings' - // #swagger.description = 'Whitelisted, non-sensitive settings the client needs to render the site.' - /* #swagger.responses[200] = { description: 'Key/value settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + // #swagger.summary = 'Public site settings + branding' + // #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: 'Public settings + branding', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicSettings" } } } } */ ctrl.getSettings, ) publicRouter.get( diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index ae82e82..529a50b 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1636,16 +1636,15 @@ "tags": [ "Public" ], - "summary": "Public site settings", - "description": "Whitelisted, non-sensitive settings the client needs to render the site.", + "summary": "Public site settings + branding", + "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": { "200": { - "description": "Key/value settings", + "description": "Public settings + branding", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/PublicSettings" } } } @@ -14005,6 +14004,275 @@ } } }, + "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": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index a8930de..b25f659 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -587,6 +587,40 @@ const doc = { 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 // or a boolean flag rather than a { message } string. DeletedId: { diff --git a/server/test/publicBrand.test.js b/server/test/publicBrand.test.js new file mode 100644 index 0000000..e4deac9 --- /dev/null +++ b/server/test/publicBrand.test.js @@ -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 +})