diff --git a/workspace/backend/alembic/versions/030_add_user_avatar.py b/workspace/backend/alembic/versions/030_add_user_avatar.py
new file mode 100644
index 000000000..036490614
--- /dev/null
+++ b/workspace/backend/alembic/versions/030_add_user_avatar.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+"""Add avatar columns to users.
+
+The bytes live in the existing `FileStore` under `avatars/{user_id}/{blob_id}.webp`
+— `avatar_key` holds that storage key and nothing else. Deliberately NOT a
+`FileRecord` row: the Files page lists FileRecords by workspace, and an avatar
+belongs to a user across every workspace they're in, so it must not show up
+there (nor be deletable from it).
+
+`avatar_updated_at` is display/diagnostics only. Cache-busting rides on the
+random `blob_id` inside the key, so the URL changes on its own every upload.
+
+Revision ID: 030
+Revises: 029
+Create Date: 2026-08-11
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "030"
+down_revision = "029"
+branch_labels = None
+depends_on = None
+
+
+def _columns(inspector, table) -> set:
+ return {c["name"] for c in inspector.get_columns(table)}
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ existing = _columns(inspector, "users")
+
+ if "avatar_key" not in existing:
+ op.add_column("users", sa.Column("avatar_key", sa.Text(), nullable=True))
+ if "avatar_updated_at" not in existing:
+ op.add_column("users", sa.Column("avatar_updated_at", sa.DateTime(timezone=True), nullable=True))
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ existing = _columns(inspector, "users")
+
+ if "avatar_updated_at" in existing:
+ op.drop_column("users", "avatar_updated_at")
+ if "avatar_key" in existing:
+ op.drop_column("users", "avatar_key")
diff --git a/workspace/backend/alembic/versions/031_add_blob_deletions.py b/workspace/backend/alembic/versions/031_add_blob_deletions.py
new file mode 100644
index 000000000..f0e366e8c
--- /dev/null
+++ b/workspace/backend/alembic/versions/031_add_blob_deletions.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+"""Add blob_deletions — a transactional outbox for FileStore deletions.
+
+Deleting a blob is a side effect on a remote system (S3), so it can't join the
+transaction that stops pointing at it. Doing it best-effort right after the
+commit means one S3 timeout leaves an avatar the user asked us to remove
+readable forever, with nothing in the system that knows about it.
+
+Instead the row that stops pointing at a key and the row that says "delete this
+key" commit together. A drainer then empties the table, retrying with backoff.
+Deletion becomes a durable to-do rather than a fire-and-forget side effect.
+
+Named `blob_deletions`, not `avatar_deletions`: nothing here is avatar-specific,
+and any other FileStore blob needing reliable deletion can enqueue into it.
+
+Revision ID: 031
+Revises: 030
+Create Date: 2026-08-11
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects.postgresql import UUID
+
+revision = "031"
+down_revision = "030"
+branch_labels = None
+depends_on = None
+
+
+def _has_table(inspector, table) -> bool:
+ return table in inspector.get_table_names()
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+
+ if not _has_table(inspector, "blob_deletions"):
+ op.create_table(
+ "blob_deletions",
+ sa.Column("id", UUID(as_uuid=False), primary_key=True),
+ sa.Column("storage_key", sa.Text(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
+ sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("next_retry_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
+ sa.Column("last_error", sa.Text(), nullable=True),
+ )
+ # The drainer's only query is "rows due now, oldest first".
+ op.create_index("idx_blob_deletions_due", "blob_deletions", ["next_retry_at"])
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ if _has_table(inspector, "blob_deletions"):
+ op.drop_index("idx_blob_deletions_due", table_name="blob_deletions")
+ op.drop_table("blob_deletions")
diff --git a/workspace/backend/app/avatar.py b/workspace/backend/app/avatar.py
new file mode 100644
index 000000000..922208b1c
--- /dev/null
+++ b/workspace/backend/app/avatar.py
@@ -0,0 +1,202 @@
+# -*- coding: utf-8 -*-
+"""
+Avatar image validation and transcoding.
+
+Every uploaded image is decoded and re-encoded to a fixed-size WebP. That
+re-encode is the security boundary, not an optimization — whatever arrives,
+what we store is a buffer Pillow produced:
+
+* **Stored XSS** — SVG (and HTML/SVG polyglots) can carry '
+ r = _upload(client, "alice", svg, filename="x.svg", content_type="image/svg+xml")
+ assert r.status_code == 400
+
+ def test_content_type_is_not_trusted(self, client):
+ """Text claiming to be a PNG is still text."""
+ r = _upload(client, "alice", b"not an image at all", content_type="image/png")
+ assert r.status_code == 400
+
+ def test_oversized_upload_is_rejected(self, client, monkeypatch):
+ from app.config import config
+ monkeypatch.setattr(config, "AVATAR_MAX_UPLOAD_SIZE", 1024)
+ r = _upload(client, "alice", _png(width=800, height=800))
+ assert r.status_code == 413
+
+ def test_decompression_bomb_is_rejected(self, client, monkeypatch):
+ """Pixel count is checked from the header, before any pixel work."""
+ from app.config import config
+ monkeypatch.setattr(config, "AVATAR_MAX_PIXELS", 1000)
+ r = _upload(client, "alice", _png(width=200, height=200))
+ assert r.status_code == 400
+
+ def test_truncated_image_is_a_400_not_a_500(self, client):
+ data = _png(width=300, height=300)
+ r = _upload(client, "alice", data[: len(data) // 2])
+ assert r.status_code == 400
+
+ def test_empty_upload_is_rejected(self, client):
+ assert _upload(client, "alice", b"").status_code == 400
+
+
+class TestExifOrientation:
+ def test_orientation_is_applied_before_cropping(self, client):
+ """A phone's portrait photo is stored landscape plus an orientation tag.
+
+ Re-encoding drops the tag, so if the rotation isn't baked in first the
+ avatar ends up rotated and cropped along the wrong axis. Compare against
+ what Pillow itself produces for the transposed image.
+ """
+ raw = _jpeg_with_orientation(6, width=100, height=40)
+ url = _upload(client, "alice", raw, filename="p.jpg", content_type="image/jpeg").json()["data"]["avatarUrl"]
+ served = Image.open(io.BytesIO(client.get(url).content))
+
+ from PIL import ImageOps
+ expected = ImageOps.exif_transpose(Image.open(io.BytesIO(raw)))
+ # Orientation 6 turns a 100x40 landscape into a 40x100 portrait.
+ assert expected.size == (40, 100)
+ assert served.size == (512, 512)
+
+
+# ---------------------------------------------------------------------------
+# Replacement, removal, and the deletion outbox
+# ---------------------------------------------------------------------------
+
+class TestLifecycle:
+ def test_replacing_deletes_the_old_blob(self, client, local_store):
+ first = _upload(client, "alice", _png(color=(255, 0, 0))).json()["data"]["avatarUrl"]
+ assert client.get(first).status_code == 200
+
+ second = _upload(client, "alice", _png(color=(0, 255, 0))).json()["data"]["avatarUrl"]
+ assert second != first
+ assert client.get(second).status_code == 200
+ assert client.get(first).status_code == 404
+
+ def test_concurrent_uploads_never_orphan_the_pointer(self, client, db):
+ """Two uploads in a row must leave the DB pointing at bytes that exist.
+
+ Random blob ids are what make this safe: with content-addressed keys, a
+ user re-uploading the same image would produce a key equal to the one
+ being deleted.
+ """
+ same = _png(color=(7, 7, 7))
+ _upload(client, "alice", same)
+ url = _upload(client, "alice", same).json()["data"]["avatarUrl"]
+
+ user = db.query(User).filter(User.email == "alice@example.com").one()
+ assert user.avatar_key is not None
+ assert client.get(url).status_code == 200
+
+ def test_removing_clears_the_pointer_and_the_bytes(self, client, db):
+ url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"]
+ r = client.delete("/v1/account/avatar", headers=_auth("alice"))
+ assert r.status_code == 200
+ assert r.json()["data"]["avatarUrl"] is None
+ assert client.get(url).status_code == 404
+
+ db.expire_all()
+ assert db.query(User).filter(User.email == "alice@example.com").one().avatar_key is None
+
+ def test_remove_is_idempotent(self, client):
+ _upload(client, "alice", _png())
+ assert client.delete("/v1/account/avatar", headers=_auth("alice")).status_code == 200
+ assert client.delete("/v1/account/avatar", headers=_auth("alice")).status_code == 200
+
+
+class TestDeletionOutbox:
+ def test_failed_delete_still_succeeds_and_is_recorded(self, client, db, monkeypatch, local_store):
+ """A storage failure must not fail the user's request — but it must not
+ vanish either. That's the whole reason the outbox exists."""
+ _upload(client, "alice", _png(color=(1, 2, 3)))
+
+ def boom(key):
+ raise RuntimeError("S3 unavailable")
+
+ monkeypatch.setattr(local_store, "delete", boom)
+ r = _upload(client, "alice", _png(color=(4, 5, 6)))
+ assert r.status_code == 200
+
+ db.expire_all()
+ pending = db.query(BlobDeletion).all()
+ assert len(pending) == 1
+ assert pending[0].storage_key.startswith("avatars/")
+
+ def test_drainer_removes_the_blob_and_the_row(self, client, db, monkeypatch, local_store):
+ first = _upload(client, "alice", _png(color=(1, 2, 3))).json()["data"]["avatarUrl"]
+
+ monkeypatch.setattr(local_store, "delete", lambda key: (_ for _ in ()).throw(RuntimeError("down")))
+ _upload(client, "alice", _png(color=(4, 5, 6)))
+ monkeypatch.undo()
+
+ db.expire_all()
+ assert db.query(BlobDeletion).count() == 1
+ assert drain_blob_deletions(db) == 1
+
+ db.expire_all()
+ assert db.query(BlobDeletion).count() == 0
+ assert client.get(first).status_code == 404
+
+ def test_drainer_is_idempotent_for_missing_blobs(self, db):
+ db.add(BlobDeletion(storage_key="avatars/nobody/deadbeef.webp"))
+ db.commit()
+ # LocalFileStore.delete on a missing path is a no-op, so the row clears.
+ assert drain_blob_deletions(db) == 1
+
+ def test_failed_attempts_back_off_rather_than_retrying_hot(self, db, monkeypatch, local_store):
+ db.add(BlobDeletion(storage_key="avatars/x/y.webp"))
+ db.commit()
+ monkeypatch.setattr(local_store, "delete", lambda key: (_ for _ in ()).throw(RuntimeError("down")))
+
+ assert drain_blob_deletions(db) == 0
+ db.expire_all()
+ row = db.query(BlobDeletion).one()
+ assert row.attempts == 1
+ assert row.last_error
+ # Rescheduled into the future, so the next cycle doesn't hammer storage.
+ assert drain_blob_deletions(db) == 0
+
+
+# ---------------------------------------------------------------------------
+# HTTP semantics
+# ---------------------------------------------------------------------------
+
+class TestCachingAndPaths:
+ def test_cache_is_private_and_revocable(self, client):
+ """`immutable` plus a long max-age would mean "can never be withdrawn"."""
+ url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"]
+ cc = client.get(url).headers["cache-control"]
+ assert "private" in cc
+ assert "immutable" not in cc
+
+ def test_if_none_match_returns_304(self, client):
+ url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"]
+ etag = client.get(url).headers["etag"]
+ assert client.get(url, headers={"If-None-Match": etag}).status_code == 304
+
+ def test_nosniff_is_set(self, client):
+ url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"]
+ assert client.get(url).headers["x-content-type-options"] == "nosniff"
+
+ @pytest.mark.parametrize("path", [
+ "/v1/avatars/not-a-uuid/00000000000000000000000000000000.webp",
+ "/v1/avatars/11111111-1111-1111-1111-111111111111/short.webp",
+ "/v1/avatars/11111111-1111-1111-1111-111111111111/../../etc/passwd",
+ "/v1/avatars/11111111-1111-1111-1111-111111111111/abc.txt",
+ ])
+ def test_malformed_paths_are_404_not_500(self, client, path):
+ assert client.get(path).status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# Identity plumbing — profile endpoint and the team roster
+# ---------------------------------------------------------------------------
+
+class TestProfile:
+ def test_profile_returns_a_stable_user_id(self, client):
+ r = client.get("/v1/account/profile", headers=_auth("alice"))
+ assert r.status_code == 200
+ data = r.json()["data"]
+ assert data["email"] == "alice@example.com"
+ assert data["userId"]
+ assert data["avatarUrl"] is None
+
+ _upload(client, "alice", _png())
+ assert client.get("/v1/account/profile", headers=_auth("alice")).json()["data"]["avatarUrl"]
+
+ def test_profile_requires_identity(self, client):
+ assert client.get("/v1/account/profile").status_code == 401
+
+ def test_account_workspaces_still_returns_a_bare_array(self, client):
+ """Three clients index this response directly — Swift decodes it as
+ [AccountWorkspace]. It must stay an array."""
+ r = client.get("/v1/account/workspaces", headers=_auth("alice"))
+ assert r.status_code == 200
+ data = r.json()["data"]
+ assert isinstance(data, list)
+ if data:
+ assert set(data[0]) == {"workspaceId", "name", "slug", "token", "role", "lastActivityAt"}
+
+
+class TestTeamAvatarGating:
+ def _workspace_with_member(self, client, db, *, open_workspace=False):
+ ws = Workspace(
+ name="W", slug="team-ws",
+ password_hash=None if open_workspace else "tok",
+ require_login=False,
+ )
+ db.add(ws)
+ db.flush()
+ user = User(email="alice@example.com", display_name="Alice")
+ db.add(user)
+ db.flush()
+ db.add(WorkspaceMembership(workspace_id=ws.id, user_id=user.id, role="owner"))
+ db.commit()
+ return ws, user
+
+ def test_identified_member_sees_avatar_urls(self, client, db):
+ ws, _ = self._workspace_with_member(client, db)
+ _upload(client, "alice", _png())
+
+ r = client.get(f"/v1/workspaces/{ws.id}/team", headers=_auth("alice"))
+ assert r.status_code == 200
+ row = r.json()["data"][0]
+ assert row["userId"]
+ assert row["avatarUrl"]
+
+ def test_machine_token_caller_gets_no_avatar_urls(self, client, db):
+ """A workspace token proves access, not identity — and it's exactly the
+ credential viewers are denied. It shouldn't hand out capabilities."""
+ ws, _ = self._workspace_with_member(client, db)
+ _upload(client, "alice", _png())
+
+ r = client.get(f"/v1/workspaces/{ws.id}/team", headers={"X-Workspace-Token": "tok"})
+ assert r.status_code == 200
+ assert r.json()["data"][0]["avatarUrl"] is None
+
+ def test_anonymous_read_of_an_open_workspace_gets_no_avatar_urls(self, client, db):
+ """Open workspaces are waved through by verify_workspace_access, so the
+ roster is anonymously readable. The avatar URLs must not be."""
+ ws, _ = self._workspace_with_member(client, db, open_workspace=True)
+ _upload(client, "alice", _png())
+
+ r = client.get(f"/v1/workspaces/{ws.id}/team")
+ assert r.status_code == 200
+ assert r.json()["data"][0]["avatarUrl"] is None
+
+
+class TestAccountDeletion:
+ def test_deleting_the_account_removes_the_avatar(self, client, db):
+ url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"]
+ assert client.get(url).status_code == 200
+
+ r = client.delete("/v1/account", headers=_auth("alice"))
+ assert r.status_code == 200
+ assert r.json()["data"]["deleted"]["avatar"] == 1
+
+ assert client.get(url).status_code == 404
+ db.expire_all()
+ assert db.query(User).filter(User.email == "alice@example.com").one().avatar_key is None
+
+ def test_deleting_without_an_avatar_is_fine(self, client):
+ r = client.delete("/v1/account", headers=_auth("bob"))
+ assert r.status_code == 200
+ assert r.json()["data"]["deleted"]["avatar"] == 0
diff --git a/workspace/frontend/app/[workspaceId]/page.tsx b/workspace/frontend/app/[workspaceId]/page.tsx
index c94d57fc1..0172c7d0c 100644
--- a/workspace/frontend/app/[workspaceId]/page.tsx
+++ b/workspace/frontend/app/[workspaceId]/page.tsx
@@ -3,6 +3,7 @@
import { use, Suspense, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { WorkspaceProvider, useWorkspace } from '@/lib/workspace-context';
+import { AvatarsProvider } from '@/lib/avatars';
import { LayoutProvider } from '@/components/layout/layout-context';
import { Wrapper } from '@/components/layout/wrapper';
import { useOpenAgentsAuth } from '@/lib/openagents-auth-context';
@@ -90,9 +91,11 @@ function WorkspaceContent({ workspaceId }: { workspaceId: string }) {
return (
+ Shown next to your messages across every workspace you're in. JPEG, PNG, GIF or WebP, + up to 5MB. +
+ +{m.displayName || m.email}
{m.displayName &&{m.email}
} diff --git a/workspace/frontend/lib/account-api.ts b/workspace/frontend/lib/account-api.ts index 6db6189c2..0a8065350 100644 --- a/workspace/frontend/lib/account-api.ts +++ b/workspace/frontend/lib/account-api.ts @@ -4,6 +4,8 @@ // access into memberships and auto-provisions an empty workspace for brand-new // users, so a freshly signed-in user always has at least one entry. +import type { AccountProfile } from '@/lib/types'; + const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://workspace-endpoint.openagents.org'; export interface AccountWorkspace { @@ -51,6 +53,51 @@ export function createAccountWorkspace( }); } +/** + * Absolute URL for an avatar path returned by the backend. + * + * The backend returns a path rather than a full URL — it can't reliably know + * its own public origin, and the frontend is served from a different host than + * the API. Pass through nulls so callers can hand the result straight to + *