const settingsDb = require('./settings.db') const brand = require('../../config/brand') const { parseJsonSetting } = require('../../utils/settingsJson') const { resolveThemeTokens } = require('../../utils/themeResolve') const { resolveBrandAssets } = require('../../utils/brandAssets') // Keys safe to expose on the public site. const PUBLIC_KEYS = [ 'site_mode', 'maintenance_message', 'status_message', 'homepage_teaser', 'contact_email', 'site_title', 'hero_layout', // portal hero composition (JSON). Draft key stays admin-only. 'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1. 'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3. 'nav_public', // public site nav overrides (JSON). §6.4. ] // Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md). // All five are JSON strings and all five are ABSENT by default — no migration // seeds them. Absence of the row, not an empty value, is what makes a surface // fall back to BRAND_* env / the hardcoded theme.css / the hardcoded NAV arrays. // // nav_admin and nav_player are deliberately not public: an anonymous visitor has // no use for either, and the admin nav's labels describe the shape of the admin // surface. They are read by their owners through GET /api/v1/settings/nav (§4.2). const THEMING_KEYS = ['theme_visual', 'brand_assets', 'nav_public', 'nav_admin', 'nav_player'] // Keys a reset may delete. An explicit allowlist, not "any key": DELETE on an // arbitrary key would let a bad request drop site_mode or the uo-link config, // whose absence means something else entirely. hero_layout_draft is included // because discarding a draft is the same operation. const DELETABLE_KEYS = [...THEMING_KEYS, 'hero_layout_draft'] // Player self-registration mode. Stored under the 'player_registration' key. // NOTE: the raw value is never exposed publicly — getPublic() derives boolean // availability flags from it instead (see below). const REGISTRATION_KEY = 'player_registration' const REGISTRATION_MODES = ['disabled', 'password', 'sso', 'both'] // Resolve the registration mode, defaulting to 'disabled' (and coercing any // unexpected stored value back to 'disabled' so a bad row can't open sign-up). async function getRegistrationMode() { const value = await settingsDb.get(REGISTRATION_KEY) return REGISTRATION_MODES.includes(value) ? value : 'disabled' } // Derived, public-safe availability flags for the register page. function registrationFlags(mode) { return { password: mode === 'password' || mode === 'both', sso: mode === 'sso' || mode === 'both', } } // Game-account signup (Protocol 2.0). The admin picks who mints game accounts: // disabled — the site never offers game-account creation (link-only). // website — the site is the authority (offer creation; pair with the shard in // website mode + AutoCreateAccounts=false). // hybrid — either side may create (the site offers creation). // game — the game server is the authority; the site does NOT offer creation. // The site OFFERS creation only for 'website'/'hybrid'; the shard's own SignupMode // (Bridge.cfg) still has the final say and may 403 a call regardless. const GAME_SIGNUP_KEY = 'game_account_signup' const GAME_SIGNUP_MODES = ['disabled', 'website', 'hybrid', 'game'] const GAME_SIGNUP_OFFER = ['website', 'hybrid'] async function getGameSignupMode() { const v = await settingsDb.get(GAME_SIGNUP_KEY) return GAME_SIGNUP_MODES.includes(v) ? v : 'disabled' } async function isGameAccountSignupEnabled() { return GAME_SIGNUP_OFFER.includes(await getGameSignupMode()) } // Android App Links opt-in (M9 follow-up). When on, the shard auto-serves // /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the // self-origin https:///mobile/callback redirect. Stored as the string // 'true'/'false'; default off. See docs/android/APP_LINKS.md. const MOBILE_APP_LINKS_KEY = 'mobile_app_links_enabled' // Fail-closed: any read error (e.g. DB unavailable) reports "disabled" so a // transient fault can never open the https redirect path or serve assetlinks.json. async function isMobileAppLinksEnabled() { try { return String(await settingsDb.get(MOBILE_APP_LINKS_KEY)) === 'true' } catch { return false } } /** * This instance's name, resolved exactly as `getPublic().brand.name` resolves it — * the admin-editable site title wins over BRAND_NAME. Anything that has to *speak* * the instance's name outside the settings payload must use this rather than * `brand.name`, or an install that set only the site title gets two different names * on two different pages. * * Never throws: a name is always better than an error, so a DB fault falls back to * the env value. */ async function getInstanceName() { try { return (await settingsDb.get('site_title')) || brand.name } catch { return brand.name } } async function get(key) { return settingsDb.get(key) } async function set(key, value, updatedBy = null) { return settingsDb.set(key, value, updatedBy) } async function remove(key) { return settingsDb.remove(key) } async function setMany(obj, updatedBy = null) { for (const [key, value] of Object.entries(obj)) { await settingsDb.set(key, value, updatedBy) } } async function getAll() { const rows = await settingsDb.getAll() return rows.reduce((acc, row) => { acc[row.key] = row.value return acc }, {}) } async function getPublic() { const all = await getAll() const out = PUBLIC_KEYS.reduce((acc, key) => { if (all[key] !== undefined) acc[key] = all[key] return acc }, {}) // Derived registration availability (never the raw mode). Lets the register // page show/hide the password form and SSO buttons. const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled' out.registration = registrationFlags(mode) // Whether the site offers game-account creation (the shard's own mode still has // the final say when the call is made). Lets the portal show/hide the form. const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled' out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode) // The effective CSS custom properties for the admin's theme, or absent when // no theme_visual row exists (or nothing in it was usable). The SPA writes // these onto ; absence means it writes nothing and theme.css's :root // stands, which is what keeps an untouched instance byte-for-byte as today. // Resolution — :root ← preset ← custom — happens here rather than in CSS so // there is one authority and brand.accent below can report the same value the // site actually paints. See THEMING_AND_NAV.md §6. const theme = resolveThemeTokens(all.theme_visual) if (theme) out.theme = theme // Uploaded brand-asset overrides (§6.3), resolved here so every consumer of // the brand block — the SPA, the Android app, the Discord bot — picks them up // through the one contract. Forgiving on read like the theme: a slot holding // something we would not emit as a URL is dropped and its neighbours kept. const brandAssets = resolveBrandAssets(parseJsonSetting(all.brand_assets)) // Instance branding (BRAND_* env defaults). The admin-editable settings — // site title, contact email, and now the theme accent and uploaded assets — // override the env value when set, so existing installs keep their // DB-configured name; everything else comes from env. // // brand.accent is a CROSS-REPO CONTRACT: the Android app themes its whole // Material palette from it (BrandDto → RunicGatewayTheme) and the Discord bot // colors its embeds from it. Resolving the effective accent here is what lets // both track admin theming with no client change. out.brand = { name: out.site_title || brand.name, shortName: brand.shortName, tagline: brand.tagline, description: brand.description, contactEmail: out.contact_email || brand.contactEmail, url: brand.url, accent: theme?.['--accent'] || brand.accent, logo: brandAssets.logo || brand.logo, hero: brandAssets.hero || brand.hero, favicon: brandAssets.favicon || brand.favicon, } // Push-notification relay (M7). The client-facing ntfy base URL the app's // embedded distributor registers its device topic against; null when push is // not configured for this shard, in which case the app simply shows push as // unavailable. The publisher's own NTFY_BASE_URL may be an internal // compose-network address, so a distinct NTFY_PUBLIC_URL is preferred; failing // that we use the first NTFY_ALLOWED_ORIGINS entry (a device endpoint must sit // on an allowed origin anyway). Never NTFY_BASE_URL — it may be internal-only. out.push = { ntfyUrl: publicNtfyUrl() } // Whether this shard has opted into Android App Links (M9 follow-up). Lets a // native client tell whether it may request the https App Link redirect_uri // before doing so (the server would otherwise reject an unallowlisted one). The // custom-scheme callback works regardless of this flag. out.mobileAppLinks = String(all[MOBILE_APP_LINKS_KEY]) === 'true' return out } /** * What the HTML shell needs, resolved exactly as getPublic() resolves it: the * effective favicon and logo, plus the theme token map for the boot