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