Skip to content

feat(web): show assignee avatars on the board - #175

Open
cloudbridgeuy wants to merge 14 commits into
theam:mainfrom
cloudbridgeuy:trunk-story-avatars
Open

feat(web): show assignee avatars on the board#175
cloudbridgeuy wants to merge 14 commits into
theam:mainfrom
cloudbridgeuy:trunk-story-avatars

Conversation

@cloudbridgeuy

Copy link
Copy Markdown

Closes #174. Also finishes the avatar half of #101, which #171 deliberately left out.

Stacked on #171 — please read #171 first. This branch is cut from
trunk-story-assignees, that PR's head. GitHub cannot base a cross-fork PR on a
branch that only exists on my fork, so this one targets main and the diff
below therefore contains #171's commits as well as this work.

The avatar work is the last three commits, everything after c9bc162, and it is
exactly seven files:

apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx | 17 +++--
apps/web/components/issues/issue-row.tsx                          | 17 +++--
apps/web/components/shell/topbar.tsx                              | 20 +++---
apps/web/lib/pipeline.ts                                          | 20 ++++++
apps/web/test/pipeline-story.test.ts                              | 39 ++++++++
packages/ui/src/avatar.tsx                                        | 55 +++++++++
packages/ui/src/index.ts                                          |  1 +
7 files changed, 146 insertions(+), 23 deletions(-)

Once #171 lands I will rebase and the diff will show only those seven files. Say so
if you would rather I hold this PR closed until then.

apps/web and packages/ui only. No new dependency, no package.json change, no
next.config.ts change, no API or schema change. Nothing new travels on the wire.

What you get

One square avatar, immediately left of @login. The +N grammar #171 introduced
is untouched.

On the story row (components/issues/issue-row.tsx, a client component), at 14px:

Story rows showing avatars beside @guzmonne, @guzmonne +1 and @cloudbridgeuy

In the story header (stories/[number]/page.tsx, a server component), at 16px
to match the header's larger type:

Story header showing a 16px avatar before @guzmonne +1

Unassigned rows and headers still render nothing at all — no placeholder, no
reserved column. storyOwner keeps its { login, extra } shape and its tests;
this PR does not touch it.

And the topbar (components/shell/topbar.tsx) now uses the same primitive.
That is the part that closes item 2 of #174: the topbar used to render an image
when avatarUrl was set and nothing when it was not, which is exactly the gap
#101 believed the topbar had already closed. It now renders the initial letter:

Topbar for a principal with no avatar URL and no GitHub login, showing the letter A before the email address

The fallback, and why it is a background image

This is the part worth reviewing closely, because it is what answers #101's own
question about deployments that must not let the browser reach github.com.

packages/ui/src/avatar.tsx paints the initial letter, then layers the remote
image over it as a CSS background-image — not as an <img>.

An <img> that fails to load makes every engine paint its own broken-image glyph
on top of the letter. alt="" does not suppress it, and neither does
color: transparent. A background image that fails to load paints nothing, so
the letter underneath is untouched. A failed avatar becomes pixel-identical to no
avatar at all. I measured this in Chromium, Firefox and WebKit before choosing it.

So a deployment whose browsers cannot reach github.com degrades to the letter with
nothing to configure — no env var, no NEXT_PUBLIC_* convention, and no change
to .env.example, the Dockerfile, compose or the self-host docs. Here is the same
board with the connection to both avatar hosts aborted:

The same board with avatar hosts unreachable: rows show the letters G, G and C, and the layout has not moved

Same rows, same spacing, no broken-image glyph anywhere. Close up, online and then
with the hosts unreachable:

A story row close-up online, avatar beside @guzmonne +1

The same row with the hosts unreachable, the letter G in place of the avatar

The letter comes from one neutral background, never a colour hashed from the login.
packages/ui's tones (agent, human, ok, bad, info, machine, muted) are
a status palette, so hashing a login into them would hand someone the colour of
"failed" or "running".

The primitive is aria-hidden. The login it stands for is always written out beside
it, so the avatar adds nothing for a screen reader to announce and nothing new to
translate.

Where the rules live

Two helpers in lib/pipeline.ts, both covered in test/pipeline-story.test.ts
the same file #171 uses, and no new test file:

Helper Rule
avatarUrlFor(login) https://github.com/{login}.png?size=40. Login trimmed and URL-encoded. null for a blank login, so nothing is drawn.
avatarInitial(value) First character, uppercased. Falls through to the email when there is no login. ? when there is nothing at all. Spread rather than [0], so an astral character survives whole.

?size=40 rather than the 14–20 CSS px actually drawn, so 2× displays stay sharp.

packages/ui/src/avatar.tsx takes src, initial, size and className and has
no framework import — it is the same component in the client row and in the two
server components. Its one piece of logic worth naming is cssUrl, which
percent-encodes ", \ and whitespace so a login can never end the CSS string or
the url() token early.

Three things called out so they are not mistaken for refactors

1. The topbar loses referrerPolicy="no-referrer". github.com will now learn
the deployment's origin when it serves the viewer's own avatar. Referrer policy is
a property of the fetch initiator, and CSS gives no way to set one. I measured the
alternatives before accepting this: a pseudo-element, a child element, an inline
style, an external stylesheet carrying referrerpolicy="no-referrer", and a
custom-property indirection all send the origin, in all three engines. The only
levers are document-wide — <meta name="referrer"> or a Referrer-Policy header —
and either would change every other request the app makes.

github.com learns nothing else; the browser connects from the user's own machine
either way. If that trade is not acceptable, the API-proxy approach in #174 is the
alternative, and I would rather hear it now than after merge.

2. The topbar avatar changes from round to square. It was rounded-full.
packages/ui/src/primitives.tsx documents PillTag as "the only pill-shaped
element in the system"
, so the round avatar was the exception, and making the new
shared primitive round would have spread that exception to three sites instead of
retiring it from one.

3. next/image goes. next.config.ts has no images key today, so keeping the
optimiser would have meant adding remotePatterns plus a caching story, and
unoptimized would have kept the broken-image glyph. Removing it also keeps
packages/ui free of a framework dependency.

Worth knowing either way: https://github.com/{login}.png?size=40 302s to
https://avatars.githubusercontent.com/u/{id}?v=4. Two requests, two hosts —
relevant only if a Content-Security-Policy is ever added. There is none today.

Testing

  • apps/web suite: 11 files, 88 tests, up from 80. Eight new cases pin the two
    helpers: a normal login, a login needing trimming, a login needing escaping, a
    blank login, an initial from a login, an initial from an email, an astral first
    character, and the ? when there is nothing to draw.

  • tsc --noEmit clean in both packages/ui and apps/web.

  • Biome (pnpm lint) run and confirmed rather than assumed, since Avatar is
    the first component of its kind in packages/ui: 406 files, no fixes.

  • pnpm verify run on this branch. Everything passes except two tests under
    scripts/, which I do not believe are mine and which I checked rather than
    assumed:

    Test Evidence it is not from this branch
    scripts/dependencies-security.test.mjspatched image-size rejects non-advancing JXL/HEIF boxes Fails identically on the base branch. Passes 3/3 when the file runs alone, on both branches. It gives a spawned Node process a hard-coded timeout: 1_500, which a loaded machine misses.
    scripts/deploy-aws.test.mjsAWS CLI adapter maps missing images … to stable denials Fails on the base branch too, and passes when run alone.

    A full pnpm verify on the base branch fails both, so the diff here does not
    make anything worse. It also cannot: it touches no file under scripts/ and adds
    no dependency — the whole diff is seven files in apps/web and packages/ui.

  • Verified by hand on a live instance against GitHub-synced issues, which is where
    every screenshot above comes from. Online: all three sites draw the avatar. With
    both avatar hosts aborted at the network layer — and the blocked URLs read back to
    confirm both hops of the 302 were really refused — all three degrade to the letter
    with no glyph and no layout shift. And with avatar_url and the GitHub login both
    cleared on the principal, the topbar renders the letter taken from the email where
    it used to render nothing.

Two limits, stated rather than rounded up: +N was exercised live at N=1 only,
as in #171 and for the same reason — GitHub assigns only users who can reach the
repository, and the test instance had two accounts. The count itself is covered by
unit test. And the cross-engine fallback result is from a standalone harness in all
three engines; the live checks above were Chromium only.

🤖 Generated with Claude Code

storyOwner() picks the first assignee (GitHub's order, not sorted)
and reports how many are left over, returning null when a story has
no assignees so no call site can render an "unassigned" placeholder
by accident. issue-row.tsx renders "@login" plus "+N" between the
label chips and the relative-time stamp, and nothing when the story
is unassigned.
Render the story's lead assignee beside the label chips in the story
header, using the same @login (+N) grammar already used on the
Backlog row. The header now reads story.assignees from StoryDetail,
which previously arrived from the API and was dropped on the floor.
Adds ownedBy() and boardHref() as pure helpers in lib/pipeline.ts, and
uses boardHref for all four board filter chips (all, stage, status
clear, mine) instead of hand-built URL strings. The mine chip narrows
each stage's stories to the signed-in viewer's GitHub login, composes
with the existing stage/status filters, and only renders when the
viewer has a GitHub login to match against. Stage chip counts and the
active-open-stories subtitle now read from the mine-scoped stories so
they never go stale relative to what's shown.
mineOn previously read straight from the mine=1 query param, so a
viewer with no GitHub login (a key principal, or any user whose
principal.githubLogin is unset) who arrived at ?mine=1 via a shared
link, bookmark, or browser history landed on a board with every
story filtered out by ownedBy(), no mine chip to undo it (it only
renders when a login exists), and no other chip to recover with,
since all four preserve mine.

Lift the derivation into mineFilterOn(mine, login) in lib/pipeline.ts,
which is false whenever the viewer has no login to match against
regardless of the raw query param. The board now renders normally
for such a viewer even with ?mine=1 in the URL, and every chip link
emits a clean, mine-free href.

@adrian-lorenzo adrian-lorenzo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution!

The background-image fallback handles a failed request, but it does not satisfy deployments that must not contact GitHub: every visible assignee still triggers an eager third-party request. It also removes the topbar’s existing referrerPolicy="no-referrer", so GitHub now receives the Facility deployment origin along with those avatar lookups. Please preserve the no-referrer guarantee and provide an operator-controlled way to disable remote avatars or proxy them without browser egress. Cover the enabled, disabled, and failed-load paths at the component boundary.

With those improvements, we can approve and merge it!

@cloudbridgeuy

Copy link
Copy Markdown
Author

Thanks @adrian-lorenzo — both concerns are addressed in 5f9ac32, pushed to this branch.

No more browser egress to GitHub at all. We went further than a disable switch: the browser-direct mode is gone. Avatars now load from this deployment's own origin via two new routes:

  • /api/avatars/u/{login} and /api/avatars/id/{id} (apps/web/app/api/avatars/[...target]/route.ts)
  • The server-side fetch carries fresh headers only — no cookies, no forwarding chain, no referrer — so GitHub learns nothing about the deployment or the viewer. This restores the referrerPolicy="no-referrer" guarantee the first cut lost.
  • The route accepts only two exact path shapes pinned to two GitHub hosts (login pattern / numeric-ID), so nothing else is ever fetched. Any upstream failure maps to a plain 404, which leaves the CSS background unset and the initial letter showing.

Operator control. NEXT_PUBLIC_FACILITY_AVATARS:

  • proxy (default) — same-origin serving as above
  • off — initial letters only, zero remote requests
  • unknown values fail closed to off
  • A deployment whose server egress to GitHub is firewalled degrades to letters on its own; documented in the self-host hardening checklist and .env.example.

Stored principal avatar URLs in the topbar are rewritten onto the proxy only when they match a known GitHub shape (github.com/{login}.png, avatars.githubusercontent.com/u/{id}); anything else falls back to the login-derived source so the browser is never pointed at an unreviewed host.

Tests (apps/web: 14 files, 105 tests, up from 88; tsc + biome clean):

Path Coverage
test/avatar-policy.test.ts mode parsing (incl. hostile values), login escaping, stored-URL allowlist vs. spoofed hosts, off-mode
test/avatar-proxy.test.ts route integration against a local fake GitHub: forwarded bytes, fresh headers asserted on the wire (no cookie/authorization/referer), exact-shape rejection, non-image → 404, unreachable → 404, off → 404 with zero upstream requests
test/avatar-component.test.tsx component boundary: enabled renders same-origin background over letter, disabled renders letter with no image request, failed-load markup identical to disabled

One honest note: the topbar's round→square change from the original description stands unchanged — that was a design-system decision independent of this review.

@cloudbridgeuy

cloudbridgeuy commented Aug 25, 2026

Copy link
Copy Markdown
Author

One note on constraints, since it may matter for how you'd like this shaped:

Avoiding new "use client" code was incidental to the first cut, not a requirement. The same-origin proxy above does not depend on it. All the policy work is rendering-agnostic:

  • apps/web/lib/avatar-policy.ts
  • apps/web/lib/avatar-proxy.ts
  • apps/web/app/api/avatars/[...target]/route.ts + its tests

Only the primitive's internals decide where the image tag lives.

If you'd rather see a client-rendered image, we're happy to make that swap. For example, a small client component rendering an <img> whose onError swaps to the initial letter — some prefer that over the CSS-background trick for debuggability. It would touch packages/ui/src/avatar.tsx and the failed-load boundary test, nothing else.

…ne filter

A failed identity request used to be collapsed into 'viewer has no
GitHub login', so ?mine=1 showed every story while removing the chip
that could undo it. mineFilterState now returns off/on/blocked so the
board can say the identity check failed rather than pretend the filter
found nothing.

Also adds the empty state the reviewer asked for: when the mine filter
is active and nothing is assigned to the viewer, the board says so and
links back to the unfiltered board instead of rendering every stage
empty.
@cloudbridgeuy

Copy link
Copy Markdown
Author

One correction to the trade-off picture in my last comment: the swap is not free.

The current CSS-background primitive ships zero avatar-related JavaScript — it renders identically from server components, which is why the topbar and the story header (both server components) use it directly. Moving the image into a client <img> with onError would either:

  • pull those two pages into client components, or
  • require a "use client" boundary around every avatar site,

and in both cases ship avatar-handling JS to the browser that today isn't there. The failed-load behavior would be identical; the cost is bundle weight and a component-graph change at three call sites, not one file.

Stating it so the choice is made with both sides visible: CSS-background keeps the current zero-JS property at the price of an unconventional technique; client-<img> is conventional at the price of new client code everywhere an avatar appears.

@adrian-lorenzo adrian-lorenzo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution!

The proxy still has two security boundary gaps. /api/avatars/... is public, and every request triggers a fresh upstream fetch, so anyone who can reach the deployment can drive unbounded GitHub egress by cycling valid-looking logins or IDs. Please prevent unauthenticated requests from reaching the upstream and add bounded server-side caching or rate limiting.

Also, redirect: "follow" allows the upstream to redirect outside the two permitted GitHub hosts. I reproduced this with an upstream fake redirecting to a second server: the route followed it and returned that server’s bytes. Please validate and limit redirect hops, and add regression tests for both cases.

With those fixes, we can approve and merge it!

cloudbridgeuy pushed a commit to cloudbridgeuy/facility that referenced this pull request Sep 1, 2026
Review found two ways the /api/avatars routes let a caller drive this
deployment's outbound traffic. Both are fixed here rather than in two
commits because they share one route body and one test harness.

The routes were public, and every request produced a fresh upstream
fetch, so anyone who could reach the deployment could cycle valid-looking
logins or IDs and make it call GitHub without limit. A request now
reaches an upstream only when three things hold: it carries a session
the control plane recognises, no cached answer already exists, and the
viewer's fetch allowance is not spent. Cookie presence alone is not
enough — the session is sealed, so only /v1/me can judge it — and a
control plane that cannot answer denies rather than admits. Verdicts are
cached per token, rejections included, so a replayed token cannot turn
avatar loads into control-plane load either.

Answers are cached server side: 512 entries, a day for image bytes, five
minutes for a miss so an avatar-less login is not refetched on every
paint, LRU eviction and a per-entry byte ceiling. Cache hits spend no
allowance, so the per-viewer ceiling binds only on targets nobody has
asked for. Both bounds are per web process, which the hardening
checklist now says.

redirect: "follow" let the upstream redirect anywhere: a fake upstream
pointing at a second server had its bytes returned as an avatar. The
fetch now follows hops by hand — each Location resolved against the URL
that produced it, vetted against the two permitted GitHub hosts over
https before it becomes a request, and the chain cut after three hops
rather than run to whatever length the upstream chooses.

Regression tests were checked by mutation rather than assumed: restoring
follow-any-host fails the redirect cases, including the assertion that
the second server receives nothing, and removing the session gate fails
four cases. apps/web is at 133 tests, up from 88.

Refs theam#175
The board named the assignee in text but did not show them. Add a shared
Avatar primitive and use it on the story row, immediately left of @login.

The image is painted as a CSS background rather than as an <img>. An <img>
that fails to load makes every browser draw its own broken-image glyph over
the letter beneath it, and alt="" does not suppress it; a background that
fails to load paints nothing. So a deployment whose browsers cannot reach
github.com falls back to the initial letter on its own, with nothing to
configure.

The avatar URL and the fallback letter are derived in apps/web, not in
packages/ui, which keeps the primitive free of any knowledge of GitHub and
puts the rules where there is a test runner to pin them.

Refs theam#174
The same primitive as the story row, at 16px to sit with the header's
larger type. The header is a server component and the row is a client
component, so this is also what proves one primitive serves both.

Refs theam#174
The topbar rendered an image when the principal had an avatar URL and
nothing at all when it did not, so a user whose GitHub identity has no
avatar saw an empty space. It now uses the same primitive as the board and
falls back to the initial letter of the login, or of the email when there
is no login.

Two visible consequences, both deliberate:

- The avatar is square rather than round. PillTag is documented as the only
  pill-shaped element in the design system, and the board avatars are
  square, so one shape now serves all three sites.
- The image is no longer an <img>, so it can no longer carry
  referrerPolicy="no-referrer". Referrer policy belongs to the fetch
  initiator and CSS cannot set one; measured across Chromium, Firefox and
  WebKit, a pseudo-element, a child element, an inline style, an external
  stylesheet carrying referrerpolicy, and a custom-property indirection all
  send the origin. The avatar host therefore now learns the deployment's
  origin. The only alternatives are document-wide and would change every
  other request the app makes.

Refs theam#174
Browsers no longer fetch avatars from GitHub. Images are proxied through
new /api/avatars/u/{login} and /api/avatars/id/{id} routes, whose server-
side fetch carries fresh headers — no cookies, forwarding chain, or
referrer — so the deployment origin never reaches GitHub with avatar
lookups, restoring the guarantee the first cut lost when it dropped
referrerPolicy="no-referrer".

An operator can set NEXT_PUBLIC_FACILITY_AVATARS=off to draw initial
letters only; unknown values fail closed to off. There is deliberately
no browser-direct mode left: it required third-party egress that
air-gapped deployments must not make.

The route serves only two exact path shapes pinned to two GitHub hosts,
and maps every upstream failure to 404, which leaves the CSS background
unset and the initial letter showing.

Stored principal avatar URLs are rewritten onto the proxy only when they
match a known GitHub shape; anything else falls back to the login-derived
source so the browser is never pointed at an unreviewed host.
Review found two ways the /api/avatars routes let a caller drive this
deployment's outbound traffic. Both are fixed here rather than in two
commits because they share one route body and one test harness.

The routes were public, and every request produced a fresh upstream
fetch, so anyone who could reach the deployment could cycle valid-looking
logins or IDs and make it call GitHub without limit. A request now
reaches an upstream only when three things hold: it carries a session
the control plane recognises, no cached answer already exists, and the
viewer's fetch allowance is not spent. Cookie presence alone is not
enough — the session is sealed, so only /v1/me can judge it — and a
control plane that cannot answer denies rather than admits. Verdicts are
cached per token, rejections included, so a replayed token cannot turn
avatar loads into control-plane load either.

Answers are cached server side: 512 entries, a day for image bytes, five
minutes for a miss so an avatar-less login is not refetched on every
paint, LRU eviction and a per-entry byte ceiling. Cache hits spend no
allowance, so the per-viewer ceiling binds only on targets nobody has
asked for. Both bounds are per web process, which the hardening
checklist now says.

redirect: "follow" let the upstream redirect anywhere: a fake upstream
pointing at a second server had its bytes returned as an avatar. The
fetch now follows hops by hand — each Location resolved against the URL
that produced it, vetted against the two permitted GitHub hosts over
https before it becomes a request, and the chain cut after three hops
rather than run to whatever length the upstream chooses.

Regression tests were checked by mutation rather than assumed: restoring
follow-any-host fails the redirect cases, including the assertion that
the second server receives nothing, and removing the session gate fails
four cases. apps/web is at 133 tests, up from 88.

Refs theam#175
@cloudbridgeuy

Copy link
Copy Markdown
Author

Both gaps are fixed in 3504a03, the one commit on top of the avatar work. Reproduced both before changing anything.

/api/avatars/… was public, and every request fetched upstream

Confirmed: the route had no auth check, and the fetch was unconditional.

A request now reaches GitHub only when three things hold.

It carries a session the control plane recognises. avatarViewerId reads facility_session and validates it against /v1/me. No cookie, an unrecognised cookie, or a control plane that cannot answer → 401 before any fetch. Cookie presence is not enough, because the token is sealed and only the control plane can judge it — a forged value is rejected like any other. Verdicts are cached per token for 60s, rejections included, so a replayed token cannot turn avatar loads into control-plane load either.

No cached answer exists. Bounded in-process cache: 512 entries, LRU eviction, 24h for image bytes, 5min for a miss so an avatar-less login is not refetched on every paint, plus a 256 KiB per-entry ceiling.

The viewer's allowance is not spent. Per-viewer token bucket, burst 60 and 30/min, charged only on a cache miss — so painting a board of distinct assignees is free after warm-up, and cycling fresh logins is what runs the meter. Over the ceiling is a 429.

Together: outbound avatar traffic is bounded by the number of people with accounts, not by inbound request volume. Both bounds are per web process — run several and each holds its own. That is now in the self-host hardening checklist.

redirect: "follow" let the upstream redirect off-host

Your repro is the right one, and it worked. Replaced with redirect: "manual" and a hop loop that vets each Location before it becomes a request: resolved against the URL that produced it, then required to be https on github.com or avatars.githubusercontent.com. Chain cut after three hops — GitHub's own is one, the rest is slack rather than an invitation.

Regression tests

apps/web is at 135 tests. The two you asked for were checked by mutation rather than assumed:

Mutation Result
Restore follow-any-host redirect cases fail, including the assertion that a second local server receives zero requests — your exact repro, as a test
Remove the session gate four cases fail

Also covered: relative and protocol-relative Location, an http://github.com downgrade, github.com.evil.example, a missing Location, an endless redirect loop, all five redirect statuses, cache hit counts, miss caching, and the rate-limit ceiling. No live credentials and no network egress — the GitHub surface and the control plane are both local fakes.

Notes

main gained the WSJF chip and the Builder plan policy while this branch was
open, and both landed in the two files this stack rewrites.

Three conflicts, all resolved by keeping both sides:

- issue-row.tsx imports and row body: the WSJF chip and the assignee avatar
  are independent additions after the labels. The chip keeps main's adjacency
  to the labels and the owner sits next to the timestamp.
- stories/page.tsx data loading: main added api.project alongside api.me,
  this stack renamed me to meResult and added the mine search param. The
  combined call binds all three.
- stories/page.tsx board body: this stack extracted the stage list into
  boardBody(), main added builderPlanRequired to IssueRow inside it. The
  extraction is kept and the prop threaded into it.

theam#171 is still open and rewrites the same two files, so it will meet the same
conflicts and must resolve them the same way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Assignee avatars on the story board (follow-up to #101)

3 participants