Introduces the SDD root: a v1.0.0 constitution and machine-readable AGENTS.md grounded in the project's real conventions; six EARS-aware persona spec-review checklists that cross-reference .claude/personas/; feature-spec/ADR/threat-model/ api-contract templates; a fully worked _example feature; a living RTM; and an adrs/ pointer that reuses the existing docs/adr/ archive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.5 KiB
As a user I want to upload a profile picture so other family members recognise me
This is the canonical worked example for SDD in this repo. It is fictional but realistic, chosen because no real avatar feature exists in the codebase. Use it as the reference shape for a real
spec.md. Every section is filled — no placeholders.
Context & Why
Readers and transcribers collaborate in threads and on document comments, but every user is
currently represented by initials only. Letting a user upload a small profile picture makes
the activity feed, comments, and the public user profile page (/users/[id]) more personal
and easier to scan — directly serving the family-archive product goal of feeling like a
shared family space, not a database.
Constitution principles this feature depends on:
- §2 Security Defaults — upload validation, permission gating, no PII in logs.
- §1.3 services own their repository — avatar storage goes through
UserService+FileService, not a controller. - §3.6 ErrorCode four-site rule — introduces
AVATAR_TOO_LARGE.
Related: builds on the existing FileService (MinIO) used by Document uploads.
User Journey
A logged-in user opens their profile settings (/profile), clicks "Profilbild ändern",
selects a PNG or JPEG from their device, sees an instant preview, and confirms. The picture
replaces their initials everywhere their name appears. They can later remove it and fall
back to initials. An admin (with ADMIN_USER) can remove an inappropriate picture from
another user's account from the admin user view.
Requirements
- REQ-001 (Ubiquitous) — The user service shall store each profile picture as a single object in the existing archive bucket under the key
avatars/{userId}, overwriting any previous object for that user. - REQ-002 (Event-driven) — When an authenticated user sends
POST /api/users/me/avatarwith a valid image, the user service shall store the image, set the user'savatarObjectKey, and return the updated profile view including a non-nullavatarUrl. - REQ-003 (Event-driven) — When an authenticated user sends
DELETE /api/users/me/avatar, the user service shall delete the stored object, clearavatarObjectKey, and return the profile view withavatarUrl = null. - REQ-004 (State-driven) — While a user has no stored avatar, the profile view for that user shall return
avatarUrl = nulland the frontend shall render the initials placeholder. - REQ-005 (Optional-feature) — Where the caller holds
Permission.ADMIN_USER, the user service shall allowDELETE /api/users/{id}/avatarto remove another user's avatar. - REQ-006 (Unwanted-behavior) — If the request to any avatar endpoint is unauthenticated, then the system shall return
401withErrorCode.UNAUTHORIZEDand store or delete nothing. - REQ-007 (Unwanted-behavior) — If the uploaded file's content type is not
image/pngorimage/jpeg, then the user service shall return400 ErrorCode.UNSUPPORTED_FILE_TYPEand store nothing. - REQ-008 (Unwanted-behavior) — If the uploaded file exceeds 2 MB, then the user service shall return
400 ErrorCode.AVATAR_TOO_LARGEand store nothing. - REQ-009 (Unwanted-behavior) — If a caller without
Permission.ADMIN_USERtargets another user's avatar via/api/users/{id}/avatar, then the system shall return403 ErrorCode.FORBIDDENand modify nothing.
Acceptance Criteria
- REQ-001 — After a successful upload, exactly one object exists at
avatars/{userId}; a second upload leaves exactly one object (no orphan), verified by aFileServiceinteraction test. - REQ-002 —
POST /api/users/me/avatarwith a 100 KB PNG returns200and a body whoseavatarUrlis a non-null string; the persistedapp_users.avatar_object_keyequalsavatars/{userId}. - REQ-003 —
DELETE /api/users/me/avatarreturns200, the object is gone, and the responseavatarUrlisnull. - REQ-004 —
GETprofile view for a user withavatar_object_key IS NULLreturnsavatarUrl: null; the rendered component shows a 2-letter initials placeholder (Playwright). - REQ-005 — An
ADMIN_USERcaller deleting another user's avatar returns200; the target'savatar_object_keybecomesNULL. - REQ-006 — An unauthenticated
POST/DELETEreturns401; bucket object count is unchanged. - REQ-007 — A
text/plainorapplication/pdfupload returns400 UNSUPPORTED_FILE_TYPE; bucket object count is unchanged. - REQ-008 — A 2.1 MB PNG returns
400 AVATAR_TOO_LARGE; bucket object count is unchanged. - REQ-009 — A non-admin caller targeting another user's id returns
403 FORBIDDEN; the target'savatar_object_keyis unchanged.
Out of Scope
- Image cropping, resizing, or transformation — the client sends a final image; the server stores it verbatim within the size limit.
- Avatars for historical
Personentities — this feature is forAppUseraccounts only (Person ≠ AppUser). - Gravatar / external avatar providers.
- Animated formats (GIF/WebP) — PNG and JPEG only in v1.
API / Contract Stub
See ./api-contract.yaml. Endpoints:
POST /api/users/me/avatar (multipart), DELETE /api/users/me/avatar,
DELETE /api/users/{id}/avatar (ADMIN_USER). The profile view gains an optional
avatarUrl: string | null. All mutating endpoints carry @RequirePermission — me
endpoints require an authenticated session; the {id} delete requires ADMIN_USER.
Data Model Changes
- Add nullable
avatar_object_key VARCHAR(512)toapp_users. - Flyway
V78__add_app_user_avatar_object_key.sql(next free number — verify againstbackend/src/main/resources/db/migration/on disk before committing). - Rollback: forward-only. Reverse manually with
ALTER TABLE app_users DROP COLUMN avatar_object_key;. The MinIOavatars/objects are orphaned but harmless on rollback and can be pruned withmc rm --recursive.
Security Considerations
STRIDE categories touched: Tampering (mass-assignment of avatarObjectKey if bound from
body), Elevation of privilege (a non-admin modifying another user's avatar — REQ-009),
Denial of service (oversized upload — REQ-008), Information disclosure (avatar URL
must not expose a signed key that bypasses auth). No AI agent involved, so ASTRIDE does not
apply. Full analysis: ./threat-model.md.
Open Questions
All resolved before implementation.
- Public or signed avatar URL? — Resolved: served through an authenticated
GET /api/users/{id}/avatarproxy (same auth as the rest of the API), not a public S3 URL. - New bucket or reuse archive bucket? — Resolved: reuse the archive bucket under an
avatars/prefix; see./adr-001-avatars-reuse-archive-bucket.md.
Traceability
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|---|---|---|---|
| REQ-001 | T-3 | UserServiceAvatarTest#storesUnderUserKey, …#replaceLeavesNoOrphan |
Planned |
| REQ-002 | T-4 | UserAvatarControllerTest#uploadReturnsAvatarUrl |
Planned |
| REQ-003 | T-5 | UserAvatarControllerTest#deleteClearsAvatar |
Planned |
| REQ-004 | T-7 | avatar-placeholder.svelte.spec.ts |
Planned |
| REQ-005 | T-6 | UserAvatarControllerTest#adminDeletesOthersAvatar |
Planned |
| REQ-006 | T-2 | UserAvatarControllerTest#unauthenticatedReturns401 |
Planned |
| REQ-007 | T-2 | UserAvatarControllerTest#rejectsNonImage |
Planned |
| REQ-008 | T-2 | UserAvatarControllerTest#rejectsOversize |
Planned |
| REQ-009 | T-6 | UserAvatarControllerTest#nonAdminForbiddenOnOthers |
Planned |
Mirrored in .specify/rtm.md.
Persona Review Results
| Persona | Status | Key Findings | Resolved |
|---|---|---|---|
| Requirements Engineer | APPROVE | All 9 REQ ids EARS-formed; every limit has an If clause. |
— |
| Developer | APPROVE | Reuses FileService/UserService; AVATAR_TOO_LARGE four-site update listed (T-1). |
— |
| Security | APPROVE | REQ-006/008/009 cover authn/DoS/EoP; avatarObjectKey server-set only (see threat model T-1). |
Yes |
| DevOps | APPROVE | V78 forward-only with rollback note; no new bucket/env var. | — |
| UI/UX | APPROVE | Placeholder + loading/error states specified; strings via i18n (T-8). | — |
| Architect | APPROVE | Bucket-reuse decision captured in ADR-001; no new domain, no boundary crossing. | Yes |