// The public API of the branding module (plan item AT2.6, spec ยง10.3): an organization's own brand // -- its display name, two colours and two logos -- as the end customers of the app see it. It is a // layer over the app brand (config/src/brand.ts) and never replaces it: layout, type and the neutral // tokens stay the app's, so one tenant cannot break the app's accessibility. // // The data is `organizations.branding`, a column of the tenancy module's own table; this module // writes it through the tenancy module's entity and owns no table of its own. import { LOGO_UPLOAD_LIMIT_BYTES } from "@conusai-kit/brand/logo/check" import { procedure } from "@conusai-kit/core" import { z } from "zod" import { LOGO_SIZES } from "./logo.js" import { brandViewSchema, LOGO_VARIANTS } from "./view.js" export { LOGO_SIZES, type LogoChoice, type LogoSize, logoUrl, resolveLogo, tenantLogo } from "./logo.js" export { type BrandView, brandViewSchema, LOGO_VARIANTS, type LogoVariant } from "./view.js" /** The audit event of every change: one per call, whether a colour, a name or a logo. */ export const BRAND_CHANGED = "branding.brand_changed" const inOrg = { org: z.string() } export const brandGet = procedure("branding.brand.get") .kind("read") .scope("org") .permission("branding.brand.read") .input(z.object(inOrg)) .output(brandViewSchema) .contract() export const brandSet = procedure("branding.brand.set") .kind("write") .scope("org") .permission("branding.brand.write") .input( z.object({ ...inOrg, // Absent leaves a field as it is; null clears it; a value sets it. displayName: z.string().max(60).nullable().optional(), primary: z.string().max(32).nullable().optional(), accent: z.string().max(32).nullable().optional(), }), ) .output(brandViewSchema) .audit(BRAND_CHANGED) .contract() export const logoSet = procedure("branding.logo.set") .kind("effect") .scope("org") .permission("branding.brand.write") .input( z.object({ ...inOrg, variant: z.enum(LOGO_VARIANTS), contentType: z.string().max(64), /** The image, standard base64: a PNG or WebP up to the upload limit (it is resized), an SVG up to the stored one. */ body: z.string().max(Math.ceil((LOGO_UPLOAD_LIMIT_BYTES * 4) / 3) + 4), }), ) .output(brandViewSchema) .audit(BRAND_CHANGED) .contract() // Removing a logo deletes its stored files, which only an effect may do, so it is not a field of `brand.set`. export const logoClear = procedure("branding.logo.clear") .kind("effect") .scope("org") .permission("branding.brand.write") .input(z.object({ ...inOrg, variant: z.enum(LOGO_VARIANTS) })) .output(brandViewSchema) .audit(BRAND_CHANGED) .contract() export { tenantStyle } from "./style.js" export const logoGet = procedure("branding.logo.get") .kind("effect") .scope("org") .permission("branding.brand.read") // `size` picks the 1x or the 2x rendition of a raster logo (2x when absent); an SVG is one file and has one size. .input(z.object({ ...inOrg, variant: z.enum(LOGO_VARIANTS), size: z.enum(LOGO_SIZES).optional() })) .output(z.object({ contentType: z.string(), body: z.string() })) .contract()