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
Migrationdrizzle/0091_organisation_branding.sql adds:
organisation_settings.brand_colour: nullablevarchar(7)with a^#[0-9A-Fa-f]{6}$check.NULLmeans 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 onorganisation_id WHERE removed_at IS NULLallows one active logo per company.
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 interraform/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:
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 singlePUT /organisation-settings; the logo is a separate save step with three parts:
- Presign.
POST /organisation-branding/logo-uploadswith the declaredcontentTypeandbyteSize. No database row is written. The presigned POST policy pins the key, theContent-Typeandcontent-length-range1 to 10,485,760 bytes (10 MiB), so a leaked form cannot upload a different key, type or size. - Upload. The browser posts the file straight to S3 under
pending/<orgId>/<uploadId>. - Confirm.
POST /organisation-branding/logo-uploads/:uploadId/confirmrebuilds 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 underlogos/, 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.
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-Typeis what every presigned download answers with. - The file must be at most 10 MiB (
MAX_LOGO_BYTES). Nobody downloads the original; the rendition is whatGET /organisation-brandingserves. - 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 fromIHDR, and theIENDtrailer 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), otherwiselogo_too_long_and_thin. This keeps the rendition within the row’s 16 to 4000 px bounds.
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.
