feat(sdd): add .specify scaffold — constitution, AGENTS, personas, templates, example, RTM

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>
This commit is contained in:
Marcel
2026-06-13 11:56:31 +02:00
committed by marcel
parent e186a3f646
commit fdc3e4ffa9
21 changed files with 1266 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
# ADR-001 (feature-local) — Avatars reuse the archive bucket under an `avatars/` prefix
**Status:** Accepted
**Date:** 2026-06-13
**Issue:** #<example> (profile picture upload)
> **Feature-local ADR.** This decision is scoped to the avatar feature and lives with its
> spec. A decision with project-wide reach is promoted to the permanent archive at
> `docs/adr/` with the next free number. (For the worked example, it stays local.)
## Context
Avatars are small binary objects keyed per user. The project already runs MinIO with a
single archive bucket and a `FileService` abstraction used by document uploads. We must
decide where avatar bytes live without adding operational surface that the self-hosted
Compose deployment has to learn about.
## Decision
Store each avatar in the **existing archive bucket** under the deterministic key
`avatars/{userId}`, written and read through the existing `FileService`. No new bucket, no
new env var, no new Compose service or bucket-bootstrap step.
## Alternatives Considered
| Option | Pros | Cons | Reason rejected |
|---|---|---|---|
| Reuse archive bucket, `avatars/` prefix | No infra change; reuses `FileService`; idempotent overwrite | Mixes avatars with documents in one bucket | **Chosen** — least operational cost; prefix keeps them logically separate |
| Dedicated `avatars` bucket | Clean separation; independent lifecycle/policy | New bucket + bootstrap step + env var + Compose idempotency test | Operational overhead not justified for small, low-value objects |
| Store bytes in PostgreSQL (`bytea`) | One datastore; transactional with the row | Bloats the DB and backups; streaming images via JPA is awkward | Wrong tool; MinIO already exists for blobs |
| External CDN / object store | Offloads bandwidth | New third-party dependency + secret + ADR; conflicts with self-hosted goal | Contradicts the self-hosted infrastructure stance |
## Consequences
- No deployment change ships with this feature — only a Flyway column and code.
- Avatars and documents share a bucket; any future per-object lifecycle policy must filter
by the `avatars/` prefix.
- The deterministic key (`avatars/{userId}`, no random suffix) makes replace an overwrite,
so there is no orphan-cleanup obligation (REQ-001).
- If avatars later need independent retention or a public CDN, this ADR is superseded by a
project-wide ADR in `docs/adr/`.
## References
- [`./spec.md`](./spec.md), [`./design.md`](./design.md)
- [constitution §5 Dependency Policy](../../constitution.md#5-dependency-policy)

View File

@@ -0,0 +1,136 @@
openapi: 3.1.0
info:
title: Familienarchiv API — Profile picture upload
version: 0.0.1-SNAPSHOT
description: >
Design-time contract for the avatar feature (.specify/features/_example).
Source of truth once shipped is the generated /v3/api-docs.
servers:
- url: http://localhost:8080
description: Local backend (dev profile)
- url: https://archiv.raddatz.cloud
description: Production (behind Caddy)
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: SESSION
schemas:
ErrorResponse:
type: object
required: [code, message]
properties:
code:
type: string
example: AVATAR_TOO_LARGE
message:
type: string
UserProfileView:
type: object
required: [id, displayName]
properties:
id:
type: string
format: uuid
displayName:
type: string
avatarUrl:
type: [string, "null"]
description: Authenticated proxy path (/api/users/{id}/avatar) when an avatar exists, else null.
example: /api/users/3f1c.../avatar
security:
- cookieAuth: []
paths:
/api/users/me/avatar:
post:
summary: Upload or replace the current user's avatar
operationId: uploadMyAvatar
security:
- cookieAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: PNG or JPEG, max 2 MB.
responses:
'200':
description: Avatar stored; updated profile returned.
content:
application/json:
schema: { $ref: '#/components/schemas/UserProfileView' }
'400':
description: Unsupported type (UNSUPPORTED_FILE_TYPE) or too large (AVATAR_TOO_LARGE).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthenticated (UNAUTHORIZED).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
delete:
summary: Remove the current user's avatar
operationId: deleteMyAvatar
security:
- cookieAuth: []
responses:
'200':
description: Avatar removed; profile returned with avatarUrl null.
content:
application/json:
schema: { $ref: '#/components/schemas/UserProfileView' }
'401':
description: Unauthenticated (UNAUTHORIZED).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/api/users/{id}/avatar:
get:
summary: Stream a user's avatar image (authenticated proxy)
operationId: getUserAvatar
security:
- cookieAuth: []
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
'200':
description: Image bytes.
content:
image/png: { schema: { type: string, format: binary } }
image/jpeg: { schema: { type: string, format: binary } }
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
'404': { description: User has no avatar, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
delete:
summary: Remove another user's avatar (admin only)
operationId: deleteUserAvatar
description: Requires Permission.ADMIN_USER (enforced by @RequirePermission on the controller).
security:
- cookieAuth: []
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
'200':
description: Avatar removed.
content:
application/json:
schema: { $ref: '#/components/schemas/UserProfileView' }
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
'403':
description: Caller lacks ADMIN_USER (FORBIDDEN).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }

View File

@@ -0,0 +1,76 @@
# Persona Review Results — Profile picture upload
> Captured from the six persona spec reviews (the comments that, in a real feature, are
> posted on the Gitea issue). This is the worked example of what a completed review round
> looks like. All personas APPROVE; the two findings raised were folded into the spec
> before approval.
## Summary
| Persona | Verdict | Blocking FAILs | Notes |
|---|---|---|---|
| Requirements Engineer | APPROVE | none | — |
| Developer | APPROVE | none | — |
| Security | APPROVE | none (2 resolved) | See F-SEC-1, F-SEC-2 |
| DevOps | APPROVE | none | — |
| UI/UX | APPROVE | none (1 resolved) | See F-UX-1 |
| Architect | APPROVE | none (1 resolved) | See F-ARCH-1 |
---
## ### Security — Spec Review
| # | Item | Status | Note |
|---|---|---|---|
| 1 | All mutating endpoints have authn + authz `If` clauses | PASS | REQ-006 (401), REQ-009 (403) |
| 2 | Each mutating endpoint names least-privilege `Permission` | PASS | `me` = authenticated; `{id}` = ADMIN_USER |
| 3 | Audit fields server-set, forbidden in body | PASS | `avatarObjectKey` server-set (design.md) |
| 4 | IDOR surfaces addressed | PASS | `/{id}` gated by ADMIN_USER + ownership |
| 5 | Untrusted content rendered safely | PASS | image bytes via proxy + `nosniff` |
| 6 | Upload: type allow-list + size + bytes | PASS | REQ-007 (PNG/JPEG), REQ-008 (2 MB) |
| 7 | No entity internals leaked | PASS | `UserProfileView`, not `AppUser` |
| 8 | Conflicts → 409 not raw 500 | N/A | no optimistic-lock surface here |
| 9 | threat-model.md present & STRIDE-complete | PASS | [threat-model.md](./threat-model.md) |
| 10 | ASTRIDE if AI tool used | N/A | no AI agent |
| 11 | Secrets from env only | PASS | none introduced |
| 12 | Logs PII-free | PASS | user UUID only |
| 13 | New dependency has ADR + clean audit | N/A | no new dependency |
**F-SEC-1 (resolved):** initial draft exposed a public S3 URL for `avatarUrl`
information disclosure. Resolved: authenticated proxy `GET /api/users/{id}/avatar`.
**F-SEC-2 (resolved):** initial draft bound `avatarObjectKey` from the request body →
mass-assignment. Resolved: server-set only.
**Verdict: APPROVE.**
## ### UI/UX — Spec Review
| # | Item | Status | Note |
|---|---|---|---|
| 1 | Every interaction state described | PASS | idle/preview/uploading/error/done (T-10) |
| 2 | Strings via Paraglide i18n | PASS | T-8 |
| 3 | Reuses design tokens/components | PASS | placeholder uses existing initials pattern |
| 4 | Responsive per device split | PASS | control usable on phone + laptop |
| 5 | Errors via `getErrorMessage(code)` | PASS | UNSUPPORTED_FILE_TYPE / AVATAR_TOO_LARGE |
| 6 | Keyboard + screen-reader | PASS | labelled file input, alt text on image |
| 7 | Acceptance criteria measurable | PASS | sizes, status codes |
| 8 | E2E scenario per journey | PASS | T-12 |
| 9 | Confirmation for destructive action | PASS | remove asks to confirm |
| 10 | Safe rendering + image dims | PASS | fixed dims avoid layout shift |
| 11 | Live routes verified | PASS | `/profile`, `/users/[id]` exist |
| 12 | Token theming respected | PASS | semantic tokens |
**F-UX-1 (resolved):** no loading state in first draft → spinner during upload added (REQ-... covered by state set in T-10).
**Verdict: APPROVE.**
## ### Architect — Spec Review
Key items PASS. **F-ARCH-1 (resolved):** bucket choice was undocumented → captured in
[adr-001-avatars-reuse-archive-bucket.md](./adr-001-avatars-reuse-archive-bucket.md). No new
domain, no boundary crossing, Person/AppUser separation intact. **Verdict: APPROVE.**
## ### Requirements Engineer / Developer / DevOps — Spec Review
All checklist items PASS (see each persona's checklist in `.specify/personas/`). RE: 9 REQ
ids, all EARS-formed, every limit has an `If`. Developer: reuses `FileService`/`UserService`,
`AVATAR_TOO_LARGE` four-site update is T-1. DevOps: V78 forward-only + rollback note, no new
bucket/env var, idempotent overwrite. **All three: APPROVE.**

View File

@@ -0,0 +1,63 @@
# Design — Profile picture upload
> Companion to [`./spec.md`](./spec.md). The spec says *what*; this says *how*, and records
> the alternatives weighed for the non-obvious choices.
## Component overview
```
ProfileSettings.svelte ──► +page.server.ts (form action)
(preview, validate) │ POST /api/users/me/avatar (multipart)
UserAvatarController ── @RequirePermission(authenticated)
│ ownership/admin check for /{id}
UserService.setAvatar(userId, MultipartFile)
│ validate type+size → ErrorCode
├──► FileService.put("avatars/{userId}", bytes) (MinIO)
└──► userRepository.save(user.avatarObjectKey=key)
UserProfileView { …, avatarUrl }
```
Reads: `GET /api/users/{id}/avatar` streams the object through the authenticated API
(`FileService.get`), so no public S3 URL is ever exposed. `avatarUrl` in the view is simply
`/api/users/{id}/avatar` when a key exists, else `null`.
## Key decisions
| Decision | Choice | Why |
|---|---|---|
| Where avatars live | Existing archive bucket, `avatars/{userId}` prefix | No new bucket/env var/Compose change — see [ADR-001](./adr-001-avatars-reuse-archive-bucket.md). |
| URL exposure | Authenticated proxy endpoint, not a signed/public URL | Same auth surface as the rest of the API; no key leakage (Information disclosure). |
| Object key | Deterministic `avatars/{userId}` (no random suffix) | A new upload overwrites the old object — no orphan-cleanup job needed (REQ-001). |
| `avatarObjectKey` binding | Server-set in `UserService` only | Never bound from request body — prevents pointing a user's avatar at an arbitrary object (Tampering / CWE-639). |
| Validation site | `UserService`, boundary-only | Type + size checked once, at the service boundary, mapped to `ErrorCode` (constitution §2.3). |
## Layering & conventions
- Controller → `UserService` only; `UserService` owns `userRepository` and calls
`FileService` (its public API), never another domain's repository. (constitution §1.21.3)
- New `ErrorCode.AVATAR_TOO_LARGE` requires the four-site update (see `tasks.md` T-1).
- `UserProfileView.avatarUrl` is `String` (nullable) with `@Schema` describing the proxy
path; not marked `requiredMode = REQUIRED` because it is legitimately null (REQ-004).
- After backend changes: `npm run generate:api` regenerates `avatarUrl` into the TS types.
## Non-functional notes
- Size cap (2 MB, REQ-008) is enforced **before** the object touches MinIO — the multipart
is read into a bounded buffer; Spring's `spring.servlet.multipart.max-file-size` is set to
a matching ceiling so an oversized body is rejected at the container edge too.
- No N+1 risk: the profile view derives `avatarUrl` from the already-loaded `avatarObjectKey`
column; no extra query, no S3 round-trip on list/read paths.
- The proxy `GET` streams bytes (no full-buffer) and sets a short `Cache-Control` so an
updated avatar propagates quickly.
## Test strategy (maps to tasks.md)
| Level | What | Tooling |
|---|---|---|
| Unit | `UserService.setAvatar` validation + storage interactions | JUnit + Mockito (mock `FileService`) |
| Slice | controller auth, status codes, error codes | `@WebMvcTest` |
| E2E | upload → preview → confirm → avatar visible; remove → initials | Playwright |
| Component | initials placeholder when `avatarUrl` is null | `vitest-browser-svelte` (`*.svelte.spec.ts`) |

View File

@@ -0,0 +1,118 @@
# 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](../../constitution.md#2-security-defaults) — upload validation, permission gating, no PII in logs.
- [§1.3 services own their repository](../../constitution.md#1-architecture-principles) — avatar storage goes through `UserService` + `FileService`, not a controller.
- [§3.6 ErrorCode four-site rule](../../constitution.md#3-code-quality-rules) — 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/avatar` with a valid image, the user service shall store the image, set the user's `avatarObjectKey`, and return the updated profile view including a non-null `avatarUrl`.
- **REQ-003** (Event-driven) — When an authenticated user sends `DELETE /api/users/me/avatar`, the user service shall delete the stored object, clear `avatarObjectKey`, and return the profile view with `avatarUrl = null`.
- **REQ-004** (State-driven) — While a user has no stored avatar, the profile view for that user shall return `avatarUrl = null` and the frontend shall render the initials placeholder.
- **REQ-005** (Optional-feature) — Where the caller holds `Permission.ADMIN_USER`, the user service shall allow `DELETE /api/users/{id}/avatar` to remove another user's avatar.
- **REQ-006** (Unwanted-behavior) — If the request to any avatar endpoint is unauthenticated, then the system shall return `401` with `ErrorCode.UNAUTHORIZED` and store or delete nothing.
- **REQ-007** (Unwanted-behavior) — If the uploaded file's content type is not `image/png` or `image/jpeg`, then the user service shall return `400 ErrorCode.UNSUPPORTED_FILE_TYPE` and store nothing.
- **REQ-008** (Unwanted-behavior) — If the uploaded file exceeds 2 MB, then the user service shall return `400 ErrorCode.AVATAR_TOO_LARGE` and store nothing.
- **REQ-009** (Unwanted-behavior) — If a caller without `Permission.ADMIN_USER` targets another user's avatar via `/api/users/{id}/avatar`, then the system shall return `403 ErrorCode.FORBIDDEN` and 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 a `FileService` interaction test.
- **REQ-002** — `POST /api/users/me/avatar` with a 100 KB PNG returns `200` and a body whose `avatarUrl` is a non-null string; the persisted `app_users.avatar_object_key` equals `avatars/{userId}`.
- **REQ-003** — `DELETE /api/users/me/avatar` returns `200`, the object is gone, and the response `avatarUrl` is `null`.
- **REQ-004** — `GET` profile view for a user with `avatar_object_key IS NULL` returns `avatarUrl: null`; the rendered component shows a 2-letter initials placeholder (Playwright).
- **REQ-005** — An `ADMIN_USER` caller deleting another user's avatar returns `200`; the target's `avatar_object_key` becomes `NULL`.
- **REQ-006** — An unauthenticated `POST`/`DELETE` returns `401`; bucket object count is unchanged.
- **REQ-007** — A `text/plain` or `application/pdf` upload returns `400 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's `avatar_object_key` is 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 `Person` entities — this feature is for `AppUser` accounts only (Person ≠ AppUser).
- Gravatar / external avatar providers.
- Animated formats (GIF/WebP) — PNG and JPEG only in v1.
## API / Contract Stub
See [`./api-contract.yaml`](./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)` to `app_users`.
- Flyway `V78__add_app_user_avatar_object_key.sql` (next free number — verify against
`backend/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 MinIO `avatars/` objects are orphaned but harmless on rollback and can be pruned with `mc 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`](./threat-model.md).
## Open Questions
> All resolved before implementation.
- [x] Public or signed avatar URL? — **Resolved:** served through an authenticated
`GET /api/users/{id}/avatar` proxy (same auth as the rest of the API), not a public S3 URL.
- [x] New bucket or reuse archive bucket? — **Resolved:** reuse the archive bucket under an
`avatars/` prefix; see [`./adr-001-avatars-reuse-archive-bucket.md`](./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`](../../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 |

View File

@@ -0,0 +1,47 @@
# Tasks — Profile picture upload
> Red/Green TDD order: each implementation task is preceded by the failing test that
> requires it. Task IDs are referenced from `spec.md` → Traceability and from `.specify/rtm.md`.
> Check off as work lands; reference the issue in each commit (`Refs #<n>`).
## Backend
- [ ] **T-1** Add `ErrorCode.AVATAR_TOO_LARGE` in all four sites at once: `ErrorCode.java`,
`frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`.
*(No new behavior yet — enables REQ-008's error.)* → covers REQ-008 (error plumbing)
- [ ] **T-2** `@WebMvcTest` `UserAvatarControllerTest`: write failing slice tests —
`unauthenticatedReturns401`, `rejectsNonImage` (400 UNSUPPORTED_FILE_TYPE),
`rejectsOversize` (400 AVATAR_TOO_LARGE). Then implement `UserAvatarController` +
`@RequirePermission` to green. → REQ-006, REQ-007, REQ-008
- [ ] **T-3** Unit `UserServiceAvatarTest`: failing tests `storesUnderUserKey`,
`replaceLeavesNoOrphan`, validation maps to `DomainException`. Then implement
`UserService.setAvatar`/`removeAvatar` (mock `FileService`) to green. → REQ-001, REQ-002, REQ-003
- [ ] **T-4** Flyway `V78__add_app_user_avatar_object_key.sql` (verify next free number on
disk) adding nullable `avatar_object_key VARCHAR(512)`; add the column + `@Schema` to
`AppUser` / `UserProfileView` (`avatarUrl` derived). Test: repository round-trip. → REQ-002
- [ ] **T-5** `deleteMyAvatar` controller test + impl (clears key, deletes object, returns
`avatarUrl: null`). → REQ-003
- [ ] **T-6** Admin path: failing tests `adminDeletesOthersAvatar` (200),
`nonAdminForbiddenOnOthers` (403). Implement ownership/`ADMIN_USER` check to green. → REQ-005, REQ-009
- [ ] **T-7** Authenticated proxy `getUserAvatar` streaming endpoint + `Content-Type` +
`X-Content-Type-Options: nosniff`; test 200 bytes / 404 when no avatar. → REQ-004 (view side)
- [ ] **T-A** Run `npm run generate:api` after T-4/T-7 so `avatarUrl` lands in `api.ts`.
## Frontend
- [ ] **T-8** i18n keys for the new strings in `messages/{de,en,es}.json` (button labels,
validation errors mapped via `getErrorMessage`). → REQ-007, REQ-008 (UX)
- [ ] **T-9** Component test `avatar-placeholder.svelte.spec.ts`: failing test asserting
initials render when `avatarUrl` is null; implement the placeholder. → REQ-004
- [ ] **T-10** `/profile` upload control: file picker, client-side type/size pre-check,
instant preview, confirm/remove. States: idle/preview/uploading/error/done. → REQ-002, REQ-003
- [ ] **T-11** Render avatar where names appear (comments, activity feed, `/users/[id]`),
falling back to the placeholder. → REQ-004
- [ ] **T-12** E2E `avatar.spec.ts`: upload → preview → confirm → avatar visible; remove →
initials return. → REQ-002, REQ-003, REQ-004
## Cross-cutting
- [ ] **T-13** Set `spring.servlet.multipart.max-file-size` to a 2 MB-matching ceiling so an
oversized body is rejected at the container edge (defense in depth for REQ-008).
- [ ] **T-14** Update `.specify/rtm.md` Status column to `Done` per REQ as each test goes green.

View File

@@ -0,0 +1,45 @@
# Threat Model — Profile picture upload
**Feature spec:** [./spec.md](./spec.md)
**Date:** 2026-06-13
**Author:** Security persona (worked example)
## Data Flow Diagram (text)
**Actors**
- Anonymous visitor (unauthenticated)
- Authenticated user (uploads their own avatar)
- Admin (`Permission.ADMIN_USER` — may remove others' avatars)
**Trust boundaries**
- TB-1: Browser ⇄ Caddy (public internet ⇄ DMZ)
- TB-2: Caddy ⇄ Backend `:8080` (DMZ ⇄ app)
- TB-3: Backend ⇄ MinIO + PostgreSQL (app ⇄ data plane)
**Data flows**
- F-1: Browser → [TB-1,TB-2] → `UserAvatarController` : multipart image
- F-2: `UserService` → [TB-3] → MinIO : object at `avatars/{userId}`
- F-3: `UserService` → [TB-3] → PostgreSQL : `app_users.avatar_object_key`
- F-4: Browser → [TB-1,TB-2,TB-3] → MinIO (via proxy GET) : image bytes
## STRIDE
| Threat Category | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|---|---|---|---|---|---|
| **S**poofing | F-1 | Unauthenticated caller uploads/deletes an avatar | Session auth required; `@RequirePermission` (REQ-006) | Low × Med | Mitigated |
| **T**ampering | F-3 | Caller sets `avatarObjectKey` via request body to point at an arbitrary stored object | `avatarObjectKey` is server-set in `UserService` only, never bound from body (CWE-639) | Med × High | Mitigated |
| **R**epudiation | F-2/F-3 | No record of who changed an avatar | Standard request logging by user UUID (no PII); admin deletions auditable via existing logs | Low × Low | Accepted |
| **I**nformation disclosure | F-4 | A public/signed S3 URL would let anyone fetch any avatar without auth | Avatars served only through the authenticated proxy `GET /api/users/{id}/avatar`; no public URL | Med × Med | Mitigated |
| **I**nformation disclosure | F-1 | Malicious file (polyglot) served back with a sniffed content type → stored XSS | Store with a fixed `image/png`/`image/jpeg` content type; proxy sets `Content-Type` + `X-Content-Type-Options: nosniff`; only PNG/JPEG accepted (REQ-007) | Low × High | Mitigated |
| **D**enial of service | F-1/F-2 | Oversized or many uploads exhaust storage/memory | 2 MB cap enforced before MinIO write + `multipart.max-file-size` ceiling (REQ-008); deterministic key means one object per user | Med × Med | Mitigated |
| **E**levation of privilege | F-1 | Non-admin removes/replaces another user's avatar via `/{id}` | Ownership check; `ADMIN_USER` required for `/{id}` (REQ-005/REQ-009, 403) | Low × Med | Mitigated |
## ASTRIDE
Not applicable — this feature invokes no AI agent, model, or tool.
## Residual Risk
- **Repudiation (Accepted):** avatar changes are not written to a dedicated audit table.
Accepted because the asset is low-value (a self-chosen picture) and request logs already
attribute the action to a user UUID. Revisit if avatars ever become trust signals.