diff --git a/apps/api/assets/default-favicon-180.png b/apps/api/assets/default-favicon-180.png
new file mode 100644
index 0000000..355d58f
Binary files /dev/null and b/apps/api/assets/default-favicon-180.png differ
diff --git a/apps/api/assets/default-favicon-32.png b/apps/api/assets/default-favicon-32.png
new file mode 100644
index 0000000..96d896b
Binary files /dev/null and b/apps/api/assets/default-favicon-32.png differ
diff --git a/apps/api/scripts/gen-default-favicon.mjs b/apps/api/scripts/gen-default-favicon.mjs
new file mode 100644
index 0000000..8aeea26
--- /dev/null
+++ b/apps/api/scripts/gen-default-favicon.mjs
@@ -0,0 +1,127 @@
+#!/usr/bin/env node
+/**
+ * Generates the shipped default favicons (issue #306):
+ * `apps/api/assets/default-favicon-32.png` and `-180.png`.
+ *
+ * The api serves these whenever an operator has not uploaded one, so an
+ * instance always has a tab icon — the `` in index.html is
+ * static and its resource must never 404.
+ *
+ * Drawn here rather than pulled in as a binary: the whole toolchain must
+ * survive the `--network none` offline build (96-offline-build-protokoll.md),
+ * and adding an image library for one 32×32 icon would be the tail wagging
+ * the dog. Node's own zlib is enough to write a PNG.
+ *
+ * Motif: a pond seen from above — the accent-green disc with two ripples.
+ *
+ * Regenerate with `node apps/api/scripts/gen-default-favicon.mjs`, commit
+ * script and binaries together.
+ */
+import { deflateSync } from 'node:zlib';
+import { writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+/** Brand green — the same value as index.html's light `theme-color`. */
+const GREEN = [0x2f, 0x6f, 0x4f];
+const LIGHT = [0xe8, 0xf2, 0xec];
+
+const crcTable = Array.from({ length: 256 }, (_, n) => {
+ let c = n;
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ return c >>> 0;
+});
+
+function crc32(buf) {
+ let c = 0xffffffff;
+ for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+function chunk(type, data) {
+ const length = Buffer.alloc(4);
+ length.writeUInt32BE(data.length);
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(crc32(body));
+ return Buffer.concat([length, body, crc]);
+}
+
+/** Minimal RGBA PNG writer — no filtering, one IDAT. */
+function encodePng(size, rgba) {
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(size, 0);
+ ihdr.writeUInt32BE(size, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 6; // colour type RGBA
+ const raw = Buffer.alloc(size * (size * 4 + 1));
+ for (let y = 0; y < size; y += 1) {
+ raw[y * (size * 4 + 1)] = 0; // filter: none
+ rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
+ }
+ return Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ chunk('IHDR', ihdr),
+ chunk('IDAT', deflateSync(raw, { level: 9 })),
+ chunk('IEND', Buffer.alloc(0)),
+ ]);
+}
+
+/**
+ * Colour at one point of the unit square, in continuous coordinates — the
+ * caller supersamples it, which is where the anti-aliasing comes from.
+ */
+function sample(x, y) {
+ const dx = x - 0.5;
+ const dy = y - 0.5;
+ const r = Math.hypot(dx, dy);
+ if (r > 0.48) return null; // outside the disc: transparent
+ // Two ripples spreading from a point struck slightly above centre — rings
+ // rather than a bullseye, which is why the centre stays green and the
+ // spacing widens outward the way real ripples do.
+ const rr = Math.hypot(dx, dy + 0.06);
+ const onRing = (radius, width) => Math.abs(rr - radius) < width;
+ if (onRing(0.33, 0.028) || onRing(0.19, 0.026)) return LIGHT;
+ return GREEN;
+}
+
+function render(size) {
+ const SS = 4; // supersampling factor
+ const out = Buffer.alloc(size * size * 4);
+ for (let y = 0; y < size; y += 1) {
+ for (let x = 0; x < size; x += 1) {
+ let r = 0;
+ let g = 0;
+ let b = 0;
+ let a = 0;
+ for (let sy = 0; sy < SS; sy += 1) {
+ for (let sx = 0; sx < SS; sx += 1) {
+ const c = sample((x + (sx + 0.5) / SS) / size, (y + (sy + 0.5) / SS) / size);
+ if (c) {
+ r += c[0];
+ g += c[1];
+ b += c[2];
+ a += 255;
+ }
+ }
+ }
+ const n = SS * SS;
+ const covered = a / 255;
+ const i = (y * size + x) * 4;
+ // Premultiplied average of the covered samples only, so the edge fades
+ // in alpha rather than towards black.
+ out[i] = covered ? Math.round(r / covered) : 0;
+ out[i + 1] = covered ? Math.round(g / covered) : 0;
+ out[i + 2] = covered ? Math.round(b / covered) : 0;
+ out[i + 3] = Math.round(a / n);
+ }
+ }
+ return out;
+}
+
+const assets = join(dirname(fileURLToPath(import.meta.url)), '../assets');
+for (const size of [32, 180]) {
+ const file = join(assets, `default-favicon-${size}.png`);
+ writeFileSync(file, encodePng(size, render(size)));
+ console.log(`wrote ${file}`);
+}
diff --git a/apps/api/src/branding/branding-storage.service.ts b/apps/api/src/branding/branding-storage.service.ts
new file mode 100644
index 0000000..1647a6b
--- /dev/null
+++ b/apps/api/src/branding/branding-storage.service.ts
@@ -0,0 +1,50 @@
+import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+import { Injectable } from '@nestjs/common';
+
+import { AppConfig } from '../config/app-config.service';
+
+/**
+ * Filesystem binding for branding assets (issue #306; pond overrides #307).
+ *
+ * One flat directory of PNGs named by a caller-supplied key
+ * (`instance-logo-light`, later `pond--favicon-32`). Flat because there
+ * are a handful of files per instance and the backup archives the directory
+ * as a whole — a tree would buy nothing and cost a traversal question.
+ *
+ * The key is constrained here rather than trusted from the route: it is the
+ * only thing between a request parameter and a path.
+ */
+@Injectable()
+export class BrandingStorageService {
+ constructor(private readonly config: AppConfig) {}
+
+ /** Lowercase, digits and dashes only — no dot, so no `..`, and no slash,
+ * so the file cannot leave the directory whatever a caller sends. */
+ private pathFor(key: string): string {
+ if (!/^[a-z0-9-]{1,120}$/.test(key)) throw new Error(`invalid branding key: ${key}`);
+ return join(this.config.env.BRANDING_DIR, `${key}.png`);
+ }
+
+ async save(key: string, bytes: Buffer): Promise {
+ await mkdir(this.config.env.BRANDING_DIR, { recursive: true });
+ await writeFile(this.pathFor(key), bytes);
+ }
+
+ /** The bytes, or null when the file is absent — a missing asset is a normal
+ * state here (nothing uploaded, or metadata and disk drifted after a
+ * partial restore), and every caller has a fallback. */
+ async read(key: string): Promise {
+ try {
+ return await readFile(this.pathFor(key));
+ } catch {
+ return null;
+ }
+ }
+
+ /** Idempotent: removing what is not there is success. */
+ async remove(key: string): Promise {
+ await rm(this.pathFor(key), { force: true });
+ }
+}
diff --git a/apps/api/src/branding/branding.controller.ts b/apps/api/src/branding/branding.controller.ts
new file mode 100644
index 0000000..83a1b71
--- /dev/null
+++ b/apps/api/src/branding/branding.controller.ts
@@ -0,0 +1,135 @@
+import {
+ BadRequestException,
+ Controller,
+ Delete,
+ Get,
+ Post,
+ Query,
+ Req,
+ Res,
+ UploadedFiles,
+ UseGuards,
+ UseInterceptors,
+} from '@nestjs/common';
+import { AnyFilesInterceptor } from '@nestjs/platform-express';
+import {
+ BrandingView,
+ FAVICON_SIZES,
+ FaviconSize,
+ LOGO_VARIANTS,
+ LogoVariant,
+ MAX_BRANDING_BYTES,
+} from '@dorfteich/shared';
+import type { Response } from 'express';
+
+import { SiteAdminGuard } from '../admin/site-admin.guard';
+import { AuthedRequest, Public } from '../auth/auth.guard';
+import { BrandingService } from './branding.service';
+
+function parseVariant(value: unknown): LogoVariant {
+ if (!LOGO_VARIANTS.includes(value as LogoVariant)) {
+ throw new BadRequestException({ code: 'bad_request' });
+ }
+ return value as LogoVariant;
+}
+
+/**
+ * Public branding surface (issue #306).
+ *
+ * Unauthenticated by design and worth stating plainly in the admin UI: the
+ * login screen carries the branding and the browser fetches the favicon before
+ * anyone signs in, so an operator's logo IS visible to anonymous visitors.
+ */
+@Controller('branding')
+export class BrandingController {
+ constructor(private readonly branding: BrandingService) {}
+
+ @Public()
+ @Get()
+ view(): Promise {
+ return this.branding.view();
+ }
+
+ @Public()
+ @Get('logo')
+ async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise {
+ const bytes = await this.branding.logoBytes(parseVariant(variant ?? 'light'));
+ // No shipped default: without a logo the app renders the instance NAME as
+ // text, so an empty answer here is the honest one.
+ if (!bytes) {
+ res.status(404).json({ code: 'not_found', message: 'no logo' });
+ return;
+ }
+ res.setHeader('Content-Type', 'image/png');
+ // The caller puts the content hash in the query string, so a given URL
+ // never changes what it points at.
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
+ res.send(bytes);
+ }
+
+ @Public()
+ @Get('favicon')
+ async favicon(@Query('size') size: string | undefined, @Res() res: Response): Promise {
+ const wanted = Number(size ?? 32);
+ if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
+ throw new BadRequestException({ code: 'bad_request' });
+ }
+ const { bytes, uploaded } = await this.branding.faviconBytes(wanted as FaviconSize);
+ res.setHeader('Content-Type', 'image/png');
+ // The `` href is a constant in index.html, so this URL
+ // cannot carry a hash — revalidation is the only way a replaced favicon
+ // ever reaches a browser that already has one.
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('ETag', `"${uploaded ? 'custom' : 'default'}-${bytes.length}"`);
+ res.send(bytes);
+ }
+}
+
+/** Site-Admin management of the instance branding (issue #306). */
+@Controller('admin/branding')
+@UseGuards(SiteAdminGuard)
+export class BrandingAdminController {
+ constructor(private readonly branding: BrandingService) {}
+
+ @Post('logo')
+ @UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
+ async setLogo(
+ @Query('variant') variant: string | undefined,
+ @Req() request: AuthedRequest,
+ @UploadedFiles() files: Express.Multer.File[] | undefined,
+ ): Promise {
+ const file = files?.find((entry) => entry.fieldname === 'file');
+ if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
+ return this.branding.setLogo(request.user!, parseVariant(variant ?? 'light'), file.buffer);
+ }
+
+ @Delete('logo')
+ clearLogo(
+ @Query('variant') variant: string | undefined,
+ @Req() request: AuthedRequest,
+ ): Promise {
+ return this.branding.clearLogo(request.user!, parseVariant(variant ?? 'light'));
+ }
+
+ @Post('favicon')
+ @UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
+ async setFavicon(
+ @Req() request: AuthedRequest,
+ @UploadedFiles() files: Express.Multer.File[] | undefined,
+ ): Promise {
+ // Field names are the pixel sizes the browser rendered: `png-32`, `png-180`.
+ const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
+ const collected = {} as Record;
+ for (const size of FAVICON_SIZES) {
+ const bytes = byField.get(`png-${size}`);
+ if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
+ collected[size] = bytes;
+ }
+ return this.branding.setFavicon(request.user!, collected);
+ }
+
+ @Delete('favicon')
+ clearFavicon(@Req() request: AuthedRequest): Promise {
+ return this.branding.clearFavicon(request.user!);
+ }
+}
diff --git a/apps/api/src/branding/branding.module.ts b/apps/api/src/branding/branding.module.ts
new file mode 100644
index 0000000..f4845cf
--- /dev/null
+++ b/apps/api/src/branding/branding.module.ts
@@ -0,0 +1,15 @@
+import { Module } from '@nestjs/common';
+
+import { BrandingAdminController, BrandingController } from './branding.controller';
+import { BrandingStorageService } from './branding-storage.service';
+import { BrandingService } from './branding.service';
+
+/** Instance branding — logo and favicon (issue #306). Exports the services so
+ * the pond-level override (#307) can build on the same storage and the same
+ * resolution path instead of a parallel one. */
+@Module({
+ controllers: [BrandingController, BrandingAdminController],
+ providers: [BrandingService, BrandingStorageService],
+ exports: [BrandingService, BrandingStorageService],
+})
+export class BrandingModule {}
diff --git a/apps/api/src/branding/branding.service.ts b/apps/api/src/branding/branding.service.ts
new file mode 100644
index 0000000..08fb9c9
--- /dev/null
+++ b/apps/api/src/branding/branding.service.ts
@@ -0,0 +1,182 @@
+import { createHash } from 'node:crypto';
+import { readFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+import { BadRequestException, Injectable } from '@nestjs/common';
+import {
+ BrandingAsset,
+ BrandingView,
+ FaviconSize,
+ LogoVariant,
+ MAX_BRANDING_BYTES,
+ MAX_LOGO_EDGE,
+ hasPngMagic,
+ looksLikeSvg,
+ pngDimensions,
+} from '@dorfteich/shared';
+import { User } from '@prisma/client';
+
+import { AuditService } from '../audit/audit.service';
+import { InstanceSettingsService } from '../settings/instance-settings.service';
+import { BrandingStorageService } from './branding-storage.service';
+
+/** The settings key each instance asset's metadata lives under. */
+const INSTANCE_KEYS = {
+ logoLight: 'instance.logo',
+ logoDark: 'instance.logoDark',
+ favicon: 'instance.favicon',
+} as const;
+
+/**
+ * Instance branding (issue #306): the logo shown at the top of the sidebar and
+ * the favicon served to the browser.
+ *
+ * The api stores and serves bytes; it never decodes them. Validation is the
+ * PNG signature, the IHDR dimensions and the size cap — see
+ * `packages/shared/src/branding.ts` for why that line is drawn there.
+ */
+@Injectable()
+export class BrandingService {
+ constructor(
+ private readonly settings: InstanceSettingsService,
+ private readonly storage: BrandingStorageService,
+ private readonly audit: AuditService,
+ ) {}
+
+ static logoKey(variant: LogoVariant): string {
+ return `instance-logo-${variant}`;
+ }
+
+ static faviconKey(size: FaviconSize): string {
+ return `instance-favicon-${size}`;
+ }
+
+ /**
+ * Rejects anything that is not a PNG within the caps, before a byte is
+ * written. SVG gets its own message: an operator who tried one deserves to
+ * learn that it is refused on purpose, not that "the file is broken".
+ */
+ private assertUsablePng(bytes: Buffer, maxEdge: number): { width: number; height: number } {
+ if (bytes.length === 0) throw new BadRequestException({ code: 'branding_file_empty' });
+ if (bytes.length > MAX_BRANDING_BYTES) {
+ throw new BadRequestException({ code: 'branding_file_too_large' });
+ }
+ if (looksLikeSvg(bytes)) throw new BadRequestException({ code: 'branding_svg_rejected' });
+ if (!hasPngMagic(bytes)) throw new BadRequestException({ code: 'branding_not_a_png' });
+ const size = pngDimensions(bytes);
+ if (!size) throw new BadRequestException({ code: 'branding_not_a_png' });
+ if (size.width > maxEdge || size.height > maxEdge) {
+ throw new BadRequestException({ code: 'branding_image_too_large' });
+ }
+ return size;
+ }
+
+ private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset {
+ return {
+ // Short digest: it only has to change when the bytes change, and it
+ // travels in every logo URL.
+ hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
+ ...size,
+ };
+ }
+
+ async view(): Promise {
+ const [logo, logoDark, favicon, instanceName] = await Promise.all([
+ this.settings.get(INSTANCE_KEYS.logoLight),
+ this.settings.get(INSTANCE_KEYS.logoDark),
+ this.settings.get(INSTANCE_KEYS.favicon),
+ this.settings.get('instance.name'),
+ ]);
+ return { logo, logoDark, favicon, instanceName };
+ }
+
+ async setLogo(admin: User, variant: LogoVariant, bytes: Buffer): Promise {
+ const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
+ await this.storage.save(BrandingService.logoKey(variant), bytes);
+ await this.settings.set(
+ variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
+ this.assetOf(bytes, size),
+ admin.id,
+ );
+ await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'set');
+ return this.view();
+ }
+
+ async clearLogo(admin: User, variant: LogoVariant): Promise {
+ await this.storage.remove(BrandingService.logoKey(variant));
+ await this.settings.set(
+ variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
+ null,
+ admin.id,
+ );
+ await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'cleared');
+ return this.view();
+ }
+
+ /**
+ * Both favicon sizes arrive together: the browser produced them from one
+ * source on the same canvas, and the api cannot resize. Storing them as a
+ * pair keeps the tab icon and the home-screen icon from ever showing two
+ * different images.
+ */
+ async setFavicon(admin: User, files: Record): Promise {
+ const sizes = Object.entries(files).map(([declared, bytes]) => {
+ const size = this.assertUsablePng(bytes, 512);
+ const expected = Number(declared);
+ if (size.width !== expected || size.height !== expected) {
+ throw new BadRequestException({ code: 'branding_favicon_not_square' });
+ }
+ return { expected: expected as FaviconSize, bytes, size };
+ });
+ for (const entry of sizes) {
+ await this.storage.save(BrandingService.faviconKey(entry.expected), entry.bytes);
+ }
+ // The 32px variant identifies the pair — it is what the tab shows.
+ const small = sizes.find((entry) => entry.expected === 32)!;
+ await this.settings.set(INSTANCE_KEYS.favicon, this.assetOf(small.bytes, small.size), admin.id);
+ await this.record(admin, 'favicon', 'set');
+ return this.view();
+ }
+
+ async clearFavicon(admin: User): Promise {
+ await this.storage.remove(BrandingService.faviconKey(32));
+ await this.storage.remove(BrandingService.faviconKey(180));
+ await this.settings.set(INSTANCE_KEYS.favicon, null, admin.id);
+ await this.record(admin, 'favicon', 'cleared');
+ return this.view();
+ }
+
+ /** The bytes to serve for a logo variant, or null when none is stored. */
+ logoBytes(variant: LogoVariant): Promise {
+ return this.storage.read(BrandingService.logoKey(variant));
+ }
+
+ /**
+ * The favicon bytes: the uploaded one, else the shipped default. The
+ * `` in index.html is static, so this route must always
+ * answer with an image — a 404 there would leave the browser's generic
+ * icon for good.
+ */
+ async faviconBytes(size: FaviconSize): Promise<{ bytes: Buffer; uploaded: boolean }> {
+ const stored = await this.storage.read(BrandingService.faviconKey(size));
+ if (stored) return { bytes: stored, uploaded: true };
+ const bytes = await readFile(join(__dirname, '../../assets', `default-favicon-${size}.png`));
+ return { bytes, uploaded: false };
+ }
+
+ private record(
+ admin: User,
+ asset: 'logo' | 'logoDark' | 'favicon',
+ action: 'set' | 'cleared',
+ ): Promise {
+ // `scope` is here from the start so the pond-level change (#307) is the
+ // same event with a different scope, not a second id in the catalogue.
+ return this.audit.record({
+ action: 'branding.changed',
+ actorId: admin.id,
+ targetType: 'setting',
+ targetId: `instance.${asset}`,
+ details: { scope: 'instance', asset, change: action },
+ });
+ }
+}
diff --git a/apps/api/src/fonts/custom-fonts.controller.ts b/apps/api/src/fonts/custom-fonts.controller.ts
index dbda4de..a4ea9ff 100644
--- a/apps/api/src/fonts/custom-fonts.controller.ts
+++ b/apps/api/src/fonts/custom-fonts.controller.ts
@@ -23,6 +23,7 @@ import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.guard';
+import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { CustomFontStorageService } from './custom-font-storage.service';
import { CustomFontsService, WeightUpload } from './custom-fonts.service';
@@ -125,6 +126,10 @@ export class CustomFontsFileController {
private readonly fonts: CustomFontsService,
) {}
+ // Explicit access declaration, as every route needs (issue #52's fence
+ // `route-permissions.e2e.db.test.ts`): a session, no further permission —
+ // the list says which families exist, which is what the pickers offer.
+ @AuthenticatedOnly()
@Get()
list(): Promise {
return this.fonts.list();
diff --git a/packages/shared/src/branding.ts b/packages/shared/src/branding.ts
new file mode 100644
index 0000000..0b0d2a0
--- /dev/null
+++ b/packages/shared/src/branding.ts
@@ -0,0 +1,98 @@
+/**
+ * Instance and pond branding assets — logo and favicon (issues #306/#307).
+ *
+ * The bytes live on disk under `BRANDING_DIR`; only metadata (present/absent,
+ * dimensions, a content hash for cache busting) goes into settings. Cropping,
+ * scaling and the conversion to PNG happen in the BROWSER on a canvas: adding
+ * a native image library to the api would put a decoder in front of
+ * attacker-supplied bytes and would have to be carried through the
+ * `--network none` offline build (96-offline-build-protokoll.md).
+ *
+ * The api therefore never decodes an image. It checks the PNG signature, reads
+ * the fixed-offset IHDR fields for the dimensions, and enforces the caps —
+ * which is exactly as far as one can go without a decoder.
+ */
+import { z } from 'zod';
+
+/** Logo variants. A set belongs to one level and is never mixed across levels
+ * (#307): a pond with only a light logo shows THAT logo in dark mode rather
+ * than silently borrowing the instance's dark one. */
+export const LOGO_VARIANTS = ['light', 'dark'] as const;
+export type LogoVariant = (typeof LOGO_VARIANTS)[number];
+
+/** Favicon sizes emitted by the browser-side crop: the tab icon and the
+ * home-screen icon. No `.ico` — every current browser accepts PNG. */
+export const FAVICON_SIZES = [32, 180] as const;
+export type FaviconSize = (typeof FAVICON_SIZES)[number];
+
+/** Longest edge of an uploaded logo. Beyond this the browser downscales
+ * before uploading; the api rejects anything larger as a backstop. */
+export const MAX_LOGO_EDGE = 512;
+
+/** Per-file cap. A 512px PNG is tens of KB; 2 MiB leaves room for a
+ * needlessly lossless export without inviting abuse. */
+export const MAX_BRANDING_BYTES = 2 * 1024 * 1024;
+
+/** Formats a source image may have in the browser. SVG is deliberately absent:
+ * it can carry script, and serving it from our own origin would be a
+ * cross-site-scripting vector (security.md §Uploads). What leaves the canvas
+ * is PNG regardless. */
+export const BRANDING_SOURCE_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
+
+const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
+
+/** True when `bytes` starts with the PNG signature. */
+export function hasPngMagic(bytes: Uint8Array): boolean {
+ if (bytes.length < PNG_MAGIC.length) return false;
+ return PNG_MAGIC.every((byte, index) => bytes[index] === byte);
+}
+
+/** True when the bytes look like SVG (XML declaration or an `