Brand ingest — the rendered half
Enable, disable, test and diagnose the headless Chromium that reads a vendor's brand off their live site.
Canonical sources. The probe and what its observations mean: devtools/brand-renderer/probe.mjs and interpret.mjs — shared verbatim by the Lambda and the local runner. The transport and its config: app/api/portal_studio/services/brand_render_service.py. The merge into a theme: brand_ingest_service.fetch_brand_assets. Infrastructure: infra/components/brand-renderer.ts. This runbook is the HOWTO and never restates them.
TL;DR
A null brand_renderer_lambda_function_name disables it. Ingest falls back to the page's declared metadata and behaves exactly as it did before the renderer existed.
node devtools/brand-renderer/run-local.mjs <url> runs the same probe through your own Playwright and prints what a site yields.
Nothing read means nothing claimed. A 403, a timeout, or a site that names no colour all return nothing, and the portal keeps Aero's palette.
What it is for
Declared metadata answers what colour is this company; a portal needs what colour is a button. Those differ on real sites — HubSpot declares #FF7A59 and paints #FF4800 on its CTAs, Twilio declares red and clicks blue — and only a rendered page can tell them apart. The renderer reports the primary CTA's fill, the corner radius cards repeat, the families type actually resolves to, the page ground and body ink, and the masthead mark with the colour behind it.
Enabling it in an environment
Config lives in app/config/environments.yaml. There is no env var and no feature-flag row: the flag is the configuration, because an environment with no function to call cannot render and a boolean saying otherwise only buys a slower failure.
The keys are scalars rather than a nested map on purpose — the cascade is a shallow {**defaults, **env}, so an env block naming one field of a map would replace the whole map and drop its siblings.
# in the env's own block — overriding one key leaves the other at its default
brand_renderer_lambda_function_name: brand-renderer-dev # null disablesPreviews are the exception that needs no line: their environment is named per-PR, so defaults carries pr-{environment_name}-brand-renderer and every preview resolves its own. That shape is not cosmetic — the deploy role's permissions boundary only permits functions under pr-preview-*, roles under pr-preview-lambda-* and log groups under /onramp/preview-*/*, so infra/components/brand-renderer.ts derives all three from the same identity and a test pins its function name to the one Flask invokes.
Order of operations:
- Build and push the image (
devtools/brand-renderer/Dockerfile) to the env's ECR repo. - Compose
createBrandRendererinto the env program and deploy it. - Set
brand_renderer_lambda_function_nameto the deployed function name and ship the config.
Until step 3, ingest runs its declared-metadata path only. Rolling back is that value returning to null — no deploy of the renderer itself is required.
Testing it locally
A local stack needs no Lambda. With brand_renderer_lambda_function_name null and ENVIRONMENT_NAME unset, render_brand shells to the same probe through the developer's Playwright, which is why a laptop and production cannot disagree about what a site says.
node devtools/brand-renderer/run-local.mjs stripe.com twilio.comOne JSON object per line: the raw observations under observed, and what they mean under brand. To exercise the whole ingest instead, including the merge with declared metadata:
uv run python -c "from app.api.portal_studio.services.brand_ingest_service import fetch_brand_assets; print(fetch_brand_assets('twilio.com'))"Called with no upload context, as above, this stays a read-only diagnostic — the font slots come back None rather than being re-hosted. Pass upload=BrandUploadContext(...) to also exercise the font re-host, which writes font files under ABS_IMAGES_FOLDER and or_files rows against whatever database the shell is pointed at.
The container can be exercised without AWS too:
docker build -t brand-renderer:local devtools/brand-renderer
docker run --rm --entrypoint node brand-renderer:local -e "import('/var/task/handler.mjs').then(m => m.handler({ url: 'https://stripe.com' })).then(r => console.log(JSON.stringify(r)))"Diagnosing a wrong or missing brand
Read sources before anything else. Every returned value is labelled with where it came from — theme-color, tile-color, mask-icon, css-variable, rendered-button, rendered-page — and a slot with no entry was supplied by Aero, not by the site. Most "wrong colour" reports are a slot Aero filled, which is a different problem from one the site got wrong.
| What you see | What it means |
|---|---|
sources empty, palette from Aero | The site declared nothing and painted no coloured CTA. Check the site answers at all. |
| Brand is a generic blue | A framework default slipped through. Bootstrap, Material and favicon-generator stock colours are refused by value in _FRAMEWORK_DEFAULT_COLORS; add the one you found, with its provenance. |
| Brand is an accent, not the identity | A CSS token named for the brand held a light accent. Compare with rendered-button, which is usually truer. |
| Logo invisible on the hero | Expected for a mark drawn for a white header; the hero sets it on a plate. logo_ink is the measured colour if you need to confirm. |
| Nothing at all, fast | Almost always HTTP 403 from a Cloudflare or Akamai edge. Confirm with the probe directly. |
A 403 refuses a real Chromium exactly as it refuses a plain fetch, so no extraction technique reaches those sites. The refusal is frequently rate limiting rather than a standing block — the same hosts answer when they have not just been hit repeatedly — so a vendor ingesting their own site once fares better than a test sweep does.
Operational shape
The function holds no credentials, reaches no data store, and is granted CloudWatch Logs and nothing else: every further grant is one a hostile page's redirect chain would be pointed at. It runs outside a VPC deliberately — its whole job is reaching arbitrary public sites, and the guard that matters is having no route to anything private. The host is re-verified immediately before dispatch, because Chromium follows its own redirects and subresource loads and none of those pass through the caller's guarded fetch.
Reserved concurrency bounds it. Ingest is a rare, human-initiated action, so a cap contains a retry storm far more cheaply than an alarm reacts to one.