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:
@@ -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)
|
||||
136
.specify/features/_example/api-contract.yaml
Normal file
136
.specify/features/_example/api-contract.yaml
Normal 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' }
|
||||
76
.specify/features/_example/checklist-results.md
Normal file
76
.specify/features/_example/checklist-results.md
Normal 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.**
|
||||
63
.specify/features/_example/design.md
Normal file
63
.specify/features/_example/design.md
Normal 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.2–1.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`) |
|
||||
118
.specify/features/_example/spec.md
Normal file
118
.specify/features/_example/spec.md
Normal 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 |
|
||||
47
.specify/features/_example/tasks.md
Normal file
47
.specify/features/_example/tasks.md
Normal 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.
|
||||
45
.specify/features/_example/threat-model.md
Normal file
45
.specify/features/_example/threat-model.md
Normal 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.
|
||||
Reference in New Issue
Block a user