Skip to content

feat(workspace) user avatars - #607

Open
QuanCheng-QC wants to merge 1 commit into
developfrom
feature/user-avatars
Open

feat(workspace) user avatars#607
QuanCheng-QC wants to merge 1 commit into
developfrom
feature/user-avatars

Conversation

@QuanCheng-QC

Copy link
Copy Markdown
Collaborator

Background

Workspace had no user avatars. Everyone showed up as an email initial in the team roster, in chat messages and in the account menu.

Source: product request, raised during this development cycle. There is no corresponding Issue. This is not a bug report and not a test finding.

This is a new capability rather than a fix, so "before/after" below describes what the product could and could not do, not a defect.

How to reproduce the gap

As any signed-in user, in any workspace: open workspace settings, the team member list, or any chat thread, and look for a way to set a personal avatar. There is no entry point anywhere, and no endpoint that reads or writes a user avatar.

Behavior before

  • No way to upload an avatar. No column on users, no endpoint.
  • Everyone rendered as an initial, so people were told apart by email address.
  • GET /v1/workspaces/{id}/team returned only email / displayName / role / joinedAt and no stable userId, leaving the frontend to use email as a person's identity.

Behavior after

  • Users can upload, replace and remove an avatar from settings. The avatar belongs to the user, not to a workspace, and follows them into every workspace they are a member of.
  • It renders in the account menu, the team list and chat messages, falling back to initials when absent or when the image fails to load.
  • New GET /v1/account/profile returns userId / email / displayName / avatarUrl.
  • The team endpoint now also returns userId and avatarUrl.

Design

Storage. Bytes go through the existing FileStore, so they land wherever files already do — S3 in production, local disk in dev — with no new component and no new configuration. The key is avatars/{user_id}/{blob_id}.webp, built through FileStore's existing four-argument save(), so app/storage.py needed no change. The database stores only that key, in users.avatar_key.

No FileRecord row. The Files page lists FileRecords per workspace. An avatar is neither workspace-scoped nor something a user should be able to delete from a file browser, so it is deliberately kept out of that table and is invisible there.

The read URL is the capability. GET /v1/avatars/{user_id}/{blob_id}.webp takes no credential and does no database query. An <img> tag cannot send an Authorization header, and copying the ?token= scheme from file downloads would have handed viewers the workspace token — which bypasses role checks and which /v1/account/workspaces deliberately withholds from them. Instead blob_id is 128 bits of secrets.token_hex(16), appearing only in authenticated responses. Team rosters withhold it from machine-token callers and from the anonymous read that open workspaces still permit.

Random keys, not content hashes. Two concurrent uploads by the same user can never collide, so neither can delete the key the other just committed. Cache-busting comes free because the URL changes on every upload.

Deletion is a transactional outbox. The pointer swap and a row in the new blob_deletions table commit together, so an S3 outage cannot lose the intent. app/blob_gc.py drains the table on the existing _run_maintenance cycle, claiming rows with FOR UPDATE SKIP LOCKED and retrying with exponential backoff. The alternative — best-effort delete plus a log line — would leave an avatar the user asked us to remove readable forever, with nothing in the system recording that it should be gone.

Uploads are re-encoded to a 512×512 WebP. This is the security boundary, not a resize: it kills SVG/polyglot stored XSS, strips the GPS coordinates phone photos carry, and bounds decompression bombs (pixel count is checked from the header before any pixel is allocated). EXIF orientation is applied before the crop, or portrait photos land sideways. Format is decided by magic bytes, never by the declared Content-Type.

Cache is private, max-age=86400, no immutable. Deleting a blob 404s the origin immediately; a browser that already fetched it keeps its copy for at most 24h. immutable plus a long max-age would have meant "never revocable".

Scope and risk

New, no effect on existing behavior

  • app/avatar.py, app/blob_gc.py, app/routers/avatars.py
  • Migration 030 (two nullable columns on users) and 031 (new blob_deletions table)

Changes existing behavior

  • GET /v1/workspaces/{id}/team gains userId and avatarUrl. avatarUrl is only sent to callers with a verifiable identity.
  • DELETE /v1/account now also clears the avatar; its deleted map gains an avatar count.
  • _run_maintenance gains one outbox drain per cycle (~5 minutes).

Deliberately left alone

  • GET /v1/account/workspaces is byte-for-byte unchanged. The Swift client decodes it as [AccountWorkspace], and the Go and web clients index it directly, so wrapping it would break three clients. Hence a separate /v1/account/profile.
  • app/storage.py and verify_workspace_access are untouched.

Known pre-existing gap, out of scope

DELETE /v1/account still does not delete the User or WorkspaceMembership rows, so identity data outlives the "delete my account" it promises. Fixing it first requires deciding what happens to workspaces the user owns; it deserves its own issue. This PR only guarantees the avatar bytes are reliably deleted.

Deployment

New dependency: Pillow>=10.0.0. Installed at image build from requirements.txt, so production needs no manual step. Note the failure mode: Pillow is imported lazily inside the transcode function, so a container missing it still boots and serves everything else — only avatar uploads return 500. It will not show up as a failed health check. Anyone running the backend from source must re-run pip install -r requirements.txt.

Migrations 030 and 031 run automatically on Railway via preDeployCommand = "alembic upgrade head" in workspace/backend/railway.toml, once before replicas start. The container entrypoint does not run them. For any deployment not driven by that config (e.g. the ECS path in the migration guide), alembic upgrade head must be run manually before the new image goes live.

Ordering and rollback. Both migrations are purely additive — two nullable columns and one new table — so the old code runs fine against the new schema. Migrate first, then deploy. Rolling the code back needs no schema rollback. Reverting 031 would discard any pending deletions, so prefer leaving it in place.

No S3 or IAM change. Avatars use the existing bucket under an avatars/ prefix, and the task-role policy is bucket-wide (arn:aws:s3:::<bucket>/*), so no policy update is required.

Memory ceiling worth tuning. Decoding runs under a semaphore that is per process. Railway runs 2 replicas × 4 uvicorn workers, so the defaults (AVATAR_DECODE_CONCURRENCY=4, AVATAR_MAX_PIXELS=25M) allow up to 4 × 4 × ~100MB ≈ 1.6GB of decode buffers per replica under a burst. Consider lowering AVATAR_DECODE_CONCURRENCY to 2, or AVATAR_MAX_PIXELS, to match the actual pod memory. All AVATAR_* settings have defaults and none is required to deploy.

SQLite (local dev only). The schema comes from create_all at startup, which creates new tables but does not add columns to existing ones. An existing .db needs ALTER TABLE users ADD COLUMN avatar_key TEXT and avatar_updated_at TIMESTAMP, or every request touching User returns 500 — and because a 500 loses its CORS headers, that surfaces in the browser as a cross-origin error rather than a server error.

How to verify

Automated

cd workspace/backend && python -m pytest tests/test_avatars.py -q   # 35 passed

The full suite goes from 546 to 581 passing with the same 64 pre-existing failures, so there are no regressions. Frontend tsc --noEmit and next build both pass.

Manual — sign in, open workspace settings, scroll to Your avatar just above "Require login".

Action | Expected -- | -- Upload an image | Account menu and team list both update Post a message | The avatar renders beside it Upload a different image | New avatar applies, old URL returns 404 Remove it | Falls back to initials, old URL 404s Upload an .svg, and again renamed to .png | Both rejected with 400 Upload a portrait photo from a phone | Correct orientation, not sideways Upload something over 5MB | 413
# Cache semantics: private, and no immutable
curl -sI "http://localhost:8000/v1/avatars/<uid>/<blob>.webp" | grep -i "cache-control\|etag"

Team read with a workspace token: avatarUrl must be null

curl -s "http://localhost:8000/v1/workspaces/&lt;wsid&gt;/team" -H "X-Workspace-Token: <token>" | python -m json.tool

After a normal avatar change the delete queue should be empty

sqlite3 <db> "select * from blob_deletions;"

Verified end to end locally against SQLite with real Firebase sign-in.

Users can upload an avatar from workspace settings. It follows them into
every workspace they're a member of, and shows up in the team roster, chat
messages and the account menu.

Storage reuses the existing FileStore, so the bytes land wherever files
already do — S3 in production, disk locally — with no new component. The
database holds only the key; deliberately no FileRecord row, since those are
listed per-workspace on the Files page and an avatar is neither
workspace-scoped nor something a user should be able to delete from there.

Two decisions worth calling out.

The read URL carries no credential. An <img> tag can't send an Authorization
header, and the obvious move — copying the `?token=` scheme from file
downloads — would have leaked the workspace token, which bypasses role checks
and is deliberately withheld from viewers. Instead the URL is the capability,
a random 128-bit blob id that only appears in authenticated responses. Team
rosters hand it out only to callers with a verified identity, since an open
workspace's roster is anonymously readable.

Deletion goes through a transactional outbox rather than a best-effort call
after the commit. One S3 timeout would otherwise leave an avatar the user
asked us to remove readable forever, with nothing recording that it should be
gone. The pointer swap and the deletion record commit together; a drainer on
the existing maintenance cycle empties the table with backoff.

Uploads are re-encoded to a 512x512 WebP. That's the security boundary rather
than a resize — it kills SVG/polyglot stored XSS, strips the GPS coordinates
phone photos carry, and bounds decompression bombs. EXIF orientation is
applied before the crop, or portrait photos land sideways.

Adds migrations 030/031 and Pillow. Does NOT fix the pre-existing gap where
DELETE /v1/account leaves the User and WorkspaceMembership rows behind —
that needs a decision about workspaces the user owns, tracked separately.
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openagents-workspace Ready Ready Preview Aug 11, 2026 9:26am

Request Review

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.

1 participant