> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Company branding

> How the brand colour and company logo are stored, uploaded, validated and used by estimate exports

Company branding gives each company a brand colour and a logo for its estimate spreadsheet exports. The colour fills the section headings and the logo sits at the top left of the sheet. With neither set, exports use the Stralos navy headings and no logo.

The data and routes live in `stralos-api-global`. The **Company branding** card in **Settings → Company defaults** is `src/CompanyBrandingPanel.tsx` in `stralos-feature-organisation`. `stralos-api-estimate` reads the branding when it builds an export. For the request and response contracts, see [Global API: Company branding](https://help.stralos.app/api/global.md#company-branding).

## Whose branding

Every route acts on the organisation in the caller's token. An estimate export forwards the caller's token, so it uses the branding of the company that owns the estimate. A contractor working in a client company sees that company's branding, and edits it when they are an admin there. Nothing is copied from the contractor's own company.

## Data model

Migration `drizzle/0091_organisation_branding.sql` adds:

* `organisation_settings.brand_colour`: nullable `varchar(7)` with a `^#[0-9A-Fa-f]{6}$` check. `NULL` means no colour. The route validates first and stores the value lower-case; the check is a backstop.
* `organisation_logos`: one row per accepted upload, holding the object key, content type, byte size, width, height, SHA-256 and audit columns. The image bytes are never stored in the database. A partial unique index on `organisation_id WHERE removed_at IS NULL` allows one active logo per company.

Migration `drizzle/0094_organisation_logo_originals.sql` adds six nullable `original_*` columns (object key, content type, byte size, width, height, SHA-256), all set or all null. The existing columns describe the rendition (see [Rendition](#rendition)), so their bounds of 512 KiB and 16 to 4000 px still hold and no existing check changed. `NULL` originals mark a logo confirmed before the rendition existed: its object is the original, and it is served as it is. The migration is additive with no backfill and no cutover marker.

The table uses forced row-level security with the usual `tenant_isolation` policy. The request role can select, insert and update but not delete, so replacing or removing a logo stamps `removed_at`. Only the platform role deletes rows, during the organisation purge.

`GET /organisation-settings` returns `brandColour` and the active logo's metadata on every page load. The short-lived download URL comes only from `GET /organisation-branding`, so it never sits in the shared organisation-settings cache.

## Logo storage

The private bucket is defined in `terraform/branding-bucket.tf` and named by the `BRANDING_BUCKET` environment variable on the API and retention Lambdas. Keys for each company sit under two prefixes:

```text theme={null}
pending/<orgId>/<uploadId>                  unvalidated browser upload; lifecycle expires it after one day
logos/<orgId>/<logoId>.<png|jpg>            rendition (what every download serves); immutable
logos/<orgId>/<logoId>.original.<png|jpg>   validated original; immutable
```

Only the organisation purge removes objects under `logos/`.

All public access is blocked, objects are encrypted with SSE-S3 and versioned, and the bucket policy denies object access to principals outside the stack's own roles. CORS allows `POST` only, from the origins in `branding_upload_cors_origins`. Downloads are presigned `GET` URLs that last five minutes.

When `BRANDING_BUCKET` is unset (for example, a local stack without the bucket), `GET /organisation-branding` reports `logo: null` and the upload routes answer `503 logo_storage_unavailable`. The organisation purge refuses to purge an organisation that has logo rows while no bucket is configured.

## Upload flow

The card uploads nothing until the admin clicks **Save changes** on **Company defaults**. The colour is part of the page's single `PUT /organisation-settings`; the logo is a separate save step with three parts:

1. **Presign.** `POST /organisation-branding/logo-uploads` with the declared `contentType` and `byteSize`. No database row is written. The presigned POST policy pins the key, the `Content-Type` and `content-length-range` 1 to 10,485,760 bytes (10 MiB), so a leaked form cannot upload a different key, type or size.
2. **Upload.** The browser posts the file straight to S3 under `pending/<orgId>/<uploadId>`.
3. **Confirm.** `POST /organisation-branding/logo-uploads/:uploadId/confirm` rebuilds the pending key from the caller's organisation, so an admin cannot confirm another company's upload. The service reads the bytes back, validates them, makes the rendition, copies the original and writes the rendition under `logos/`, then soft-removes the previous active row and inserts the new one in one transaction. The pending object is deleted on a best-effort basis.

If the new image has the same SHA-256 as the active logo's original (or, for a pre-0094 logo, its only object), confirm keeps the existing record. If two admins confirm at once, the partial unique index rejects the second insert and the route answers `409 logo_changed_concurrently`.

`DELETE /organisation-branding/logo` soft-removes the active row and keeps its object. It needs no bucket, and removing when there is no logo is not an error.

## Validation

`src/lib/logoImage.ts` decides from the bytes, never from a file name or declared type alone:

* The magic number must be PNG or JPEG and must match the declared type, because the stored `Content-Type` is what every presigned download answers with.
* The file must be at most 10 MiB (`MAX_LOGO_BYTES`). Nobody downloads the original; the rendition is what `GET /organisation-branding` serves.
* Each side must be 16 to 10,000 pixels (`MIN_LOGO_SIDE`, `MAX_LOGO_SIDE`), and the image at most 50 megapixels (`MAX_LOGO_PIXELS`). Dimensions are read from the header before anything is decoded, so a small file claiming a huge image never reaches the decoder. PNG dimensions come from `IHDR`, and the `IEND` trailer must be present, so a truncated upload is refused. JPEG dimensions come from the first start-of-frame marker.
* The long side must be at most 250 times the short side (`MAX_LOGO_ASPECT`), otherwise `logo_too_long_and_thin`. This keeps the rendition within the row's 16 to 4000 px bounds.

The card checks the type and size before uploading and shows the same sentences the API returns. A `503` from storage shows "Logos can't be saved just now. Please try again in a few minutes."

## Rendition

`src/lib/logoRendition.ts` resizes the accepted original with `sharp`, loaded on first use so other routes' cold starts do not pay for it. One rendition serves the settings card preview and the export headers in `stralos-api-estimate`, which refuse a download over 512 KB.

* Longest side at most 1,200 px, never upscaled, and the shortest side never below 16 px.
* At most 512 KiB. A ladder of encodings is tried in order and the first that fits wins: PNG falls back to palette encoding and then smaller sizes; JPEG falls back to lower quality and then smaller sizes.
* PNG stays PNG and JPEG stays JPEG. Transparency is kept: a PNG with alpha is never flattened or converted to JPEG, and the palette fallback quantises RGBA.
* EXIF orientation is applied, because the exports ignore it. The output is sRGB, 8 bits per channel, with no metadata, so a phone photo's location never reaches a download.

## Export reads

`src/exports/exportBrandingReads.ts` in `stralos-api-estimate` calls `GET /organisation-branding` with the caller's token, then downloads the logo from its presigned URL without sending the token. It checks the download's signature and SHA-256 against the metadata. Both reads fail soft: if either fails, the export still completes with the Stralos look (a colour that loaded is kept if only the logo fails), and a warning is logged.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.