// White-label: a brand.json next to the data dir, applied only with the `whitelabel` entitlement (an unlicensed server // reports what it found and keeps the default). Read on every request, so an edit shows on the next reload. import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { z } from "zod"; import { DATA_DIR } from "./config.ts"; import { entitled } from "./enterprise.ts"; const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/; const MAX_LOGO_CHARS = 300_000; const MAX_FAVICON_CHARS = 120_000; export const brandSchema = z .object({ /** Product name shown in the window title, onboarding and settings. */ name: z.string().trim().min(1, "name must not be empty").max(40, "name must be 40 characters or fewer"), /** One line under the name on the welcome screen. */ tagline: z.string().trim().max(120, "tagline must be 120 characters or fewer").optional(), /** Accent colour for buttons, links and focus rings; text on it is derived for contrast. */ accent: z.string().regex(HEX_COLOUR, "accent must be a 6-digit hex colour such as #C2510A").optional(), /** Welcome-screen mark: an inline data:image/... URI or an https:// URL. */ logo: z .string() .max(MAX_LOGO_CHARS, `logo must be under ${MAX_LOGO_CHARS} characters (inline a small SVG or link an https:// image)`) .refine((v) => v.startsWith("data:image/") || v.startsWith("https://"), "logo must be a data:image/... URI or an https:// URL") .optional(), /** Where "get help" points for this deployment. */ supportUrl: z.string().url().startsWith("https://", "supportUrl must be an https:// URL").optional(), /** Tab and home-screen icon: an inline data:image/... URI (PNG or SVG, small) or an https:// URL. */ favicon: z .string() .max(MAX_FAVICON_CHARS, `favicon must be under ${MAX_FAVICON_CHARS} characters (a 64px PNG or a small SVG)`) .refine((v) => v.startsWith("data:image/") || v.startsWith("https://"), "favicon must be a data:image/... URI or an https:// URL") .optional(), }) .strict(); export type Brand = z.infer; export const DEFAULT_BRAND: Brand = { name: "OpenMausBot" }; export interface BrandStatus { brand: Brand; /** "file" when brand.json is applied; "default" otherwise (see notice for why). */ source: "default" | "file"; /** The path consulted, so an operator knows where to put the file. */ file: string; /** Why the file was not applied, in terms of what to change. */ notice?: string; } export function brandFile(): string { return process.env.OMB_BRAND_FILE || join(DATA_DIR, "brand.json"); } /** Never throws. */ export function loadBrand(options: { file?: string; isEntitled?: (feature: string) => boolean } = {}): BrandStatus { const file = options.file ?? brandFile(); const isEntitled = options.isEntitled ?? entitled; const fallback = (notice?: string): BrandStatus => notice ? { brand: DEFAULT_BRAND, source: "default", file, notice } : { brand: DEFAULT_BRAND, source: "default", file }; if (!existsSync(file)) return fallback(); let raw: unknown; try { raw = JSON.parse(readFileSync(file, "utf8")); } catch (error) { return fallback(`${file} is not valid JSON (${error instanceof Error ? error.message : String(error)}); using the default brand`); } const parsed = brandSchema.safeParse(raw); if (!parsed.success) { const issue = parsed.error.issues[0]; const where = issue?.path.length ? `${issue.path.join(".")}: ` : ""; return fallback(`${file}: ${where}${issue?.message ?? "invalid"}; using the default brand`); } if (!isEntitled("whitelabel")) { return fallback(`${file} found but this server is not licensed for whitelabel; using the default brand`); } return { brand: parsed.data, source: "file", file }; } export function describeBrand(status: BrandStatus): string { if (status.source === "file") return `brand: ${status.brand.name} (from ${status.file})`; return `brand: default${status.notice ? ` (${status.notice})` : ""}`; }