Skip to main content
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.

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), 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:
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.