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
parent 982a437523
commit 01f51854f6
21 changed files with 1266 additions and 0 deletions

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`) |