feat(public): type the brand block so mobile clients get typed theming
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
@@ -13,9 +13,9 @@ 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(
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": {
|
"DeletedId": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -587,6 +587,40 @@ const doc = {
|
|||||||
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
|
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: {
|
||||||
|
|||||||
57
server/test/publicBrand.test.js
Normal file
57
server/test/publicBrand.test.js
Normal 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
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user