Compare commits

...

23 Commits

Author SHA1 Message Date
Marcel
e93e5ec4d1 ci(sdd): cache and pin Spectral in contract-validate
Some checks are pending
CI / Unit & Component Tests (push) Waiting to run
CI / OCR Service Tests (push) Waiting to run
CI / Backend Unit Tests (push) Waiting to run
CI / fail2ban Regex (push) Waiting to run
CI / Semgrep Security Scan (push) Waiting to run
CI / Compose Bucket Idempotency (push) Waiting to run
Pins @stoplight/spectral-cli@6.16.0 and caches ~/.npm keyed on that version, so
Spectral is fetched once and reused across runs instead of re-downloaded each
time. A version bump busts the cache key deterministically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
87b199a772 docs(sdd): add at-a-glance workflow graph, prerequisites, and gates
Consolidates the full SDD workflow into one view at the top of the guide: a
Mermaid flowchart (skills, the three gates, the TDD loop), a one-time
prerequisites checklist, and a gates table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
dc25b77a1c fix(sdd): add Spectral ruleset so contract-validate passes
Spectral v6 ships no implicit ruleset — the CI job exited 'no ruleset found'.
Adds .spectral.yaml (extends spectral:oas, documentation-only warnings relaxed
for design-time stubs), adds operation tags to the _example contract so it lints
clean (0 results), and aligns the api-contract-stub note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
d50e239a2f feat(sdd): add /draft-spec skill — requirements-engineer authors a new spec
Front of the SDD funnel: the requirements engineer interviews the user, elicits
EARS REQ-NNN requirements + measurable acceptance criteria (probing hard for the
Unwanted-behavior clauses specs usually miss), then creates the Gitea feature
issue (issue body = spec), labels it, and emits RTM rows. Authors only — hands
off to /review-issue rather than self-approving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
c160ab3223 refactor(sdd): make the feature spec issue-only (no committed spec.md)
The Gitea issue body is the single source of truth for a spec; the only
per-feature artifact in git is the RTM row (REQ-ID -> issue # -> test). Drops
per-feature spec.md/tasks.md/checklist files from the workflow (the _example
stays as a template/reference). Updates the guide, ADR-041, AGENTS.md, CLAUDE.md,
templates, the RTM (adds an Issue column), the implement/review-pr skills, and
replaces the file-spec CI jobs with an rtm-check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
fa6677a7c5 feat(sdd): adopt review-issue, review-pr, implement skills to the SDD workflow
review-issue becomes the SDD spec-review gate (adds the requirements engineer,
pairs each .claude persona identity with its .specify checklist, EARS/REQ-NNN
aware). review-pr verifies the diff against the constitution and the spec's
REQ-NNN traceability. implement reads the spec artifacts, plans from tasks.md,
ties tasks to REQ-NNN, and keeps the RTM and generated API types in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
a401e595d7 docs(sdd): add SDD onboarding guide and cross-reference from governance docs
Adds SPEC_DRIVEN_DEVELOPMENT.md (8-step workflow, before/after issue, persona
review example, agent-prompt example, maintenance rules, cheatsheet) and points
CLAUDE.md, COLLABORATING.md, and CONTRIBUTING.md at the new .specify/ workflow
without altering the existing cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
a904590843 feat(sdd): add Gitea issue templates and SDD CI gate
Adds .gitea/ISSUE_TEMPLATE/{feature,bug}.md (the feature template mirrors the
EARS feature-spec) and .gitea/workflows/sdd-gate.yml — spec-lint, contract
validation (Spectral), traceability check (all non-blocking during adoption),
and a constitution-impact PR comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
fdc3e4ffa9 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>
2026-06-13 12:55:26 +02:00
Marcel
e186a3f646 docs(adr): adopt Spec-Driven Development (ADR-041)
Records the decision to layer SDD (EARS requirements, OpenSpec-style delta
artifacts, a versioned constitution, and AGENTS.md) on top of the existing
Gitea-issue + multi-persona-review workflow. Numbered 041 to extend the
existing docs/adr/ archive rather than starting a parallel one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
210dde6562 fix(timeline): reject reversed RANGE events; thread precision
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m56s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Successful in 5m49s
CI / fail2ban Regex (push) Successful in 49s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
The DB CHECK chk_timeline_event_range enforces only the presence
biconditional (eventDateEnd non-null IFF RANGE), not date ordering, so a
RANGE event with eventDateEnd before eventDate persisted silently and
rendered as a negative span. validateRangeInvariant now also rejects
end-before-start (INVALID_DATE_RANGE); equal dates remain a valid one-day
closed range.

Also compute effectivePrecision once per create/update and thread it into
validateRangeInvariant and applyUpdate instead of recomputing.

Addresses review of #822 (#775).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
3de4ff55ea chore(timeline): regenerate API types for event CRUD endpoints
Regenerated frontend/src/lib/generated/api.ts from the OpenAPI spec — adds the
/api/timeline/events paths and TimelineEventRequest/TimelineEventView schemas.
CI has no OpenAPI drift guard, so the regen is committed here. (Operation-id
churn create->create_1 etc. is cosmetic; the typed client keys off paths, not
operation ids; the timeline PersonView merges with geschichte's identical one.)

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
96e04dbda9 feat(timeline): map new ErrorCodes to localized messages
errors.ts ErrorCode union + getErrorMessage() cases for the four new codes,
with de/en/es i18n keys. Conflict messages are calm/recoverable
('...wurde zwischenzeitlich geändert. Bitte neu laden.'). Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
bb0639b324 docs(timeline): sync ErrorCode catalog with new timeline codes
R9 doc-sync: add TIMELINE_EVENT_NOT_FOUND, TIMELINE_EVENT_CONFLICT,
TIMELINE_TITLE_TOO_LONG, and the generic CONFLICT to the valid-error-codes
list in CLAUDE.md and the error-code reference in docs/ARCHITECTURE.md. Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
d7f8abd6c4 test(timeline): add service integration tests (Testcontainers)
Two service-level integration tests against real Postgres (V77 CHECKs are
Postgres-specific): (1) view-assembly round-trip proving the
@Transactional(readOnly=true) LazyInit guard populates persons/documents after
an em.clear()ed fresh getEvent, with a serialized-JSON assertion that no
notes/provisional/password leak; (2) real optimistic-lock 409 — editor B's
stale version yields TIMELINE_EVENT_CONFLICT end-to-end (the unit test only
proves the catch/guard branches).

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
209f223b9f fix(timeline): engage optimistic lock via explicit version compare
The spec's prescribed mechanism (load managed entity -> setVersion(clientVersion)
-> saveAndFlush -> catch ObjectOptimisticLockingFailureException) does NOT engage
the lock: Hibernate ignores a manually-set @Version on a managed entity and uses
its own loaded-version snapshot for the UPDATE ... WHERE version=? clause, so a
stale client write silently succeeds. The integration test the issue mandated to
'prove the lock engages end-to-end' caught exactly this.

Replace it with requireVersionMatch: an explicit compare of the client's
last-seen token against the freshly-loaded version (the true semantics of the Q1
client-supplied-token decision). The native @Version increment still fires on
every save, and the saveAndFlush+catch is retained as the backstop for two
transactions flushing concurrently. Null token => last-write-wins, unchanged.

Deviation from #775's reviewed setVersion mechanism (per maintainer direction the
issue body is left as-is); version unit tests updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
34146d7309 feat(exception): add optimistic-lock backstop returning generic 409
Centralized @ExceptionHandler(ObjectOptimisticLockingFailureException) net so
any write path losing a @Version race becomes a generic 409 (CONFLICT code) —
never a 500 + Sentry + Hibernate internals (CWE-209). No Sentry, class-name-
only parameterized logging, body free of id/version/class. Entity-agnostic by
design (no switch on getPersistentClassName); the service catch keeps the
precise TIMELINE_EVENT_CONFLICT. Per #775 Q2/R4/R8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
390ab30260 feat(timeline): add TimelineEventController CRUD endpoints
POST→201, PUT→200, DELETE→204, GET→200; @RequirePermission(WRITE_ALL) on the
three writes, GET via global auth baseline (no annotation, documented). @Valid
request body; all bodies are TimelineEventView. Injects UserService + private
requireUserId wrapper. Controller slice tests cover 401/403/exact-status per
verb, GET 404, service PERSON_NOT_FOUND→404, Bean-Validation 400s carrying
code=VALIDATION_ERROR, and ArgumentCaptor proof that actorId is the resolved
session principal (not a forged body field) on both write paths.

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
c51fc5e79f feat(timeline): add TimelineEventService with CRUD + view assembly
create/update/delete write methods (@Transactional) + getEvent read
(@Transactional(readOnly=true) for the LazyInit guard). Persons resolved via
PersonService.getAllById with a distinct-size check; documents via per-id
DocumentService.getDocumentById loop; both dedupe-first, fail-closed. RANGE
invariant (both directions), title-length guard, YEAR date normalization, and
default precision. Audit fields server-set (createdBy+updatedBy on create;
only updatedBy on update). Optimistic-lock conflict translated to
TIMELINE_EVENT_CONFLICT via saveAndFlush+catch. Views assembled after flush.

Per #775 / ADR-040.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
b7a5cd7b53 feat(timeline): add TimelineEventView response views
TimelineEventView + nested PersonView + timeline-local DocumentRef. Explicit
field allow-list, never the raw entity (lazy-collection 500 + curator-field
leak). DocumentRef stays timeline-local by design (#775 R7). Per ADR-040 §2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
0eea19c0d4 feat(timeline): add TimelineEventRequest input DTO
Flat input DTO with Bean Validation (@NotBlank/@NotNull/@Size). createdBy/
updatedBy deliberately absent (server-populated; CWE-639). version is an
optional concurrency token, exempt from the server-only audit rule. Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
262568f577 feat(timeline): add ErrorCodes for event CRUD
Add TIMELINE_EVENT_NOT_FOUND (404), TIMELINE_EVENT_CONFLICT (409),
TIMELINE_TITLE_TOO_LONG (400), and a generic CONFLICT (409) used by the
optimistic-lock backstop. Per #775 / ADR-040.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
83ca2eb34d DevOps: Renovate runner + nightly npm audit early-warning (#818) (#821)
Some checks failed
CI / OCR Service Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
## Summary

Closes #818. Sets up the prevention layer so newly-published advisories are caught on a branch we own, not on a contributor's PR.

**What changed:**
- `renovate.json` — migrated 2 deprecated keys (`matchPackagePatterns` → `matchPackageNames`, `matchPaths` → `matchFileNames`); added `osvVulnerabilityAlerts`, `dependencyDashboard`, `vulnerabilityAlerts` (labels: security + P1-high), weekly routine `schedule`, and `lockFileMaintenance` (no automerge)
- `.gitea/workflows/renovate.yml` — **new** daily cron runner (`0 3 * * *`), pinned to `renovatebot/github-action@8217b3fc` (v46.1.15) with `renovate-version: "46.1.15"`, `RENOVATE_TOKEN` secret, Gitea platform/endpoint env vars
- `.gitea/workflows/nightly.yml` — added `npm-audit` job (parallel to `deploy-staging`, independent signal): shell self-test, `set +e` audit capture, jq-built deduped issue open/update, `NIGHTLY_AUDIT_TOKEN` via step env only, heartbeat on clean path
- `docs/adr/041-renovate-runner-setup.md` — **new** negative-space ADR (no auto GITEA_TOKEN, two-token rationale, OSV-vs-platform, digest-pin threat model, schedule-batches-routine-only, l2-containers omission)
- `docs/infrastructure/ci-gitea.md` — two-token model, PAT rotation cadence, OSV-vs-platform, nightly/PR-gate divergence table, runbook for nightly-opened issues
- `docs/infrastructure/self-hosted-catalogue.md` — fixed Renovate snippet (daily cron, digest pin, `RENOVATE_TOKEN`, fixed version, no root `automerge: true`)

**No `l2-containers.puml` entry** — Renovate is a scheduled CI job, not a long-lived container. Stated here as a decision, not an oversight (ADR-041).

## Manual steps required before the runner is live (not automated)

1. Create a dedicated bot account (e.g. `renovate-bot`) on the Gitea instance
2. Mint `RENOVATE_TOKEN` PAT (scopes: `contents` + `pull_request` + `issues`) → add as Gitea secret
3. Mint `NIGHTLY_AUDIT_TOKEN` PAT (scope: `issues` only) → add as Gitea secret
4. Configure `main` branch protection to forbid the bot pushing directly

## Acceptance criteria status

- [x] `renovate.json` deprecated keys migrated; vuln surfacing config enabled
- [x] `.gitea/workflows/renovate.yml` exists (digest-pinned, daily cron, fixed version)
- [x] `self-hosted-catalogue.md` snippet corrected (4 items)
- [x] `nightly.yml` npm-audit job: survives non-zero exit, deduped tracking issue, jq payload, NIGHTLY_AUDIT_TOKEN via env only, heartbeat on clean
- [x] ADR-041 records all negative-space decisions
- [x] `ci-gitea.md` documents two-token model + runbook
- [ ] Phase 0 manual gates: bot account creation, Renovate onboarding PR evidence, Dependency Dashboard screenshot — **requires manual provisioning**
- [ ] Dedupe AC verified via `workflow_dispatch` — **requires NIGHTLY_AUDIT_TOKEN secret to be provisioned first**
- [ ] `$GITHUB_STEP_SUMMARY` availability on this runner — **verify in first live run**

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #821
2026-06-13 12:13:35 +02:00
56 changed files with 4193 additions and 125 deletions

View File

@@ -0,0 +1,99 @@
---
name: draft-spec
description: Requirements-engineer-led authoring of a new feature spec. Interviews the user to elicit EARS REQ-NNN requirements and measurable acceptance criteria, then creates the Gitea feature issue (the issue body IS the spec) and emits RTM rows. Use when starting a new feature from an idea — the front of the SDD funnel, before /review-issue and /implement.
---
# Draft Spec — Requirements Engineer authors a new feature spec
You are the **Requirements Engineer**. Read your full persona from
[`.claude/personas/req_engineer.md`](../../personas/req_engineer.md) and adopt its voice and
priorities. Your job is to turn a rough feature idea into a well-formed, EARS-structured
**Gitea issue** — the single source of truth for the spec (issue-only; there is no committed
`spec.md`). You *author* the spec; you do **not** approve it — that's `/review-issue`'s job.
## Argument
A free-text feature idea, e.g. `users should be able to upload a profile picture`. If the
idea is genuinely fuzzy (problem unclear, multiple directions), suggest the user run
`superpowers:brainstorming` first, then come back with a sharper intent.
## Phase 0 — Load the SDD ground truth
Read before interviewing:
- [`.specify/constitution.md`](../../../.specify/constitution.md) and [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — the rules the spec must respect
- [`.specify/templates/feature-spec.md`](../../../.specify/templates/feature-spec.md) — the section structure and the five EARS patterns
- [`.specify/personas/requirements-engineer.md`](../../../.specify/personas/requirements-engineer.md) — **your own checklist; apply it as you write, not after**
- [`.specify/features/_example/spec.md`](../../../.specify/features/_example/spec.md) — what "good" looks like
- [`docs/GLOSSARY.md`](../../../docs/GLOSSARY.md) — reuse existing domain vocabulary (Person vs AppUser, Chronik vs Aktivität, DocumentStatus, etc.)
Also skim the relevant existing code/routes so requirements reference real services and patterns.
## Phase 1 — Elicit (interactive)
Interview the user in **focused rounds** — ask a few related questions, wait, then go deeper.
Do not dump one giant questionnaire. Cover, in roughly this order:
1. **Why & who** — the business motivation and the role(s) involved. Drives the issue title
`As a <role> I want <capability> so <reason>`.
2. **User journey** — the plain-prose happy path, from the user's perspective. This bounds scope.
3. **Happy-path behaviors** — what the system does on success. Each becomes a Ubiquitous,
Event-driven, or State-driven requirement.
4. **The unwanted paths — probe hard, this is where specs fail.** For every mutating action
ask: what if the caller is unauthenticated? unauthorized? what input is invalid, and what's
the limit (size, count, length)? what's the exact response (`ErrorCode` + HTTP status)?
Each answer is an Unwanted-behavior (`If …`) requirement. (Checklist item #7 is your prompt bank.)
5. **Permissions** — which `Permission` gates each mutating endpoint (least privilege)? Each
gate is an Optional-feature (`Where …`) requirement.
6. **Data model** — new tables/columns/constraints? the next free Flyway `V<n>` (you'll verify on disk)?
7. **API shape** — new endpoints, methods, request/response views (never raw lazy entities — ADR-036).
8. **Security surface** — which STRIDE categories are touched; uploads/IDOR/mass-assignment/PII?
9. **Out of scope** — name the nearest tempting scope creep and exclude it.
10. **Open questions** — anything you cannot decide; these block until resolved.
Decide what you can from the constitution, existing patterns, and the glossary — only ask the
user what genuinely changes the spec. Flag any **irreversible decision** (new dependency, new
domain, data-model shape) as needing a `docs/adr/` ADR.
## Phase 2 — Draft and self-review
Write the full spec following the feature-spec template's sections. Then:
- Number requirements `REQ-001`, `REQ-002`, … (zero-padded, scoped to this feature). Each uses
exactly one EARS pattern. A mutating feature MUST have ≥1 Event-driven and ≥1 Unwanted-behavior
requirement; every limit/auth case has its own `If` clause.
- Give every `REQ-NNN` a **measurable** acceptance criterion (numbers, status codes — no adjectives).
- Run your `requirements-engineer.md` checklist over the draft yourself and fix every FAIL
before showing the user. (You're allowed to block your own draft.)
- Present the full draft to the user. Refine until they confirm. **Do not create the issue
until the user approves the draft text.**
## Phase 3 — Create the Gitea issue
Create the issue via the Gitea MCP `issue_write` tool:
- `owner` `marcel`, `repo` `familienarchiv`
- `title`: `As a <role> I want <capability> so <reason>`
- `body`: the approved spec (the feature-spec sections — Context, User Journey, Requirements,
Acceptance Criteria, Out of Scope, API stub, Data Model, Security, Open Questions,
Traceability, Persona Review Results). Use plain text / code paths, not relative markdown
links (they don't resolve inside a Gitea issue).
- **Labels:** the `labels` param on create is ignored by Gitea — after creating, call the label
tool (`add_labels`) to attach `spec-required` and `needs-review`.
## Phase 4 — Emit RTM rows + flag ADRs
- Emit ready-to-paste [`.specify/rtm.md`](../../../.specify/rtm.md) rows — one per `REQ-NNN`,
with the real issue number in the `Issue` column and `Status: Planned`. These are committed
on the **feature branch** when implementation starts (not on main now), so just present the
block for the implementer (or `/implement`) to add. If you're already on the feature's
worktree/branch, append them to `rtm.md` directly.
- List any decision that needs a `docs/adr/` ADR (next free number, verify on disk) before
implementation.
## Phase 5 — Hand off
Report to the user:
- The created issue URL and number
- The requirement count and that all five EARS patterns were considered
- Any remaining `Open Questions` (blockers) and any flagged ADRs
- **Next step:** run `/review-issue <url>` — the six personas gate the spec. You authored it;
you don't self-approve. After it passes and Open Questions are empty, run `/implement <url>`.

View File

@@ -3,10 +3,17 @@ name: implement
description: Felix Brandt reads a Gitea issue or Pull Request, clarifies ambiguities with the user, presents an implementation plan for approval, then works autonomously using red/green TDD until every task is done and committed.
---
# Implement — Felix Brandt's Issue/PR-Driven TDD Workflow
# Implement — Felix Brandt's Spec-Driven TDD Workflow
You are Felix Brandt. Read your full persona from `.claude/personas/developer.md` before doing anything else.
Then load the SDD ground truth you must obey throughout:
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — stack, executable constraints, workflow rules, do-not-touch list
- [`.specify/constitution.md`](../../../.specify/constitution.md) — the non-negotiable rules AGENTS.md references
The feature's `spec.md` (its `REQ-NNN` requirements) is the contract. Implement exactly what
the requirements say — no more, no less.
## Argument
The user provides a Gitea issue **or** pull request URL, e.g.:
@@ -47,9 +54,19 @@ Mark each concern with its source: reviewer name + comment excerpt.
Also read:
- `CLAUDE.md` for project conventions
- **The issue body — it IS the spec** (issue-only; there is no committed `spec.md`). Extract its
`REQ-NNN` requirements, acceptance criteria, API stub, data-model delta, and any inline
STRIDE/threat notes. These are your contract.
- [`.specify/rtm.md`](../../../.specify/rtm.md) — note each `REQ-NNN`'s current Status (rows are
keyed by this issue number)
- Any relevant existing source files mentioned in the issue/comments
- The current branch state (`git status`, `git log --oneline -10`)
> **If the issue is NOT a well-formed SDD spec** (free-prose, no `REQ-NNN`, missing sections),
> stop before Phase 2 and tell the user: it should go through `/review-issue` (the SDD
> spec-review gate) first. Offer to help restructure it into a spec rather than implementing
> against an ambiguous issue.
Do not start Phase 2 until you have read everything.
---
@@ -58,10 +75,12 @@ Do not start Phase 2 until you have read everything.
### Issue mode
After reading, identify every point that is genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
- Scope questions (is X in or out of this issue?)
- Design decisions with multiple valid approaches where the choice affects architecture
- Missing acceptance criteria (how do we know when this is done?)
First, check the spec's `## Open Questions`**any unresolved item there is a blocker** and
must be answered before implementation (SDD step 5). Then identify any further point that is
genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
- Scope questions (is X in or out? — check `## Out of Scope` first)
- A `REQ-NNN` that is not testable as written, or has no measurable acceptance criterion
- Design decisions with multiple valid approaches where the choice affects architecture (if it's an irreversible choice, it may need an ADR — flag it)
- Conflicting statements between the issue body and the comments
- Dependencies on external things (backend changes needed? migration required?)
@@ -81,12 +100,15 @@ Wait for the user to answer before continuing.
## Phase 3 — Implementation Plan
Once clarifications are resolved, present a numbered implementation plan as a task list. Each item must be:
Once clarifications are resolved, present a numbered implementation plan as a task list,
**derived from the issue's `REQ-NNN` requirements** (one or more tasks per requirement, in
red/green order). Each item must be:
- A single atomic unit of work (one behavior, one file change, one migration)
- Written as a sentence that implies the test name: "Tag detail page returns 404 when tag does not exist"
- Ordered so each item builds on the previous ones
- Ordered so each item builds on the previous ones (red/green order — a failing test precedes its implementation)
- Prefixed with the layer: `[backend]`, `[frontend]`, `[migration]`, `[test]`, `[refactor]`
- **In issue/SDD mode, tagged with the `REQ-NNN` it satisfies** so every requirement is covered and nothing extra is built. Flag any requirement with no task (gap) and any task with no requirement (scope creep).
**In PR mode**, each task must reference the reviewer concern it addresses, e.g.:
```
@@ -97,10 +119,10 @@ Format:
```
## Implementation Plan
1. [backend] PersonController returns 404 when person id does not exist
2. [migration] Add index on documents.sender_id for performance
3. [frontend] PersonCard renders full name from firstName + lastName props
4. [frontend] PersonCard shows placeholder when both names are null
1. [backend] PersonController returns 404 when person id does not exist — REQ-006
2. [migration] V<n> add index on documents.sender_id (verify next free number on disk) — REQ-002
3. [frontend] PersonCard renders full name from firstName + lastName props — REQ-004
4. [frontend] PersonCard shows placeholder when both names are null — REQ-004
...
```
@@ -145,12 +167,22 @@ Check the current branch.
2. Apply any needed clean-up — no new behavior
3. Run the full suite again to confirm still green
**Sync (SDD):**
1. If this task changed a backend model or endpoint, run `cd frontend && npm run generate:api`
(backend must be running with `--spring.profiles.active=dev`) and stage the regenerated types.
2. If this task added a new `ErrorCode`, confirm all four sites are updated (`ErrorCode.java`,
`frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`).
3. Flip the task's `REQ-NNN` Status in [`.specify/rtm.md`](../../../.specify/rtm.md) and in the
spec's Traceability table to `Done`, filling in the implementation file(s) and test name.
**Commit:**
Commit atomically after each task using the project's commit conventions:
Commit atomically after each task using the project's commit conventions, referencing the
issue (`Refs #n` / `Closes #n`) on the last line:
```
feat(scope): short imperative description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Refs #<n>
Co-Authored-By: <model> <noreply@anthropic.com>
```
Move to the next task immediately.
@@ -164,8 +196,10 @@ Move to the next task immediately.
### Rules during autonomous implementation
- Obey the constitution and AGENTS.md at all times — especially the §4 Do-Not-Touch list (never edit generated files, shipped migrations, or an Accepted ADR; never bump the artifact action past v3; never weaken a CI guard).
- Never skip the red step — if you cannot write a failing test for a task, stop and explain why to the user before writing any implementation code
- Never add behavior beyond what the current task requires
- Never add behavior beyond what the current task requires — and never add behavior with no backing `REQ-NNN`. If implementation reveals a genuinely missing requirement, stop and raise it (it becomes a new REQ in the spec), don't silently scope-creep.
- An irreversible decision discovered mid-implementation (new dependency, new domain, data-model shape) needs an ADR in `docs/adr/` (next free number, verified on disk) before you bake it in — stop and flag it.
- Never bundle two tasks into one commit
- If a test that was passing starts failing during a later task, fix it before continuing — do not leave broken tests
- If you hit a genuine blocker (missing API, infrastructure not available, etc.) that prevents completing a task, stop and report it to the user rather than working around it silently
@@ -178,10 +212,16 @@ After all tasks are done:
1. Run the full test suite one final time and confirm all green
2. Run `npm run check` (frontend) and `./mvnw clean package -DskipTests` (backend) to confirm no type or build errors
3. **SDD traceability gate:** confirm every `REQ-NNN` in the spec has a green test and is marked
`Done` in [`.specify/rtm.md`](../../../.specify/rtm.md). Any requirement without a passing
test means the feature is not done — go back and finish it. Confirm `generate:api` was run
if any backend model/endpoint changed.
### Issue mode
3. Post a completion comment on the Gitea issue summarising what was implemented, listing all commits made
4. Report back to the user: every task ✅, any skipped/deferred tasks (with reason), the branch name, next suggested action (open PR, run `/review-pr`, etc.)
4. Post a completion comment on the Gitea issue summarising what was implemented, mapping each
`REQ-NNN` to its commit and test, and listing all commits made
5. Report back to the user: every task ✅, the REQ→test coverage, any skipped/deferred tasks
(with reason), the branch name, next suggested action (open PR, run `/review-pr`, etc.)
### PR mode
3. Push the updated branch

View File

@@ -1,13 +1,15 @@
---
name: review-issue
description: Multi-persona feature issue review. Each persona from .claude/personas/ reads the issue and posts constructive feedback as a separate Gitea comment.
description: Multi-persona SDD spec review of a Gitea feature issue. Each persona pairs its .claude/personas/ identity with its .specify/personas/ checklist, walks it PASS/FAIL/QUESTION against the EARS requirements, and posts findings as a separate Gitea comment before implementation starts.
---
# Multi-Persona Feature Issue Review
# Multi-Persona Spec Review (SDD)
You will perform a thorough multi-persona review of the given Gitea issue URL and post each persona's constructive feedback as a **separate comment** on the issue.
Personas give **advisory input only** — no blocking, no verdicts. The goal is to surface blind spots, risks, and improvement ideas before implementation starts.
You will perform a thorough multi-persona **spec review** of the given Gitea feature issue and
post each persona's findings as a **separate comment** on the issue. This is the SDD
spec-review gate (step 4 of [SPEC_DRIVEN_DEVELOPMENT.md](../../../SPEC_DRIVEN_DEVELOPMENT.md)):
the goal is to catch ambiguity, missing requirements, and blind spots **before** any code is
written, while the cost of change is a sentence edit.
## Argument
@@ -19,57 +21,83 @@ Parse it to extract:
- `repo` — e.g. `familienarchiv`
- `issue_number` — e.g. `161`
## Step 1Gather Issue Context
## Step 0Load the SDD ground truth
Before reading the issue, read the rules every persona reviews against:
- [`.specify/constitution.md`](../../../.specify/constitution.md) — the non-negotiable rules
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — stack, constraints, workflow
- [`.specify/templates/feature-spec.md`](../../../.specify/templates/feature-spec.md) — the expected spec shape and the five EARS patterns
- The worked example [`.specify/features/_example/spec.md`](../../../.specify/features/_example/spec.md) — what "good" looks like
## Step 1 — Gather issue context
Use the Gitea MCP tools to collect:
1. The full issue (title, body, labels, milestone, assignees) via `issue_read`
2. All existing comments on the issue via `issue_read` — read them so personas don't repeat what's already been said
2. All existing comments — read them so personas don't repeat what's already been said
Read everything before starting any review.
## Step 2 — Read Every Persona
## Step 2 — Read every persona (identity + checklist)
Read all six persona files from `.claude/personas/`:
- `developer.md` → Felix Brandt
- `architect.md` → architect persona
- `tester.md` → tester persona
- `security_expert.md` → security persona
- `ui_expert.md` → UI/UX persona
- `devops.md` → DevOps persona
Each persona is its **character identity** (`.claude/personas/`) **plus** its **SDD spec-review
checklist** (`.specify/personas/`). Adopt the voice from the former; gate the spec with the latter.
## Step 3 — Write Each Review
| Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) |
|---|---|---|
| Requirements Engineer | `req_engineer.md` | `requirements-engineer.md` |
| Developer (Felix Brandt) | `developer.md` | `developer.md` |
| Security (Nora "NullX" Steiner) | `security_expert.md` | `security.md` |
| DevOps | `devops.md` | `devops.md` |
| UI/UX | `ui_expert.md` | `ui-ux.md` |
| Architect | `architect.md` | `architect.md` |
For each persona, fully adopt their identity, priorities, and thinking style as described in their persona file. Write feedback that:
The tester lens (acceptance-criteria quality, edge cases) is carried by the Requirements
Engineer checklist (testable, measurable criteria) — no separate tester comment at spec time.
- Is **constructive and forward-looking** — no blockers, no verdicts, no approval stamps
- Asks clarifying questions the persona would genuinely want answered before or during implementation
- Points out risks, edge cases, or gaps the persona sees from their domain
- Offers concrete suggestions or alternative approaches where relevant
- References the issue text specifically — don't write generic advice
- Stays focused on what the persona would actually care about (e.g. Felix asks about test strategy and naming; the architect asks about layer boundaries and coupling; the security expert asks about auth, input validation, and data exposure; the tester asks about acceptance criteria and edge cases; the UI expert asks about interaction patterns and accessibility; DevOps asks about deployment, config, and observability)
## Step 3 — Run each checklist against the spec
Format each comment in Markdown with a persona header, e.g.:
For each persona, walk **every item** in its `.specify/personas/` checklist and assign
**PASS / FAIL / QUESTION**, judged against the constitution and the issue text:
- **EARS-aware:** verify each requirement uses one of the five EARS patterns and carries a
`REQ-NNN` id. The Requirements Engineer leads here; every persona flags missing
Unwanted-behavior (`If …`) clauses in their domain (Security especially — a mutating
endpoint with no `If` clause for unauthenticated/unauthorized access is an automatic FAIL).
- **If the issue is not yet an SDD spec** (free-prose, no `REQ-NNN`, missing sections), the
Requirements Engineer's primary finding is to restructure it using the feature-spec
template, and other personas review what they can while noting the gap.
- Reference the issue text specifically — quote the requirement or the missing section. No
generic advice.
## Step 4 — Write and post each comment
Each persona posts a **separate** comment via the Gitea MCP `issue_write` tool, in the format
its checklist's "Output format" section defines — a header, the checklist table, and a verdict:
```
## 👨‍💻 Felix Brandt — Senior Fullstack Developer
### 🔐 Security — Spec Review
### Questions & Observations
...
| # | Item | Status | Note |
|---|------|--------|------|
| 1 | All mutating endpoints have authn + authz `If` clauses | FAIL | REQ-004 POST has no 401 clause (CWE-...) |
| 2 | ... | PASS | |
### Suggestions
...
**Verdict: CHANGES REQUESTED** — blocking FAIL: #1. Resolve before implementation.
```
Keep each comment focused and scannable. Use bullet points. Avoid walls of text.
Post all six comments. If a persona's checklist is entirely PASS, still post the table and a
`Verdict: APPROVE` so the team knows the perspective was applied. Keep comments scannable.
## Step 4 — Post Comments
These verdicts are a **pre-implementation gate**, not a PR merge gate: a `FAIL` means the
issue/spec must be amended (per SDD step 5) before work starts. Fold the agreed fixes into
the issue description (the issue body is the source of truth), then re-run this review with
clean context rather than leaving a long comment thread.
Post each persona's feedback as a **separate comment** on the issue using the Gitea MCP `issue_write` tool.
Post all six comments. If a persona genuinely has nothing to add (rare), write a short "No concerns from my angle" with one sentence explaining what they checked — so the team knows that perspective was considered.
## Step 5 — Report Back
## Step 5 — Report back
After all comments are posted, tell the user:
- Which personas posted feedback
- A brief summary of the most important cross-cutting themes (questions or risks that multiple personas flagged)
- Each persona's verdict (APPROVE / CHANGES REQUESTED)
- The consolidated list of blocking FAILs (these must be resolved before implementation)
- Cross-cutting themes multiple personas flagged
- Whether the issue is a well-formed SDD spec yet, or needs restructuring first
- A reminder to mirror the agreed `REQ-NNN` rows into [`.specify/rtm.md`](../../../.specify/rtm.md)

View File

@@ -1,74 +1,95 @@
---
name: review-pr
description: Multi-persona PR review. Each persona from .claude/personas/ reviews the PR and posts their findings as a separate Gitea comment.
description: Multi-persona SDD code review of a Gitea PR. Each persona pairs its .claude/personas/ identity with its .specify/personas/ checklist, verifies the diff against the constitution and the feature spec's REQ-NNN (every requirement implemented and tested), and posts findings as a separate Gitea comment.
---
# Multi-Persona PR Review
# Multi-Persona PR Review (SDD)
You will perform a thorough multi-persona code review of the given PR URL and post each persona's findings as a **separate comment** on the PR.
You will perform a thorough multi-persona code review of the given PR and post each persona's
findings as a **separate comment**. Under SDD, the review verifies the diff against two
contracts: the project [constitution](../../../.specify/constitution.md) and the feature's
spec (the linked **Gitea issue body** — every `REQ-NNN` must be implemented **and** covered by a test).
## Argument
The user provides a Gitea PR URL, e.g.:
`http://heim-nas:3005/marcel/familienarchiv/pulls/160`
Parse it to extract:
- `owner` — e.g. `marcel`
- `repo` — e.g. `familienarchiv`
- `pull_number` — e.g. `160`
Parse it to extract `owner`, `repo`, and `pull_number`.
## Step 1Gather PR Context
## Step 0Load the SDD ground truth
Read before reviewing:
- [`.specify/constitution.md`](../../../.specify/constitution.md) — rules the code must obey (esp. §4 Do-Not-Touch)
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — constraints
- The feature's spec — the **Gitea issue** the PR closes (`Closes #n`). Read its body for the
`REQ-NNN` requirements, acceptance criteria, inline API stub, and any STRIDE/threat notes.
- [`.specify/rtm.md`](../../../.specify/rtm.md) — the requirement→test→status matrix
## Step 1 — Gather PR context
Use the Gitea MCP tools to collect:
1. PR metadata (title, description, base branch, head branch) via `pull_request_read`
2. The list of changed files via `get_dir_contents` or the PR files endpoint
3. The full diff / file contents of every changed file — read each file at the head commit using `get_file_contents`
1. PR metadata (title, description, base/head branch) via `pull_request_read`
2. The list of changed files
3. The full content of every changed file at the head commit via `get_file_contents`
Read ALL changed files completely before starting any review. Do not skip files.
Read ALL changed files completely before starting. Do not skip files.
## Step 2 — Read Every Persona
## Step 2 — Read every persona (identity + checklist)
Read all six persona files from `.claude/personas/`:
- `developer.md` → Felix Brandt
- `architect.md` → architect persona
- `tester.md` → tester persona
- `security_expert.md` → security persona
- `ui_expert.md` → UI/UX persona
- `devops.md` → DevOps persona
Adopt each persona's voice from `.claude/personas/`; apply its review lens. For the SDD
personas, also re-read the matching `.specify/personas/` checklist — at PR time the same
checklist items are verified against the **code** rather than the spec.
## Step 3 — Write Each Review
| Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) | PR-time focus |
|---|---|---|---|
| Requirements Engineer | `req_engineer.md` | `requirements-engineer.md` | Traceability: every `REQ-NNN` implemented; RTM updated |
| Developer (Felix Brandt) | `developer.md` | `developer.md` | Clean code, layering, generate:api run, ErrorCode four-site |
| Tester | `tester.md` | — (uses identity) | Test quality: each REQ has a real failing-first test; edge cases; levels right |
| Security (Nora "NullX") | `security_expert.md` | `security.md` | authn/authz, IDOR, mass-assignment, `{@html}`, secrets/PII |
| DevOps | `devops.md` | `devops.md` | migration rollback, env vars, CI guards intact, artifact pin |
| UI/UX | `ui_expert.md` | `ui-ux.md` | states, i18n, a11y, design tokens |
| Architect | `architect.md` | `architect.md` | boundaries, ADR present for irreversible choices, no superseded-ADR violation |
For each persona, fully adopt their identity, priorities, and review lens as described in their persona file. Write a review that:
## Step 3 — Write each review
For each persona, write a review that:
- Opens with a one-line verdict: **✅ Approved**, **⚠️ Approved with concerns**, or **🚫 Changes requested**
- Lists concrete findings with file paths and line references where relevant
- Distinguishes blockers (must fix) from suggestions (nice to have)
- Uses the persona's voice and priorities (e.g. Felix cares about TDD and clean code; the security expert checks for injection, auth, and data exposure; the architect checks layer boundaries and coupling)
- Stays focused — only comment on what the persona would actually care about
Format each comment in Markdown with a persona header, e.g.:
- Lists concrete findings with file paths and line references; cite the constitution rule
(e.g. "violates §2.4 — `updatedBy` bound from request body") or the `REQ-NNN` at issue
- Distinguishes **blockers** (must fix) from **suggestions** (nice to have)
- **Requirements Engineer specifically** produces a traceability table — for each `REQ-NNN`:
is it implemented? is there a test? is `rtm.md` updated to `Done`? Any unimplemented or
untested REQ is a blocker. Any code behavior with no backing requirement is flagged
(scope creep — should it be a new REQ, or removed?).
- A constitution **Do-Not-Touch** violation (edited generated file, edited shipped migration,
edited an Accepted ADR, bumped the artifact action past v3, weakened a CI guard) is always
a blocker.
```
## 👨‍💻 Felix Brandt — Senior Fullstack Developer
### 🔐 Security — PR Review
**Verdict: ⚠️ Approved with concerns**
### Blockers
...
- `UserAvatarController.java:42` — REQ-009's 403 path has no test (constitution §2.8)
### Suggestions
...
- ...
```
## Step 4 — Post Comments
## Step 4 — Post comments
Post each persona's review as a **separate comment** on the PR using the Gitea MCP `issue_write` tool (issues and PRs share the comment API in Gitea).
Post each persona's review as a **separate comment** via the Gitea MCP `issue_write` tool
(issues and PRs share the comment API). Post all personas; if one has nothing to flag, post a
brief "LGTM" naming what they checked.
Post all six comments. Do not skip any persona even if their domain has nothing to flag — in that case write a brief "LGTM" with a short explanation of what they checked.
## Step 5 — Report back
## Step 5 — Report Back
After all comments are posted, summarize to the user:
- Which personas posted comments
- The overall verdict across all personas (worst-case wins: if any said "Changes requested", the overall is "Changes requested")
- A bullet list of the top blockers found (if any)
Summarize to the user:
- Each persona's verdict and the overall verdict (worst-case wins: any "Changes requested" → overall "Changes requested")
- The full list of blockers, grouped by persona
- **Traceability status:** which `REQ-NNN` are implemented+tested vs. missing, and whether
`rtm.md` is in sync
- Any constitution Do-Not-Touch violations (called out explicitly)

View File

@@ -0,0 +1,40 @@
---
name: "Bug"
about: "Something is broken. Describe user-facing impact, not the technical cause."
title: "<What breaks> when <trigger>"
labels:
- bug
assignees: []
---
<!--
Title format (COLLABORATING.md): "<What breaks> when <trigger>", e.g.
"Upload fails silently when file exceeds 50MB". Keep it focused — a bug is small and direct.
A failing test is written first, then the fix (red/green TDD).
-->
## What happens
<The observed broken behavior, from the user's perspective.>
## Expected
<What should happen instead.>
## Steps to reproduce
1.
2.
3.
## Originating requirement (if known)
<REQ-NNN + feature this regresses, from .specify/rtm.md — e.g. "REQ-008 (profile-picture-upload)". Helps target the failing test. Write "unknown" if not traceable.>
## Environment
<Browser / role / data state / deploy (local vs prod) as relevant.>
## Notes
<Logs, GlitchTip link, screenshots. Redact PII.>

View File

@@ -0,0 +1,81 @@
---
name: "Feature (SDD spec)"
about: "Spec-driven feature request. Fill in EARS requirements before implementation starts."
title: "As a <role> I want <capability> so <reason>"
labels:
- spec-required
- needs-review
assignees: []
---
<!--
This issue body IS the spec (issue-only — there is no committed spec.md). Every requirement
uses an EARS pattern + a REQ-NNN id. Reference: .specify/templates/feature-spec.md and the
worked example .specify/features/_example/. Delete the placeholder hints as you fill each section.
-->
## Context & Why
<Who needs this and why now (24 sentences). Link the constitution principle(s) this depends on: .specify/constitution.md>
## User Journey
<Plain-prose steps the user takes to get value, from the user's perspective. Anything not here is out of scope.>
## Requirements
<!-- One per line, each REQ-NNN + one EARS pattern. A mutating feature needs at least one Event-driven and one Unwanted-behavior requirement. -->
- **REQ-001** (Ubiquitous) — The `<component>` shall `<always-true behavior>`.
- **REQ-002** (Event-driven) — When `<trigger>`, the `<component>` shall `<response>`.
- **REQ-003** (State-driven) — While `<state>`, the `<component>` shall `<behavior>`.
- **REQ-004** (Optional-feature) — Where `<caller has Permission.X / flag set>`, the `<component>` shall `<behavior>`.
- **REQ-005** (Unwanted-behavior) — If `<undesired condition>`, then the `<component>` shall `<safe response / ErrorCode>`.
## Acceptance Criteria
<!-- One measurable criterion per REQ-NNN: numbers, limits, status codes — not adjectives. -->
- **REQ-001** — <measurable>.
- **REQ-002** — <measurable>.
## Out of Scope
- <The nearest tempting scope creep, named and excluded.>
## API / Contract Stub
<Inline OpenAPI stub (use .specify/templates/api-contract-stub.md as a writing aid). Name new paths/methods/status codes and the @RequirePermission on each mutating endpoint.>
## Data Model Changes
<Schema delta + next free Flyway V<n> (verify on disk) + rollback note. "none" if not applicable.>
## Security Considerations
<STRIDE categories touched (+ ASTRIDE if an AI agent/tool is involved). Link a threat-model.md if the attack surface is non-trivial.>
## Open Questions
<!-- Each item BLOCKS implementation until resolved. -->
- [ ] <question> — owner: <name>
## Traceability
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|---|---|---|---|
| REQ-001 | | | Planned |
<!-- Mirror these rows into .specify/rtm.md. -->
## Persona Review Results
| Persona | Status | Key Findings | Resolved |
|---|---|---|---|
| Requirements Engineer | PENDING | | |
| Developer | PENDING | | |
| Security | PENDING | | |
| DevOps | PENDING | | |
| UI/UX | PENDING | | |
| Architect | PENDING | | |

View File

@@ -161,3 +161,147 @@ jobs:
# without first re-evaluating ADR-011.
if: always()
run: rm -f .env.staging
npm-audit:
# Independent parallel job — a deploy failure cannot mask the audit signal
# and a clean audit cannot hide a broken deploy. Intentionally no `needs:`.
#
# Scans dev deps too (no --omit=dev), which is deliberately broader than the
# PR gate (ci.yml §Security audit) that uses --omit=dev. A nightly broader
# result is NOT a PR gate failure — it catches dev-tooling advisories (esbuild,
# Vite, etc.) early. See docs/infrastructure/ci-gitea.md §Nightly audit vs PR gate.
#
# Required Gitea secrets:
# NIGHTLY_AUDIT_TOKEN — PAT with issues scope only. An issues-only token
# means a leak via logs/process-args cannot push
# branches, open PRs, or read repo contents (ADR-041).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Assert jq is available
run: which jq || sudo apt-get install -y jq
- name: Run npm audit and file tracking issue on findings
# Never run under set -x — NIGHTLY_AUDIT_TOKEN in env would leak to logs.
env:
NIGHTLY_AUDIT_TOKEN: ${{ secrets.NIGHTLY_AUDIT_TOKEN }}
run: |
MARKER="Nightly npm audit: high-severity advisory"
GITEA_URL="${{ github.server_url }}"
REPO="${{ github.repository }}"
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
# --- Self-test (mirrors ci.yml §Assert pattern) ---
# Tests the exact jq test() call used in the dedupe step, before any
# API call, so a broken matcher fails loudly early rather than silently
# opening duplicate issues. Proves the regex only — create-vs-update
# decision is exercised by the workflow_dispatch AC.
echo "{\"title\": \"${MARKER}\"}" \
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
echo "Self-test passed."
# --- Run audit ---
# No npm ci — audit reads only the lockfile (no network, no install).
set +e
(cd frontend && npm audit --audit-level=high --json > /tmp/audit.json)
AUDIT_EXIT=$?
set -e
if [ "$AUDIT_EXIT" -ne 0 ]; then
# --- Build issue body with jq (never string-concat advisory text) ---
# Advisory overview/title text is registry-controlled; string-concat
# would be an injection/escaping vector into the API body. Truncate
# raw excerpt to 500 chars so a pathological overview can't produce
# a multi-MB PATCH body.
ISSUE_BODY=$(jq -r \
--arg run_url "$RUN_URL" \
'
(.vulnerabilities // {}) as $vulns |
($vulns | to_entries |
map(select(.value.severity == "high" or .value.severity == "critical")) |
map("- **" + .key + "** (" + .value.severity + ")") |
if length > 0 then join("\n") else "_See raw output for details._" end) as $pkg_list |
"## npm audit: high/critical advisories\n\n" + $pkg_list +
"\n\n**Run:** " + $run_url +
"\n\n<details><summary>Raw audit excerpt (first 500 chars)</summary>\n\n```\n" +
(tostring | .[0:500]) +
"\n```\n\n</details>"
' /tmp/audit.json)
# --- Dedupe: fetch open security issues, match by title marker ---
# Renovate vuln PRs also carry the "security" label, so >1 open
# "security" issue WILL occur. Title-match (not just label) ensures
# we deduplicate only our own tracking issue.
OPEN_ISSUES=$(curl -sf \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
MATCHED=$(echo "$OPEN_ISSUES" | jq \
--arg m "$MARKER" \
'[.[] | select(.title | test($m; "i"))] | sort_by(.created_at)')
MATCH_COUNT=$(echo "$MATCHED" | jq 'length')
if [ "$MATCH_COUNT" -gt 0 ]; then
# Patch the oldest matched issue (append run URL to body).
ISSUE_NUMBER=$(echo "$MATCHED" | jq -r '.[0].number')
EXISTING_BODY=$(echo "$MATCHED" | jq -r '.[0].body')
NEW_BODY=$(jq -n \
--arg existing "$EXISTING_BODY" \
--arg run_url "$RUN_URL" \
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
curl -sf -X PATCH \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null
echo "Updated tracking issue #${ISSUE_NUMBER}"
else
# Closed prior issue that recurs → new issue (not reopened).
# A re-opened issue would obscure when the advisory was re-discovered.
PAYLOAD=$(jq -n \
--arg title "$MARKER" \
--arg body "$ISSUE_BODY" \
'{"title": $title, "body": $body}')
CREATED=$(curl -sf -X POST \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues")
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
echo "Opened new tracking issue #${NEW_NUMBER}"
# Labels are ignored on issue create in Gitea — add in a follow-up call.
LABEL_IDS=$(curl -sf \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
curl -sf -X POST \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"labels\": $LABEL_IDS}" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null
fi
exit "$AUDIT_EXIT"
else
# --- Heartbeat: proves the job ran and found nothing ---
# "No issue created" is only meaningful evidence when paired with a
# visible positive signal. Without this, a never-ran job is
# indistinguishable from a clean run.
#
# $GITHUB_STEP_SUMMARY availability is unproven on this runner
# (act_runner populates it, but this is the first run to verify it).
# Guard before use so an unset variable does not fail the clean-path.
MSG="✅ npm audit clean $(date -u)"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "$MSG" >> "$GITHUB_STEP_SUMMARY"
fi
echo "$MSG"
fi

View File

@@ -0,0 +1,44 @@
name: Renovate
# Runs Renovate daily to surface newly-published advisories via OSV.dev
# (osvVulnerabilityAlerts) and open routine update PRs on a weekly batch
# schedule (see renovate.json §schedule). Security/vulnerability PRs are
# raised immediately regardless of the weekly schedule window.
#
# Required Gitea secrets (see docs/adr/041-renovate-runner-setup.md):
# RENOVATE_TOKEN — PAT with scopes: contents + pull_request + issues
# Belongs to a dedicated bot account. Branch protection
# on main must forbid this bot pushing directly.
#
# Platform config is injected via env vars below; the renovate.json in the
# repo root carries only dependency rules (no platform/endpoint/repos).
#
# Digest pin: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd
# corresponds to release v46.1.15. Update by bumping both the digest and the
# renovate-version when Renovate publishes a new release. Renovate itself
# will open a PR to bump this digest once it runs.
on:
schedule:
- cron: "0 3 * * *" # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Renovate
# Pinned by digest — this action holds contents+pull_request+issues
# scopes; an unpinned tag is a supply-chain risk (see ADR-041).
uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
with:
configurationFile: renovate.json
token: ${{ secrets.RENOVATE_TOKEN }}
renovate-version: "46.1.15"
env:
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.raddatz.cloud
RENOVATE_REPOSITORIES: '["marcel/familienarchiv"]'
LOG_LEVEL: info

View File

@@ -0,0 +1,169 @@
name: SDD Gate
# Spec-Driven Development quality gate. Runs on PRs.
#
# This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed
# spec.md (see ADR-041). So CI cannot lint the spec text itself — instead it validates the SDD
# artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution.
#
# The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the
# workflow without CI immediately failing.
#
# TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`)
# once SDD adoption has settled — target: after the first 5 features have shipped through
# the workflow. Tracked in ADR-041.
on:
pull_request:
jobs:
# ─── RTM check ────────────────────────────────────────────────────────────────
# The Requirements Traceability Matrix is the one per-feature SDD artifact in git. Every
# data row must point at a Gitea issue (`#n`) and name at least one test. Warn otherwise.
# Pure awk — no external tooling. Columns: | REQ-ID | Summary | Issue | Feature | Impl | Test | Status |
rtm-check:
name: RTM Check
runs-on: ubuntu-latest
continue-on-error: true # TODO: remove to make blocking (see header)
steps:
- uses: actions/checkout@v4
- name: Validate .specify/rtm.md rows
shell: bash
run: |
set -uo pipefail
rtm=".specify/rtm.md"
test -f "$rtm" || { echo "::error::$rtm is missing"; exit 1; }
# Self-test: a good row passes, a row with an empty Issue or Test is flagged.
check_row() { awk -F'|' '{
issue=$4; test_col=$7;
gsub(/^[ \t]+|[ \t]+$/,"",issue); gsub(/^[ \t]+|[ \t]+$/,"",test_col);
if (issue !~ /#/ || test_col=="") exit 1; else exit 0 }'; }
echo '| REQ-001 | x | #42 | f | impl | SomeTest#works | Done |' | check_row \
|| { echo "FAIL: rtm-check self-test rejected a valid row"; exit 1; }
echo '| REQ-002 | x | | f | impl | | Planned |' | check_row \
&& { echo "FAIL: rtm-check self-test accepted an empty row"; exit 1; }
bad=0
while IFS= read -r line; do
echo "$line" | check_row || {
req=$(echo "$line" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$2); print $2}')
echo "::warning file=$rtm::row $req is missing an Issue (#n) or a Test"
bad=$((bad+1))
}
done < <(grep -E '^\| REQ-[0-9]{3} ' "$rtm")
echo "$bad RTM row(s) incomplete (warning only)."
# ─── Contract validation ──────────────────────────────────────────────────────
# Validate any committed OpenAPI contract with Spectral (OpenAPI 3.1). REST stack — no
# GraphQL. Contracts are optional and ride a feature branch when present; the _example one
# is always linted. Skips cleanly when none changed.
contract-validate:
name: Contract Validate
runs-on: ubuntu-latest
continue-on-error: true # TODO: remove to make blocking (see header)
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '24'
# Cache the npm/npx download so Spectral isn't re-fetched every run. The key is pinned to
# the exact Spectral version below, so a version bump busts the cache deterministically.
- name: Cache Spectral (npm cache)
uses: actions/cache@v4
with:
path: ~/.npm
key: spectral-cli-6.16.0
restore-keys: spectral-cli-
- name: Lint changed OpenAPI contracts
shell: bash
env:
SPECTRAL: "@stoplight/spectral-cli@6.16.0" # pinned — keep in sync with the cache key above
run: |
set -uo pipefail
base="origin/${{ github.event.pull_request.base.ref }}"
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true
# Any *.yaml under .specify/ or any file named like a contract.
changed="$(git diff --name-only "$base"...HEAD -- '.specify/**/*.yaml' '**/api-contract.yaml' '**/*.openapi.yaml' || true)"
if [ -z "$changed" ]; then
echo "No OpenAPI contract changed — nothing to validate."
exit 0
fi
rc=0
for f in $changed; do
[ -f "$f" ] || continue
echo "── spectral lint $f"
npx --yes "$SPECTRAL" lint "$f" || rc=1
done
exit $rc
# ─── Constitution change impact ───────────────────────────────────────────────
# When .specify/constitution.md is modified, list every file that references it (and so
# may need a Sync Impact update) and post it as a PR comment. Best-effort: if no token is
# available the list is only echoed to the log. This job is informational, never blocking.
constitution-diff:
name: Constitution Impact
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: List files referencing the constitution
id: impact
shell: bash
run: |
set -uo pipefail
base="origin/${{ github.event.pull_request.base.ref }}"
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true
if ! git diff --name-only "$base"...HEAD -- '.specify/constitution.md' | grep -q .; then
echo "constitution.md not modified — skipping."
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Files referencing constitution.md (review for Sync Impact):"
grep -rIl --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=target \
-e 'constitution.md' -e 'constitution §' . \
| grep -v '^\./.specify/constitution.md$' | sort > /tmp/refs.txt || true
cat /tmp/refs.txt
{
echo "body<<EOF"
echo "### ⚠️ Constitution changed — Sync Impact review"
echo ""
echo "\`.specify/constitution.md\` was modified in this PR. Per its §6 Sync Impact rule, re-read and reconcile every file below, and confirm the semantic version bump:"
echo ""
while IFS= read -r line; do echo "- \`${line#./}\`"; done < /tmp/refs.txt
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Post PR comment (best-effort)
if: steps.impact.outputs.changed == 'true'
shell: bash
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
BODY: ${{ steps.impact.outputs.body }}
run: |
set -uo pipefail
if [ -z "${TOKEN:-}" ]; then
echo "No token available — printing impact list to log only:"
echo "$BODY"
exit 0
fi
payload="$(jq -n --arg b "$BODY" '{body:$b}')"
curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${SERVER}/api/v1/repos/${REPO}/issues/${PR}/comments" \
-d "$payload" >/dev/null \
&& echo "Posted Sync Impact comment to PR #${PR}." \
|| { echo "Comment POST failed (non-fatal); impact list:"; echo "$BODY"; }

77
.specify/AGENTS.md Normal file
View File

@@ -0,0 +1,77 @@
# AGENTS.md
Machine-readable rules for AI coding agents (Claude Code, Copilot, Cursor, …) working in
this repository. Read this on every invocation. These are **executable constraints**, not
aspirations. The full rationale lives in [constitution.md](./constitution.md) and the docs
it links — this file does not duplicate it, it points to it.
If anything here conflicts with the user's explicit instruction, the user wins. Otherwise,
constitution > this file > convenience.
---
## Stack & Versions
| Layer | Tech | Version |
|---|---|---|
| Backend | Spring Boot (Java, Maven, Jetty, JPA/Hibernate, Flyway, Spring Security, Session JDBC) | Boot 4.0.6 / Java 21 |
| API docs | springdoc-openapi (webmvc-ui), served at `/v3/api-docs` (dev profile only) | — |
| Frontend | SvelteKit / Svelte | 2.60 / 5.43 |
| Frontend lang/style | TypeScript / Tailwind CSS / Paraglide i18n (de/en/es) | TS 5.9 / TW 4.1 |
| API client | `openapi-fetch` + `openapi-typescript` (types generated from the live spec) | — |
| DB | PostgreSQL | 16 |
| Object storage | MinIO (S3-compatible) | — |
| Sidecars | `ocr-service`, `nlp-service` (Python / FastAPI) | Python 3.11 |
| Tests | JUnit + Mockito + `@WebMvcTest` + Testcontainers (backend); Vitest + `vitest-browser-svelte` + Playwright (frontend); Pytest (services) | — |
| Lint/format | ESLint 9 (+ `eslint-plugin-boundaries`) + Prettier; Semgrep (backend) | — |
| CI | Gitea Actions (`.gitea/workflows/`) | — |
App port `8080`; management port `8081`. Backend app id: `org.raddatz.familienarchiv` / `0.0.1-SNAPSHOT`.
## Architectural Constraints
- Controllers call services only — never a repository. (constitution §1.2)
- A service uses only its own domain's repository; reach other domains via their service. (constitution §1.3)
- A new backend domain goes in its own package AND is added to `ArchitectureTest`'s allow-lists in the same change. (constitution §1.7)
- Frontend cross-domain imports are allowed only where `frontend/eslint.config.js` permits; otherwise move shared code to `$lib/shared/`. (constitution §1.4)
- Never serialize a lazy-collection entity across the controller boundary — assemble a view in-transaction. (constitution §1.6 / ADR-036)
- `Person``AppUser`; do not add account guards to Person-domain operations. (constitution §1.5)
- Every `POST/PUT/PATCH/DELETE` endpoint has `@RequirePermission(Permission.X)`. Use the enum, never `@PreAuthorize`. (constitution §2.12.2)
- Throw only `DomainException.notFound/forbidden/conflict/internal()` from services, each with an `ErrorCode`. (CONTRIBUTING §Error handling)
- Set `createdBy`/`updatedBy` from the session principal in the service — never bind them from a request body. (constitution §2.4)
- Add an `@Schema(requiredMode = REQUIRED)` to every always-populated field. (constitution §3.5)
- Never introduce a new runtime dependency without an ADR in `Accepted` status. (constitution §5.1)
- Render untrusted text with `{...}`; never `{@html}` on user/import data. (constitution §2.5)
- Build dates from ISO strings with a `T12:00:00` suffix. (constitution §3.7)
## Workflow Rules
- Always write a failing test before implementation code; confirm it fails, then make it pass, then refactor. (constitution §3.1)
- Run only the specific test file/class locally — never the full suite (it crashes the machine); leave the full sweep to CI.
- Run `npm run generate:api` (in `frontend/`) after ANY backend model or endpoint change — most common cause of TS errors.
- Run `npm run lint` before every commit; a fresh frontend worktree needs `npm install` first or the pre-commit hook fails.
- When adding a new `ErrorCode`, update all four sites at once (constitution §3.6).
- One logical change per commit; reference the Gitea issue (`Closes #n` / `Refs #n`) on the last line.
- Create a git worktree for new issue work — never `git checkout -b` in the main repo while another branch has in-flight work. Avoid `+` in worktree/branch names (breaks vitest browser mode).
- Pull `main` as a separate explicit step before creating a branch.
- Track work as Gitea issues (`http://192.168.178.71:3005`, repo `marcel/familienarchiv`), not todo files.
- Verify ADR and Flyway migration numbers against disk before using one — parallel worktrees make issue-body numbers go stale.
## Do Not Touch
- Generated: `frontend/src/lib/generated/api.ts`, `frontend/src/lib/paraglide/`, `frontend/.svelte-kit/`, `frontend/build/`, `backend/target/`.
- Shipped Flyway migrations — add a new forward-only migration instead.
- An `Accepted` ADR — supersede it with a new one.
- `actions/(upload|download)-artifact` version — stays at `@v3` (ADR-014).
- CI guard steps — do not remove/weaken without an ADR.
- `main` — never commit directly; branch + PR only.
- Worktree copies (`familienarchiv-*`, `.worktrees/`) and `data/` — never commit.
## Spec-Driven Development
A feature's spec is its **Gitea issue body** — there is no committed `spec.md`. The issue's
EARS requirements (`REQ-NNN`) and acceptance criteria are the contract; each maps to a test,
traced in [`.specify/rtm.md`](./rtm.md) (`REQ-ID → issue # → test`). Read the issue before
implementing. The committed [`.specify/features/_example/`](./features/_example/) is a
template/reference showing the full artifact set, not a live feature. Full workflow:
[SPEC_DRIVEN_DEVELOPMENT.md](../SPEC_DRIVEN_DEVELOPMENT.md).

25
.specify/adrs/README.md Normal file
View File

@@ -0,0 +1,25 @@
# ADR archive — see `docs/adr/`
This project already keeps a mature, permanent ADR archive at
[`../../docs/adr/`](../../docs/adr/) (40+ records, format `NNN-kebab-title.md`). SDD does
**not** introduce a second archive — that would split the project's decision history in two.
## Where ADRs live
- **Project-wide decisions** → [`docs/adr/NNN-kebab-title.md`](../../docs/adr/). Use the
next free `NNN` (verify against the directory on disk — parallel worktrees make
issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md).
- **The decision to adopt SDD itself** →
[`docs/adr/041-sdd-adoption.md`](../../docs/adr/041-sdd-adoption.md) (this is the
"ADR-000" the SDD scaffold calls for, numbered to fit the existing sequence).
- **Feature-local decisions** that are only meaningful within one in-flight feature →
beside that feature's spec, e.g.
[`../features/_example/adr-001-avatars-reuse-archive-bucket.md`](../features/_example/adr-001-avatars-reuse-archive-bucket.md).
Promote one to `docs/adr/` if its reach turns out to be project-wide.
## Rules (unchanged from the existing convention)
- An ADR is **immutable once `Accepted`** — supersede it with a new, higher-numbered ADR;
set the old one's status to `Superseded by ADR-MMM`.
- Header style matches the existing archive: `# ADR-NNN — Title`, then
`**Status:** / **Date:** / **Issue:**`.

80
.specify/constitution.md Normal file
View File

@@ -0,0 +1,80 @@
# Familienarchiv Constitution
**Version:** v1.0.0
**Status:** Ratified
**Date:** 2026-06-13
**Adoption ADR:** [docs/adr/041-sdd-adoption.md](../docs/adr/041-sdd-adoption.md)
> The non-negotiable rules of this project. Every spec, every PR, and every AI agent is
> bound by this document. Rules here are deliberately few and absolute — guidance and
> rationale live in [CLAUDE.md](../CLAUDE.md), [COLLABORATING.md](../COLLABORATING.md),
> [CODESTYLE.md](../CODESTYLE.md), [CONTRIBUTING.md](../CONTRIBUTING.md), and the ADR
> archive ([docs/adr/](../docs/adr/)). When this file conflicts with any of those, **this
> file wins** — open an ADR to change it.
>
> Versioning is semantic: **MAJOR** = a rule removed or weakened (existing code may now
> violate the constitution), **MINOR** = a rule added or tightened, **PATCH** = wording
> only. Any change requires the Sync Impact review in the last section.
---
## 1. Architecture Principles
1. The backend is organised package-by-domain under `org.raddatz.familienarchiv`; a new domain lives in its own package, never spread across layer packages.
2. Controllers never call repositories directly — a controller calls only services.
3. A service accesses only its own domain's repository; cross-domain data is fetched through the other domain's service, never its repository.
4. The frontend mirrors the backend domain split under `frontend/src/lib/<domain>/`, and cross-domain imports are allowed only where `frontend/eslint.config.js` (`boundaries/dependencies`) permits them.
5. A `Person` (historical subject) and an `AppUser` (login account) are distinct domains and never share an identity or an account guard.
6. Lazy-collection-bearing entities are never serialized across the controller boundary; the owning service assembles an explicit view inside the transaction (see [ADR-036](../docs/adr/036-geschichte-responses-are-views-not-entities.md)).
7. A new backend domain package is added to `ArchitectureTest`'s package allow-lists in the same change that introduces it.
8. Synchronous cross-domain side effects use in-transaction domain events, not direct service-to-service write calls (see [ADR-006](../docs/adr/006-synchronous-domain-events-in-transaction.md)).
## 2. Security Defaults
1. Every `POST`, `PUT`, `PATCH`, and `DELETE` endpoint carries `@RequirePermission(Permission.X)` — there is no unguarded mutating endpoint.
2. Authorization uses the typed `Permission` enum and `@RequirePermission`, never magic-string `@PreAuthorize`.
3. All user input is validated at the system boundary (controller / form action), and validation failures return a typed `ErrorCode`, never a raw exception.
4. Audit fields (`createdBy`/`updatedBy`) are set from the session principal inside the service and are never bound from a request body.
5. Untrusted text is rendered through Svelte's default `{...}` escaping; `{@html}` is never used on user- or import-derived strings.
6. Secrets are read only from environment variables (see `.env.example`); no secret, token, password, or DSN is ever committed to the repository or written to a log.
7. Logs never contain PII beyond a stable user/entity UUID — no names, email addresses, document contents, or transcription text.
8. Every state-mutating endpoint is covered by an Unwanted-behavior requirement (EARS `If`) describing the unauthenticated/unauthorized response.
9. A dependency security audit runs on every CI run (`npm audit --audit-level=high` frontend, Semgrep `.semgrep/security.yml` backend) and nightly; a `high` finding blocks merge.
## 3. Code Quality Rules
1. All new behavior is driven by a failing test written before the implementation (Red → Green → Refactor); a passing-on-first-run test proves nothing and is rejected.
2. KISS beats DRY — no premature abstraction; an abstraction is introduced only on the third real caller.
3. Each commit does exactly one logical thing and references its Gitea issue (`Closes #n` / `Refs #n`) on the last line of the body.
4. No backwards-compatibility shims are added for code that has no callers.
5. Every entity/DTO field the backend always populates carries `@Schema(requiredMode = REQUIRED)`, and `npm run generate:api` is run after any backend model or endpoint change.
6. A new `ErrorCode` is added in all four places at once: `ErrorCode.java`, `frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, and `messages/{de,en,es}.json`.
7. Dates built from an ISO date string append `T12:00:00` to avoid UTC off-by-one.
8. `npm run lint` (Prettier + ESLint, including the domain boundary rule) passes before every commit.
## 4. Do-Not-Touch List
1. Do not edit generated artifacts: `frontend/src/lib/generated/api.ts`, `frontend/src/lib/paraglide/`, `frontend/.svelte-kit/`, `frontend/build/`, `backend/target/`.
2. Do not edit an `Accepted` ADR — supersede it with a new, higher-numbered ADR.
3. Do not upgrade `actions/upload-artifact` / `download-artifact` past `@v3` (Gitea act_runner lacks the v4 protocol — [ADR-014](../docs/adr/014-upload-artifact-v3-pin.md)).
4. Do not remove or weaken a CI guard step (banned-pattern greps, self-tested regexes) without an ADR recording why.
5. Do not commit to `main` directly — all work flows through a branch and a PR.
6. Do not edit a Flyway migration that has shipped; add a new forward-only migration instead.
7. Do not commit the worktree copy directories (`familienarchiv-*`, `.worktrees/`) or `data/`.
## 5. Dependency Policy
1. A new runtime dependency (backend `pom.xml` or frontend `dependencies`) requires an ADR in `Accepted` status before it is merged.
2. A new dependency must be version-pinned in the manifest, and any exact pin (no caret) carries a comment stating why it cannot float (see the `@vitest/browser-playwright` pin).
3. Renovate manages dependency-update PRs; a major-version bump is treated as a feature requiring its own spec and review, not an auto-merge.
4. A dependency with an unresolved `high`+ advisory is not merged; it is pinned to a safe version or replaced.
## 6. Sync Impact
When this constitution changes, the author MUST, in the same PR:
1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/041-sdd-adoption.md](../docs/adr/041-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change).
2. Re-read and reconcile every file that restates a rule changed here: `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, `CONTRIBUTING.md`, `.specify/AGENTS.md`, and the affected `.specify/personas/*.md` checklists.
3. Update any `.specify/templates/*` section that quotes a changed rule.
4. Run the `constitution-diff` CI job locally (or read its PR comment) and resolve every file it lists.
5. Announce the version bump in the PR description so reviewers re-read the constitution before approving.

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,140 @@
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
tags: [Users]
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
tags: [Users]
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)
tags: [Users]
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)
tags: [Users]
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.

View File

@@ -0,0 +1,40 @@
# Persona — Architect (spec review)
> Concise spec-review checklist. Full character persona:
> [`.claude/personas/architect.md`](../../.claude/personas/architect.md). This file gates a
> `spec.md` and its `design.md`/ADRs for systemic fit and long-term consequence.
## Role summary
I check that a feature fits the system's domain boundaries and decision history, and that
any irreversible choice it makes is captured in an ADR before code is written. I block specs
that quietly contradict an Accepted ADR, blur a domain boundary, or bake in a decision with
no recorded rationale.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Does the feature respect the package-by-domain structure — new code in the right domain, no logic smeared across layer packages?
2. Does it honor the layering rule and the frontend boundary rule, or does it justify and record any new cross-domain edge?
3. Does any irreversible or contentious decision (new dependency, new domain, data-model shape, response-as-view vs entity, sync vs async side effect) have an ADR in `Proposed`/`Accepted` status under `docs/adr/`?
4. Does the spec contradict any existing Accepted ADR — and if a change is intended, does it **supersede** that ADR rather than silently diverge?
5. Is the ADR number the next free one verified against `docs/adr/` on disk?
6. Does the design reuse an established pattern (in-transaction views per ADR-036, domain events per ADR-006, DatePrecision sharing per ADR-039/040) instead of a novel mechanism for a solved problem?
7. Are domain terms used per [docs/GLOSSARY.md](../../docs/GLOSSARY.md), keeping the ubiquitous language consistent?
8. Is the blast radius bounded — does the change avoid forcing edits across unrelated domains, or is the coupling explicitly justified?
9. Does the data model choose the right precision/constraint level deliberately (e.g. NOT NULL audit fields, CHECK constraints) rather than by default, and is the choice recorded?
10. Does the spec keep `Person`/`AppUser` (and other established separations) distinct?
11. Are non-functional consequences (performance of the lazy-fetch path, N+1 risk, index needs) named in `design.md`?
12. Does `design.md` list the alternatives considered and why they were rejected, not just the chosen path?
## EARS patterns to watch for
- **Ubiquitous** requirements (`The <system> shall <invariant>`) encode architectural invariants — confirm each invariant is enforced at the right layer (DB CHECK, service guard, or type) and not merely asserted in prose.
- **Optional-feature** requirements signal a new seam/extension point — verify it does not become an unbounded plugin surface without an ADR.
- Watch for requirements that imply a second source of truth for data that already has an owning domain.
## Output format
A Gitea comment titled **`### Architect — Spec Review`** with the checklist table
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
blocking `FAIL` numbers and, for any decision lacking one, the specific ADR that must be
written before implementation.

View File

@@ -0,0 +1,39 @@
# Persona — Developer (spec review)
> Concise spec-review checklist. Full character persona:
> [`.claude/personas/developer.md`](../../.claude/personas/developer.md). This file gates a
> `spec.md` for implementability against the real codebase.
## Role summary
I check that a spec can actually be built in *this* codebase without fighting its
architecture: that it reuses existing services, layers, and error machinery, and that its
requirements decompose cleanly into red/green TDD tasks. I block specs that invent parallel
structures or hand-wave the hard integration points.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Does the spec reference existing service interfaces (e.g. `DocumentService`, `FileService`, `UserService`) rather than inventing new ones inconsistent with the current layer structure?
2. Does it respect the layering rule — no requirement implies a controller touching a repository or a service reaching into another domain's repository?
3. If it adds a backend domain, does it commit to adding the package to `ArchitectureTest`'s allow-lists?
4. Are new error conditions expressed as named `ErrorCode`s, with the four-site update (`ErrorCode.java`, `errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`) called out as tasks?
5. Does every entity/DTO field the spec adds get `@Schema(requiredMode = REQUIRED)` where always-populated, and is `npm run generate:api` listed as a task after backend changes?
6. Are frontend changes inside the correct `$lib/<domain>/` boundary, with any cross-domain import either pre-allowed in `eslint.config.js` or flagged for an explicit allow-entry?
7. Does each `REQ-NNN` imply a concrete test at the right level (unit / `@WebMvcTest` slice / Playwright E2E per COLLABORATING.md's table) — i.e. is it specified concretely enough to write that test?
8. Is lazy-loading handled — does any returned entity with a lazy collection get a view (ADR-036) instead of being serialized raw?
9. Does the design avoid premature abstraction (KISS over DRY) — no new base class/util introduced before a third caller exists?
10. Are data-model changes expressed as a single forward-only Flyway migration with the next free `V<n>` number verified against disk?
11. Does the spec avoid backwards-compat shims for code paths that have no existing callers?
12. Are the requirements decomposable into a red/green-ordered task list — each behavior small enough that a failing test can precede its implementation?
## EARS patterns to watch for
- **Event-driven** requirements must name the exact endpoint/method so the test target is unambiguous (`When POST /api/users/{id}/avatar receives a valid image, the user service shall …`).
- **Unwanted-behavior** requirements are the ones that become `@WebMvcTest` error-path cases — flag any that lack a stated `ErrorCode` and HTTP status.
- **Optional-feature** (`Where …`) requirements map to a `@RequirePermission` gate — confirm the permission already exists or is added.
## Output format
A Gitea comment titled **`### Developer — Spec Review`** with the checklist table
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing the
blocking `FAIL` numbers and the single most important integration risk in one sentence.

View File

@@ -0,0 +1,39 @@
# Persona — DevOps (spec review)
> Concise spec-review checklist. Full character persona:
> [`.claude/personas/devops.md`](../../.claude/personas/devops.md). This file gates a
> `spec.md` for deployability, migration safety, and CI/observability impact.
## Role summary
I check that a feature can ship to the self-hosted Gitea-Actions / Docker-Compose
environment without breaking deploys, migrations, or observability. I block specs that add
a migration with no rollback story, a new env var nobody documented, or a CI step that the
act_runner cannot execute.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Does the spec include a rollback strategy for any database migration it introduces (forward-only `V<n>` plus the manual DDL to reverse it, or an explicit "no rollback, forward-fix only" statement)?
2. Is the Flyway migration number the next free `V<n>` verified against disk, not copied from a stale issue body?
3. Are all new configuration values introduced as documented env vars (added to `.env.example`) and read via env, never hard-coded?
4. Does any new CI step avoid `actions/(upload|download)-artifact@v4+` and other features the Gitea `act_runner` does not support?
5. If the spec adds a CI guard, is it self-testing (the regex proves it catches the bad form and ignores the good form), matching the existing guard style?
6. Does the feature keep the management port (`8081`) / app port (`8080`) separation intact, and not require Caddy to proxy `/actuator/*`?
7. Are new dependencies pinned, and does the change keep `npm audit --audit-level=high` and Semgrep green?
8. Does a new external service or sidecar come with a healthcheck and a documented Compose entry, and is bucket/bootstrap logic idempotent (re-deploy must not fail)?
9. Are new metrics/logs/traces routed through the existing observability stack (Prometheus scrape, Promtail/Loki, Tempo, GlitchTip) rather than a new ad-hoc channel?
10. Does logging added by the feature stay PII-free and structured (JSON), consistent with the existing log pipeline?
11. Is the feature backwards-compatible across a rolling deploy, or does the spec state the required downtime/ordering (migrate-then-deploy)?
12. Does the spec avoid committing secrets, and does any composite-action secret flow follow the unquoted-heredoc env convention (ADR-029)?
## EARS patterns to watch for
- **State-driven** (`While a migration is in progress, the system shall …`) and **Unwanted-behavior** (`If the OCR service is unavailable, then the system shall return OCR_SERVICE_UNAVAILABLE`) requirements encode operational resilience — flag mutating/processing features that lack them.
- **Optional-feature** (`Where the observability stack is enabled …`) requirements gate optional infra — confirm the feature degrades cleanly when it is off.
## Output format
A Gitea comment titled **`### DevOps — Spec Review`** with the checklist table
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
blocking `FAIL` numbers, with the migration/rollback line called out explicitly when
relevant.

View File

@@ -0,0 +1,43 @@
# Persona — Requirements Engineer (spec review)
> Concise spec-review checklist. The full character persona (used for issue/PR review via
> the `review-issue` / `review-pr` skills) lives at
> [`.claude/personas/req_engineer.md`](../../.claude/personas/req_engineer.md). This file is
> scoped to one job: gate a `spec.md` before implementation starts.
## Role summary
I own requirement quality: every requirement must be atomic, testable, uniquely identified,
and written in EARS so an engineer and an AI agent read it the same way. I block specs that
are ambiguous, unmeasurable, or untraceable — vague requirements become vague code.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Does every requirement have a unique zero-padded `REQ-NNN` ID, scoped to this feature?
2. Is every requirement written in one of the five EARS patterns (no free-prose "shall" sentences)?
3. Is each requirement atomic — exactly one testable behavior, no "and"-joined clauses hiding two requirements?
4. Does every requirement name a concrete system actor (e.g. `the document service`, `the upload form`) rather than a vague "system"?
5. Does each `REQ-NNN` have at least one matching, **measurable** acceptance criterion (numbers/limits, not adjectives like "fast" or "user-friendly")?
6. Are all five EARS patterns considered, and is each used where appropriate (not every requirement forced into Ubiquitous)?
7. Is there an Unwanted-behavior (`If …`) requirement for every error, limit, and rejected input the happy path implies?
8. Does the `## Out of Scope` section explicitly fence off the nearest tempting scope creep?
9. Are all `## Open Questions` resolved (or explicitly deferred with an owner) — none left as silent blockers?
10. Does the spec link the constitution principle(s) it depends on in `## Context & Why`?
11. Is every `REQ-NNN` present in `.specify/rtm.md` with a Feature, Test, and Status column filled (even if Status = Planned)?
12. Does the spec reuse existing domain vocabulary from [docs/GLOSSARY.md](../../docs/GLOSSARY.md) (e.g. Person vs AppUser, Chronik vs Aktivität) rather than inventing terms?
13. Are the User Journey and E2E Scenarios (per COLLABORATING.md) present and consistent with the EARS requirements?
## EARS patterns to watch for (common violations)
- **Ubiquitous** — `The <system> shall <behavior>.` Violation: an invariant written as prose with no "shall".
- **Event-driven** — `When <trigger>, the <system> shall <behavior>.` Violation: a trigger described but the response left implicit.
- **State-driven** — `While <state>, the <system> shall <behavior>.` Violation: a state precondition buried inside an Event-driven clause.
- **Optional-feature** — `Where <feature is present>, the <system> shall <behavior>.` Violation: a permission-/flag-gated behavior written as Ubiquitous, so it appears mandatory.
- **Unwanted-behavior** — `If <undesired condition>, then the <system> shall <response>.` Violation: missing entirely — the single most common gap. Every limit and rejected input needs one.
## Output format
A Gitea comment titled **`### Requirements Engineer — Spec Review`** containing the
checklist as a table `| # | Item | Status | Note |` with `PASS` / `FAIL` / `QUESTION` per
row, then a short verdict line: `Verdict: APPROVE` or `Verdict: CHANGES REQUESTED` with the
blocking `FAIL` numbers listed.

View File

@@ -0,0 +1,42 @@
# Persona — Security (spec review)
> Concise spec-review checklist. Full character persona (Nora "NullX" Steiner):
> [`.claude/personas/security_expert.md`](../../.claude/personas/security_expert.md). This
> file gates a `spec.md` and its `threat-model.md` before implementation.
## Role summary
I read every spec adversarially: I assume the requirement will be hit by an unauthenticated
attacker, a logged-in user attacking another user's data, and malicious input. I block specs
whose mutating endpoints, file handling, or audit trails leave a hole that the happy-path
requirements never mention.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Are **all** state-mutating endpoints (`POST/PUT/PATCH/DELETE`) covered by an Unwanted-behavior EARS clause for unauthenticated **and** unauthorized access, each naming the `Permission` and the response code?
2. Does every mutating endpoint name the `@RequirePermission(Permission.X)` it will carry — and is that permission the least privilege that works?
3. Are audit fields (`createdBy`/`updatedBy`) specified as server-set from the session principal, with an explicit requirement forbidding them in the request body (mass-assignment / authorship-forgery, CWE-639)?
4. Is every IDOR surface addressed — does fetching/mutating a child resource verify it belongs to the caller's accessible parent (e.g. JourneyItem → Geschichte), with a requirement and a test?
5. Is all untrusted text (user input, OCR/import-derived) specified to render via default escaping, never `{@html}` (CWE-79)?
6. For file uploads: are content-type allow-list, size limit, and magic-byte/extension validation specified as requirements with concrete numbers and an `ErrorCode`?
7. Does the spec avoid leaking entity internals (email, password hash, group graph) in any response — i.e. does it use a view, not a raw `AppUser`/entity?
8. Are concurrency conflicts (optimistic locking) specified to surface as `conflict()` (409), never a raw 500 exposing Hibernate internals (CWE-209)?
9. Does the `threat-model.md` exist and cover the relevant STRIDE categories for each new data flow and trust boundary?
10. If the feature invokes an AI agent/tool (OCR/NLP/LLM), does the threat model cover the ASTRIDE extensions (prompt injection, context poisoning, unsafe tool invocation, reasoning subversion)?
11. Are secrets (tokens, DSNs, passwords) sourced only from env vars, with none introduced into the repo, config, or logs?
12. Does logging for this feature exclude PII beyond a stable UUID (no names, emails, document/transcription content)?
13. Does a new runtime dependency (if any) have an ADR and a clean `npm audit` / Semgrep status?
## EARS patterns to watch for
- The **Unwanted-behavior** pattern (`If <attacker condition>, then the <system> shall <safe response>`) is *the* security pattern. Every auth, authz, validation, and limit case must appear as one. A spec with zero `If` requirements on a mutating endpoint is an automatic `FAIL`.
- **Optional-feature** (`Where the caller has Permission.X …`) requirements encode the authorization model — verify the gate is on the *write*, not just the read.
- Watch for **Ubiquitous** requirements that quietly assume trust ("The system shall store the uploaded file") with no companion `If` clause validating it first.
## Output format
A Gitea comment titled **`### Security — Spec Review`** with the checklist table
`| # | Item | Status | Note |`, each `FAIL` tagged with its CWE where applicable, then
`Verdict: APPROVE` / `CHANGES REQUESTED` listing blocking `FAIL` numbers. Security `FAIL`s
are hard blockers — a spec does not proceed until each is resolved or risk-accepted in the
threat model.

View File

@@ -0,0 +1,39 @@
# Persona — UI/UX (spec review)
> Concise spec-review checklist. Full character persona:
> [`.claude/personas/ui_expert.md`](../../.claude/personas/ui_expert.md). This file gates a
> `spec.md` for user-facing features against the project's design system and audience split.
## Role summary
I check that a user-facing feature is usable by *this* audience — older transcribers on
laptops/tablets and younger readers on phones — and that it uses the established design
tokens, components, and i18n rather than reinventing them. I block specs whose UI is
described in adjectives instead of states, or that ignore accessibility and responsiveness.
## Review checklist (PASS / FAIL / QUESTION per item)
1. Does the spec describe every interaction **state** (loading, empty, error, success, disabled), not just the happy path?
2. Are user-facing strings specified to go through Paraglide i18n with keys added to `messages/{de,en,es}.json` — no hard-coded German/English literals?
3. Does it reuse the established component library and patterns (`BackButton`, the card pattern, `brand-navy`/`brand-mint` tokens, `font-serif`/`font-sans`) rather than introducing new one-off styles?
4. Is the responsive behavior specified per the device split — Critical for the reader/phone path, at least Minor for the author/laptop path — with concrete breakpoints, not "responsive"?
5. Are error states mapped to `getErrorMessage(code)` output so the user sees a localized message, never a raw code or stack?
6. Is every interactive element keyboard-reachable and screen-reader-labeled (the project runs `@axe-core/playwright`)?
7. Are acceptance criteria measurable (e.g. "image preview appears within 1 of selection", "tap target ≥ 44px"), not adjectival ("looks clean")?
8. Does the spec define an E2E Playwright scenario (per COLLABORATING.md) for each primary user journey step?
9. For destructive or irreversible actions, is a confirmation/undo affordance specified?
10. Does any uploaded/derived content render through default escaping (no `{@html}`), and are images given alt text / dimensions to avoid layout shift?
11. Does the feature respect existing navigation (live DOM nav, real routes — verify route names against the running app, since CLAUDE.md route lists can be stale)?
12. Is dark-mode / token theming respected (uses semantic tokens like `bg-surface`/`text-ink-3`, not raw palette constants)?
## EARS patterns to watch for
- **State-driven** (`While the upload is in progress, the upload form shall show a progress indicator`) requirements capture UI states — a UI spec with no `While` requirements usually means the loading/disabled states were forgotten.
- **Event-driven** (`When the user selects an image, the form shall render a preview`) requirements map directly to Playwright steps — confirm each has a measurable acceptance criterion.
- **Unwanted-behavior** (`If the selected file exceeds the size limit, then the form shall show a localized error and not upload`) requirements cover client-side validation feedback.
## Output format
A Gitea comment titled **`### UI/UX — Spec Review`** with the checklist table
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
blocking `FAIL` numbers and the single biggest usability/accessibility gap in one sentence.

39
.specify/rtm.md Normal file
View File

@@ -0,0 +1,39 @@
# Requirements Traceability Matrix (RTM)
> Living document. One row per `REQ-NNN` across all in-flight and shipped features. The spec
> itself lives in the **Gitea issue** (issue-only — there is no committed `spec.md`); this
> matrix is the part of the spec that *is* committed: it links each requirement to its issue,
> the code that implements it, and the test(s) that prove it — so any requirement traces end
> to end, and any orphan (a requirement with no test) is visible on `main`.
## How to update
1. When a feature's issue is approved (via `/review-issue`), add one row per `REQ-NNN` with the
`Issue` set to the Gitea issue number and `Status: Planned`. Commit these rows on the feature
branch (they merge with the feature's PR).
2. As tasks land, fill `Implementation File(s)` + `Test(s)` and flip `Status`
`In progress``Done`.
3. `REQ-ID`s are **scoped per feature**, so always read them together with the `Issue` column —
`REQ-001` for issue #142 is not `REQ-001` for issue #150.
4. The `sdd-gate.yml` CI job (`rtm-check`) warns (non-blocking, for now) when a row is missing
its `Issue` or `Test(s)`. It flips to blocking once adoption settles (see the workflow's TODO).
## Status legend
`Planned` · `In progress` · `Done` · `Deferred`
## Matrix
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
|---|---|---|---|---|---|---|
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->

42
.specify/templates/adr.md Normal file
View File

@@ -0,0 +1,42 @@
<!--
ADR template. ADRs live in the existing archive: docs/adr/NNN-kebab-title.md.
Verify the next free NNN against `ls docs/adr/` on disk (parallel worktrees make
issue-body numbers stale). An ADR is IMMUTABLE once Status = Accepted — to change a
decision, write a NEW higher-numbered ADR and set this one's Status to Superseded.
This header mirrors the existing archive style (see docs/adr/040-*.md). Delete this comment.
-->
# ADR-NNN — <Short decision title>
**Status:** Proposed <!-- Proposed | Accepted | Deprecated | Superseded by ADR-MMM -->
**Date:** <YYYY-MM-DD>
**Issue:** #<n> <!-- the Gitea issue / feature this decision serves -->
## Context
<The forces at play: what problem demands a decision now, the constraints from the
constitution and existing ADRs, and why the status quo is insufficient. State facts, not
the chosen answer.>
## Decision
<The decision, stated in active voice as something the project now does. Number sub-decisions
(### 1, ### 2, …) if the ADR commits several related choices, matching the existing archive.>
## Alternatives Considered
| Option | Pros | Cons | Reason rejected |
|---|---|---|---|
| <chosen — name it> | <pros> | <cons> | **Chosen** |
| <alternative A> | <pros> | <cons> | <why not> |
| <alternative B> | <pros> | <cons> | <why not> |
## Consequences
<What becomes easier and what becomes harder. Include the obligations this decision places
on future work (migrations forward-only, tests that must exist, guards that must hold), and
any new coupling introduced.>
## References
- <constitution §, related ADRs, issue links, external docs>

View File

@@ -0,0 +1,97 @@
# API Contract Stub
This project is **REST + OpenAPI**. The backend serves the live spec via springdoc at
`http://localhost:8080/v3/api-docs` (dev profile only), and the frontend generates its
TypeScript client from it with `npm run generate:api` (`openapi-typescript`
`frontend/src/lib/generated/api.ts`). There is no GraphQL in this stack.
> **The live spec is generated from the Java controllers — it is the source of truth.** A
> hand-written stub is a *design artifact*: it pins the intended shape during spec review.
> Issue-only: paste the stub inline into the issue's `## API / Contract Stub` section. Keep it
> OpenAPI **3.1**, and keep `@Schema(requiredMode = REQUIRED)` on the Java side as the real
> driver of `required`.
## How to use this stub
1. Fill in the skeleton below with the paths/methods/schemas your feature adds, and paste it
into the issue's `## API / Contract Stub` section.
2. Every mutating path documents the `403`/`401` responses and the `cookieAuth` security
requirement (matching the real `@RequirePermission` gate).
3. If you prefer a standalone, lintable file (e.g. for a large contract), commit it on the
**feature branch** as `<feature>.openapi.yaml` — the `sdd-gate.yml` CI job lints any
committed OpenAPI contract with Spectral (`npx @stoplight/spectral-cli lint`). It never
needs to predate the issue.
4. After the endpoint ships, run `npm run generate:api` and diff the generated types against
this contract; reconcile any drift (the generated spec wins — update the contract).
## OpenAPI 3.1 skeleton
```yaml
openapi: 3.1.0
info:
title: Familienarchiv API — <feature name>
version: 0.0.1-SNAPSHOT
description: Design-time contract for <feature>. Source of truth 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: # Spring Session JDBC — opaque session id in the SESSION cookie
type: apiKey
in: cookie
name: SESSION
schemas:
ErrorResponse: # shape produced by GlobalExceptionHandler
type: object
required: [code, message]
properties:
code:
type: string
description: Machine-readable ErrorCode (see ErrorCode.java / errors.ts).
example: FORBIDDEN
message:
type: string
# <YourResponseView>: # always a view, never a lazy-collection entity (ADR-036)
# type: object
# required: [id]
# properties:
# id: { type: string, format: uuid }
security:
- cookieAuth: [] # default: every path requires a session unless overridden to []
paths:
/api/<resource>:
post:
summary: <create …>
operationId: <createResource>
security:
- cookieAuth: [] # plus @RequirePermission(Permission.X) on the controller
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/<CreateDTO>' }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/<YourResponseView>' }
'400': { description: Validation failed, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
'403': { description: Missing permission, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
```
## Validating the contract in CI
The `sdd-gate.yml` `contract-validate` job lints any committed OpenAPI file changed in the PR:
```bash
npx @stoplight/spectral-cli lint <your-contract>.yaml
```
The ruleset is `.spectral.yaml` at the repo root (extends `spectral:oas`; documentation-only
warnings relaxed for design-time stubs). Spectral auto-discovers it. It catches malformed
specs, undefined `$ref`s, and duplicate `operationId`s; tune `.spectral.yaml` to adjust.

View File

@@ -0,0 +1,89 @@
<!--
Feature Spec template — paste this into the Gitea issue body (issue-only: this IS the spec;
there is no committed spec.md). The .gitea/ISSUE_TEMPLATE/feature.md mirror gives the same
structure with the right labels. Replace every <placeholder>. Delete this comment before submitting.
EARS = Easy Approach to Requirements Syntax. Every requirement uses one of the five patterns
shown in ## Requirements and carries a unique REQ-NNN id (three-digit, scoped to THIS feature).
Use plain code-path references (not relative markdown links) — links don't resolve inside a Gitea issue.
-->
# <Feature title — match the Gitea issue: "As a <role> I want <capability> so <reason>">
## Context & Why
<Business motivation in 24 sentences: who needs this and why now.>
Constitution principles this feature depends on (see `.specify/constitution.md`):
- §<n> <principle name> — <why it applies>
Related: <links to prior issues / ADRs>.
## User Journey
<Plain-prose steps the user takes to get value, from the user's perspective — per COLLABORATING.md. Anything not in this journey is out of scope.>
## Requirements
> One requirement per line, each with a `REQ-NNN` id and one EARS pattern. Include the
> patterns the feature actually needs — do not force all five, but a mutating feature almost
> always needs at least one Event-driven and one Unwanted-behavior requirement.
- **REQ-001** (Ubiquitous) — The `<system component>` shall `<always-true behavior>`.
- **REQ-002** (Event-driven) — When `<trigger / endpoint receives X>`, the `<system component>` shall `<response>`.
- **REQ-003** (State-driven) — While `<system is in state X>`, the `<system component>` shall `<behavior>`.
- **REQ-004** (Optional-feature) — Where `<the caller has Permission.X / a feature flag is set>`, the `<system component>` shall `<behavior>`.
- **REQ-005** (Unwanted-behavior) — If `<undesired condition, e.g. caller is unauthenticated / input invalid>`, then the `<system component>` shall `<safe response, e.g. return 401 / ErrorCode.X>`.
## Acceptance Criteria
> One measurable criterion per REQ-NNN. Numbers, limits, status codes — never adjectives.
- **REQ-001** — <measurable, e.g. "the response always includes a non-null `id` (UUID)">.
- **REQ-002** — <measurable, e.g. "POST returns 201 and the persisted row within the same request">.
- **REQ-003** — <measurable>.
- **REQ-004** — <measurable, e.g. "a caller without Permission.X receives 403 with ErrorCode.FORBIDDEN">.
- **REQ-005** — <measurable, e.g. "an unauthenticated request receives 401 and nothing is persisted">.
## Out of Scope
- <Explicit boundary statement — the nearest tempting scope creep, named and excluded.>
- <…>
## API / Contract Stub
<Inline OpenAPI stub. Name the new/changed paths, methods, request/response shapes, status codes, and `@RequirePermission`. Use the `.specify/templates/api-contract-stub.md` skeleton as a writing aid.>
## Data Model Changes
<Entity/schema delta: new tables/columns, constraints, the next free Flyway `V<n>`, and the rollback note. Write "none" if not applicable.>
## Security Considerations
<STRIDE categories touched (Spoofing/Tampering/Repudiation/Information disclosure/DoS/Elevation). For AI-agent/tool features, also ASTRIDE. Include an inline STRIDE table (use `.specify/templates/threat-model.md`) if the feature has a non-trivial attack surface.>
## Open Questions
> Each item is a BLOCKER until resolved. Empty this list before implementation starts.
- [ ] <question> — owner: <name>
- [ ] <question> — owner: <name>
## Traceability
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|---|---|---|---|
| REQ-001 | <T-1> | <test name> | Planned |
| REQ-002 | <T-2> | <test name> | Planned |
<After approval, add one committed row per REQ-NNN to `.specify/rtm.md` with this issue's number. Fill Task/Test IDs as work progresses.>
## Persona Review Results
| Persona | Status | Key Findings | Resolved |
|---|---|---|---|
| Requirements Engineer | PENDING | | |
| Developer | PENDING | | |
| Security | PENDING | | |
| DevOps | PENDING | | |
| UI/UX | PENDING | | |
| Architect | PENDING | | |

View File

@@ -0,0 +1,53 @@
<!--
Threat model template — STRIDE + ASTRIDE. WRITING AID: fill this in and paste the result into
the issue's "## Security Considerations" section (issue-only — the threat model lives in the
issue body, not a committed file). Required when a feature adds a new trust boundary, handles
uploads, exposes a new mutating endpoint, or invokes an AI agent/tool. The Security persona
gates it during /review-issue. Delete this comment.
-->
# Threat Model — <Feature name>
**Feature spec:** Gitea issue #<n>
**Date:** <YYYY-MM-DD>
**Author:** <name>
## Data Flow Diagram (text)
**Actors**
- <e.g. Anonymous visitor, Authenticated reader, Authenticated transcriber, Admin, OCR sidecar>
**Trust boundaries**
- TB-1: Browser ⇄ Caddy (public internet ⇄ DMZ)
- TB-2: Caddy ⇄ Backend (`:8080`) (DMZ ⇄ app)
- TB-3: Backend ⇄ PostgreSQL / MinIO / sidecars (app ⇄ data plane)
- <add feature-specific boundaries>
**Data flows** (source → [boundary] → sink : data)
- F-1: Browser → [TB-1,TB-2] → Backend : <request payload>
- F-2: Backend → [TB-3] → MinIO : <stored object>
- <…>
## STRIDE
| Threat Category | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|---|---|---|---|---|---|
| **S**poofing | <asset> | <e.g. unauthenticated caller forges a request> | <session auth + @RequirePermission> | Low × High | <Open/Mitigated/Accepted> |
| **T**ampering | <asset> | <e.g. mass-assignment of createdBy> | <server-set audit fields, no body binding> | Med × High | |
| **R**epudiation | <asset> | <e.g. no record of who changed what> | <NOT NULL createdBy/updatedBy audit trail> | Low × Med | |
| **I**nformation disclosure | <asset> | <e.g. entity leaks email/hash; raw 500 leaks Hibernate internals> | <view not entity; DomainException.conflict> | Med × High | |
| **D**enial of service | <asset> | <e.g. oversized upload / unbounded list> | <size limit, batch cap, pagination> | Med × Med | |
| **E**levation of privilege | <asset> | <e.g. reader reaches a write endpoint / IDOR> | <least-privilege Permission, ownership check> | Low × High | |
## ASTRIDE (only if the feature invokes an AI agent / tool — OCR, NLP, LLM)
| Threat | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|---|---|---|---|---|---|
| Prompt Injection | <input to the model> | <untrusted document text steers the model> | <treat model output as untrusted; no auto-exec> | | |
| Context Poisoning | <retrieved/shared context> | <attacker plants data that biases later runs> | <scope/provenance of context; validation> | | |
| Unsafe Tool Invocation | <tool the agent can call> | <model triggers a privileged action> | <allow-list tools; human-in-loop on mutations> | | |
| Reasoning Subversion | <decision the model makes> | <crafted input flips a classification/decision> | <confidence threshold; deterministic guardrail> | | |
## Residual Risk
<Threats marked Accepted, who accepted them, and why the residual risk is tolerable.>

15
.spectral.yaml Normal file
View File

@@ -0,0 +1,15 @@
# Spectral ruleset for OpenAPI contract linting (SDD api-contract files).
# Spectral v6 ships no implicit ruleset — this enables the built-in OpenAPI rules.
# Used by .gitea/workflows/sdd-gate.yml (contract-validate) and locally:
# npx @stoplight/spectral-cli lint <contract>.yaml
extends: ["spectral:oas"]
rules:
# Design-time SDD stubs are not full published API docs — relax the documentation-completeness
# warnings that would otherwise fire on a focused contract. The structural/correctness rules
# (oas3-schema, valid $refs, duplicate operationId, etc.) stay on.
info-contact: off
info-description: off
operation-description: off
operation-tag-defined: off
oas3-unused-component: off

View File

@@ -16,6 +16,10 @@ See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking wo
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
## Spec-Driven Development
This project uses Spec-Driven Development. **Before implementing a feature, read [`.specify/AGENTS.md`](./.specify/AGENTS.md)** (the short, machine-readable agent rules) and obey the [`.specify/constitution.md`](./.specify/constitution.md) it references. A feature's contract is its **Gitea issue body** (EARS `REQ-NNN` requirements) — there is no committed `spec.md`; the RTM ([`.specify/rtm.md`](./.specify/rtm.md)) traces each `REQ-ID → issue # → test`. Full workflow: [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md); template/reference: [`.specify/features/_example/`](./.specify/features/_example/). The LLM reminders below restate constitution rules — the constitution and AGENTS.md are authoritative if they ever diverge.
---
## Stack
@@ -165,7 +169,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
### Security / Permissions
@@ -273,7 +277,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
---

View File

@@ -8,6 +8,14 @@ Evaluate all suggestions on their technical merits. No sycophancy — if somethi
## Core Workflow: Research → Plan → Implement → Validate
> **Spec-Driven Development.** Feature work is front-ended by an SDD spec: EARS-formatted
> `REQ-NNN` requirements, persona spec-review checklists, and the project constitution. The
> sequence below is unchanged — SDD formalises its *inputs* (the issue body becomes a
> structured spec; the User Journey + E2E Scenarios below feed it). See
> [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md) and
> [`.specify/`](./.specify/) ([constitution](./.specify/constitution.md),
> [AGENTS.md](./.specify/AGENTS.md)).
Every non-trivial feature or bug fix follows this sequence:
1. **Research** — Read the relevant code. Understand existing patterns before touching anything.

View File

@@ -1,6 +1,7 @@
# Contributing to Familienarchiv
For the full collaboration rules (issue workflow, PR process, Red/Green TDD, commit conventions) see [COLLABORATING.md](./COLLABORATING.md).
For the Spec-Driven Development workflow (EARS specs, persona review, the constitution, and `.specify/`) see [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md).
For coding style see [CODESTYLE.md](./CODESTYLE.md).
For the system architecture see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (introduced in DOC-2; until that PR merges, see [docs/architecture/c4-diagrams.md](./docs/architecture/c4-diagrams.md)).
For domain terminology see [docs/GLOSSARY.md](./docs/GLOSSARY.md).

235
SPEC_DRIVEN_DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,235 @@
# Spec-Driven Development (SDD)
How we turn a feature idea into merged, traceable code in this repo. SDD layers a uniform,
machine-readable front-end onto the workflow we already run (Gitea issues → branch/PR →
multi-persona review → red/green TDD). It does not replace any of that — see
[ADR-041](./docs/adr/041-sdd-adoption.md) for the why.
- **The rules** live in [`.specify/constitution.md`](./.specify/constitution.md) (humans) and
[`.specify/AGENTS.md`](./.specify/AGENTS.md) (AI agents, every invocation).
- **The templates** live in [`.specify/templates/`](./.specify/templates/).
- **The worked example** is [`.specify/features/_example/`](./.specify/features/_example/) — read it first.
---
## 0. The whole workflow at a glance
```mermaid
flowchart TD
idea([Feature idea]):::start --> draft
subgraph author["✍️ Author"]
draft[/"/draft-spec<br/>(Requirements Engineer)"/]:::skill --> issue[("Gitea issue = the SPEC<br/>EARS REQ-NNN + acceptance criteria")]:::spec
end
issue --> ri[/"/review-issue"/]:::skill
ri --> g1{"GATE 1 · spec review<br/>6 personas APPROVE?<br/>Open Questions empty?"}:::gate
g1 -- "FAIL / question" --> amend["Amend the issue body"]:::work --> ri
g1 -- "APPROVE" --> rtm["Seed RTM rows<br/>REQ-ID → issue #"]:::work
rtm --> wt["Create git worktree<br/>(pull main first)"]:::work --> impl[/"/implement"/]:::skill
subgraph build["🔁 Build · TDD per REQ-NNN"]
impl --> red["Red: failing test"]:::work --> green["Green: minimal code"]:::work --> sync["Refactor + sync<br/>generate:api · flip RTM → Done"]:::work --> commit["Commit · Refs #n"]:::work
commit -- "next REQ" --> red
end
build --> pr[["Open PR · Closes #n"]]:::work --> g2{"GATE 2 · CI green?<br/>ci.yml + sdd-gate.yml"}:::gate
g2 -- "red" --> fixci["Fix on branch"]:::work --> g2
g2 -- "green" --> rp[/"/review-pr"/]:::skill
rp --> g3{"GATE 3 · PR review<br/>all personas APPROVE?<br/>every REQ implemented + tested?<br/>no Do-Not-Touch violation?"}:::gate
g3 -- "changes requested" --> fixpr["Fix on branch"]:::work --> rp
g3 -- "APPROVE" --> merge([Merge → main<br/>closed issue = archived spec]):::start
rules["📐 constitution.md + AGENTS.md<br/>(bind every step)"]:::rules -.-> draft
rules -.-> impl
rules -.-> rp
classDef start fill:#1d3b53,color:#fff,stroke:#1d3b53;
classDef skill fill:#e8f5f0,stroke:#3aa884,color:#13352b;
classDef gate fill:#fff3cd,stroke:#d39e00,color:#5a4500;
classDef spec fill:#eef2ff,stroke:#5b6ee1,color:#1e2a5a;
classDef work fill:#f6f6f6,stroke:#bbb,color:#222;
classDef rules fill:#fdecea,stroke:#d9534f,color:#611a15;
```
> `/deliver-issue` runs **GATE 1 → discuss → build → GATE 3 (loop)** end-to-end in one go.
### Prerequisites (one-time setup)
Before the workflow runs cleanly, confirm these exist (most ship with this repo):
- [ ] **Gitea labels** `spec-required` and `needs-review` exist (the feature template + `/draft-spec` attach them; the `labels` create-param is ignored, so they must pre-exist).
- [ ] **Gitea MCP** server configured (`gitea`) — the skills read/write issues and PRs through it.
- [ ] **`.spectral.yaml`** at the repo root (extends `spectral:oas`) — the CI contract check needs it.
- [ ] **Personas present**: identities in [`.claude/personas/`](./.claude/personas/) + checklists in [`.specify/personas/`](./.specify/personas/).
- [ ] **`.specify/constitution.md` + `AGENTS.md`** committed on `main` (so every branch inherits them).
- [ ] **Worktrees + hooks**: new feature work goes in a `git worktree` (plus-free name); run `npm install` in `frontend/` once per worktree so the pre-commit lint hook works.
### The three gates
| Gate | When | Mechanism | Blocks on |
|---|---|---|---|
| **1 · Spec review** | after `/draft-spec`, before any code | `/review-issue` (6 persona checklists) | any persona `CHANGES REQUESTED`, or an unresolved `## Open Question` |
| **2 · CI** | on every PR | `ci.yml` (tests · lint · semgrep) + `sdd-gate.yml` (rtm-check · contract-validate · constitution-diff) | `ci.yml` failure (hard); `sdd-gate` jobs are non-blocking during adoption — see the workflow TODO |
| **3 · PR review** | before merge | `/review-pr` (7 personas + traceability) | any persona `Changes requested`, an unimplemented/untested `REQ-NNN`, or a constitution Do-Not-Touch violation |
---
## 1. The workflow in 8 steps
| # | Step | Who | Artifacts created / touched |
|---|---|---|---|
| 1 | **Idea → Gitea issue** using the Feature template | author | Gitea issue (labels `spec-required`, `needs-review`) from `.gitea/ISSUE_TEMPLATE/feature.md` |
| 2 | **Write the spec _in the issue body_** — Context, User Journey, EARS `REQ-NNN` requirements, measurable acceptance criteria, Out of Scope | author | the Gitea issue body **is** the spec (single source of truth — no committed `spec.md`) |
| 3 | **Capture durable design decisions** as needed | author | a `docs/adr/` ADR for any project-wide/irreversible decision; an OpenAPI contract and a STRIDE threat model inline in the issue (use the `.specify/templates/` as the writing aid) |
| 4 | **Persona spec review** — the six checklists gate the spec | RE, Developer, Security, DevOps, UI/UX, Architect | `/review-issue` posts each persona's checklist verdict as a Gitea comment; findings folded into the issue body |
| 5 | **Resolve Open Questions & blocking FAILs** — spec does not proceed while any remain | author | issue body updated; `Open Questions` emptied |
| 6 | **Seed the RTM** — one row per `REQ-NNN`, pointing at the issue | author | rows added to [`.specify/rtm.md`](./.specify/rtm.md) (`Issue: #n`, `Status: Planned`) — committed with the feature branch |
| 7 | **Implement** in a worktree, TDD per task (failing test → green → refactor → commit); agent reads `AGENTS.md` + the **issue body** (the spec) | implementer (often an AI agent) | code + tests; `npm run generate:api` after backend changes; RTM `Status``Done` |
| 8 | **PR → multi-persona PR review → merge** | reviewers | PR (`Closes #n`); the closed issue is the archived spec, the RTM rows record what shipped |
The personas at step 4 review the **spec (the issue)**; the same personas at step 8 (via the
existing `review-pr` / `deliver-issue` skills) review the **code**. Step 4 catches at spec time
what used to surface only at step 8.
**Skills that drive this:** `/draft-spec` (requirements engineer authors steps 12 → creates
the issue) → `/review-issue` (step 4 gate) → `/implement` (steps 67) → `/review-pr` (step 8).
`/deliver-issue` runs review → discuss → implement → review-loop end-to-end.
> **Why issue-only?** The Gitea issue body is the single source of truth for a spec — there is
> no committed per-feature `spec.md` to drift out of sync with it. The only SDD artifact that
> lives in git per feature is the RTM row (`REQ-ID → issue # → test`). The worked example under
> [`.specify/features/_example/`](./.specify/features/_example/) is a **template/reference**, not
> a live feature — it shows the full artifact set in one place; real features keep the spec in
> the issue.
## 2. How a Gitea issue becomes a spec
**Before (free-form issue):**
> **Title:** Add profile pictures
> Users should be able to upload a picture for their profile. Make sure it's not too big and
> only admins can remove other people's. Show initials if there's no picture.
Ambiguous: how big? which formats? what status code on rejection? what about unauthenticated
callers? No identifiers to trace, no measurable criteria.
**After (SDD-structured issue — excerpt):**
> **Title:** As a user I want to upload a profile picture so other family members recognise me
>
> **## Requirements**
> - **REQ-002** (Event-driven) — When an authenticated user sends `POST /api/users/me/avatar`
> with a valid image, the user service shall store it and return a profile view with a
> non-null `avatarUrl`.
> - **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, then the system shall return `403 ErrorCode.FORBIDDEN`.
>
> **## Acceptance Criteria**
> - **REQ-008** — a 2.1 MB PNG returns `400 AVATAR_TOO_LARGE`; bucket object count unchanged.
Every behavior is now a uniquely-identified, testable, EARS-formed requirement with a
measurable acceptance criterion. See the full version in
[`.specify/features/_example/spec.md`](./.specify/features/_example/spec.md).
## 3. How to run a persona review
Each persona reads the spec, walks its checklist in `.specify/personas/<persona>.md`, and
posts a Gitea comment with **PASS / FAIL / QUESTION** per
item and a verdict. A `FAIL` from Security or Architect is a hard block. Concrete example:
> ### Security — Spec Review
>
> | # | Item | Status | Note |
> |---|---|---|---|
> | 1 | All mutating endpoints have authn + authz `If` clauses | PASS | REQ-006 (401), REQ-009 (403) |
> | 3 | Audit fields server-set, forbidden in body | **FAIL** | `avatarObjectKey` is bound from the request body → mass-assignment (CWE-639). Make it server-set in `UserService`. |
> | 6 | Upload type allow-list + size | PASS | REQ-007 / REQ-008 |
> | 9 | threat-model.md present & STRIDE-complete | **QUESTION** | Is the avatar URL public or proxied? If public S3, that's information disclosure. |
>
> **Verdict: CHANGES REQUESTED** — blocking FAIL: #3. Resolve #9 in the threat model.
The author folds the fix into the spec (here: server-set key + authenticated proxy URL),
empties the finding, and the persona re-reviews until `APPROVE`. This mirrors the existing
`review-issue` skill — the persona checklists just make the spec pass/fail explicit.
## 4. How the AI agent uses the spec
Once the spec is `APPROVE`d and tasks are seeded, the implementer points the agent at the
artifacts. Example prompt:
> Implement Gitea issue #142 (profile picture upload). Read `.specify/AGENTS.md` and obey the
> constitution it references. The contract is the issue body — its EARS requirements
> REQ-001…REQ-009 and acceptance criteria. Build a red/green task list from them, write the
> failing test for each REQ first, confirm it fails, then make it pass. After backend model
> changes run `npm run generate:api`. Do not mark a REQ done until its test is green; flip its
> row in `.specify/rtm.md` to Done as you go.
The agent now has: the rules (`AGENTS.md` → constitution) and the exact requirements with ids
from the issue — so its output is bounded and verifiable. (The `/implement` skill fetches the
issue body for you via the Gitea API.)
## 5. Maintenance rules
- **Constitution** ([`.specify/constitution.md`](./.specify/constitution.md)) — change it only
when a project-wide rule genuinely changes. Bump the semantic version (MAJOR = rule
removed/weakened, MINOR = rule added/tightened, PATCH = wording), run the §6 Sync Impact
review, and let the `constitution-diff` CI job list the files to reconcile. Record the bump
in ADR-041's revision log (or a superseding ADR for MAJOR).
- **AGENTS.md** — keep it under 200 lines. It cross-references the constitution; it must never
duplicate or contradict it.
- **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free
`NNN`, verify on disk). Immutable once `Accepted`; supersede, don't edit.
- **Feature specs** — the spec is the Gitea issue body; there is no committed `spec.md`.
"Archiving" is just closing the issue (`Closes #n` on merge). The closed issue + the RTM
rows are the record of what shipped.
- **RTM** ([`.specify/rtm.md`](./.specify/rtm.md)) — append one row per `REQ-NNN` when a spec
is approved, each pointing at its issue (`#n`); flip `Status` as tests go green; never delete
a shipped requirement's row.
- **Personas** — update `.specify/personas/*.md` checklists when a recurring blind spot
appears; keep them aligned with the richer `.claude/personas/`.
## 6. Quick-start cheatsheet
**EARS patterns** (every requirement is one of these + a `REQ-NNN` id):
| Pattern | Shape |
|---|---|
| Ubiquitous | `The <system> shall <behavior>.` |
| Event-driven | `When <trigger>, the <system> shall <behavior>.` |
| State-driven | `While <state>, the <system> shall <behavior>.` |
| Optional-feature | `Where <feature/permission present>, the <system> shall <behavior>.` |
| Unwanted-behavior | `If <undesired condition>, then the <system> shall <response>.` |
**File locations:**
| What | Where |
|---|---|
| Non-negotiable rules | `.specify/constitution.md` |
| Agent rules (read every time) | `.specify/AGENTS.md` |
| Templates (writing aids) | `.specify/templates/{feature-spec,adr,threat-model,api-contract-stub}.md` |
| Persona checklists | `.specify/personas/*.md` |
| In-flight feature spec | the **Gitea issue body** (not a committed file) |
| Worked example (template/reference) | `.specify/features/_example/` |
| Traceability matrix | `.specify/rtm.md` (`REQ-ID → issue # → test`) |
| ADR archive | `docs/adr/NNN-*.md` |
| Issue templates | `.gitea/ISSUE_TEMPLATE/{feature,bug}.md` |
| CI gate | `.gitea/workflows/sdd-gate.yml` |
**Before you mark a feature done:** every `REQ-NNN` has a green test, the RTM Status is
`Done`, all six personas APPROVE, `npm run lint` and the targeted tests pass, and
`npm run generate:api` has been run if the backend model changed.
**Commands:**
```bash
# validate an OpenAPI contract locally (if you drafted one — same as CI)
npx @stoplight/spectral-cli lint <your-contract>.yaml
# regenerate the TS client after a backend model/endpoint change
cd frontend && npm run generate:api # backend must run with --spring.profiles.active=dev
```

View File

@@ -155,6 +155,14 @@ public enum ErrorCode {
/** The merge target is a descendant of the source tag. 400 */
TAG_MERGE_INVALID_TARGET,
// --- Timeline (Zeitstrahl) ---
/** A timeline event with the given ID does not exist. 404 */
TIMELINE_EVENT_NOT_FOUND,
/** Optimistic-locking conflict — the timeline event was modified by another curator. 409 */
TIMELINE_EVENT_CONFLICT,
/** A timeline event title exceeds the maximum length (255 characters — the DB column bound). 400 */
TIMELINE_TITLE_TOO_LONG,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,
@@ -162,6 +170,8 @@ public enum ErrorCode {
BATCH_TOO_LARGE,
/** Bulk edit request exceeds the per-request document ID cap. 400 */
BULK_EDIT_TOO_MANY_IDS,
/** A concurrent modification was detected (generic optimistic-lock backstop). 409 */
CONFLICT,
/** An unexpected server-side error occurred. 500 */
INTERNAL_ERROR,
}

View File

@@ -104,6 +104,30 @@ public class GlobalExceptionHandler {
return "unknown";
}
/**
* Generic backstop for optimistic-locking conflicts that escape a service-level catch. A
* conflict is a 409, not a system fault — so, like {@link #handleDataIntegrityViolation}, it
* must NOT fire Sentry and must NOT leak Hibernate internals (CWE-209): the response carries
* only the generic {@link ErrorCode#CONFLICT} code and a generic message — no entity id, no
* version, no persistent-class name.
*
* <p>Deliberately code-GENERIC: do NOT {@code switch} on {@code getPersistentClassName()} to map
* back to a per-entity code. Unlike {@link #handleDataIntegrityViolation}, which branches on
* stable schema constraint NAMES, persistent-class names are not a contract. The precise,
* code-carrying path is the service catch (e.g. {@code TIMELINE_EVENT_CONFLICT}); this is only
* the net that keeps any current or future write path from regressing to a 500.
*/
@ExceptionHandler(org.springframework.orm.ObjectOptimisticLockingFailureException.class)
public ResponseEntity<ErrorResponse> handleOptimisticLock(
org.springframework.orm.ObjectOptimisticLockingFailureException ex) {
// Log the persistent-class name ONLY (schema metadata, safe for Loki). Never `ex` /
// ex.getMessage(): those embed the entity id + version (CWE-209). No Sentry: it's a 409.
log.warn("Rejected a write that lost an optimistic-lock race on: {}", ex.getPersistentClassName());
return ResponseEntity.status(409)
.body(new ErrorResponse(ErrorCode.CONFLICT,
"The resource was modified concurrently. Please reload and try again."));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
Sentry.captureException(ex);

View File

@@ -0,0 +1,71 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.security.SecurityUtils;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/timeline/events")
@RequiredArgsConstructor
public class TimelineEventController {
private final TimelineEventService timelineEventService;
private final UserService userService;
/**
* No {@code @RequirePermission} on GET by design: the global {@code anyRequest().authenticated()}
* rule is the READ_ALL baseline, consistent with {@code DocumentController.getDocument}. Do not
* "fix" the missing annotation.
*/
@GetMapping("/{id}")
public TimelineEventView getEvent(@PathVariable UUID id) {
return timelineEventService.getEvent(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.WRITE_ALL)
public TimelineEventView create(@Valid @RequestBody TimelineEventRequest request, Authentication authentication) {
return timelineEventService.create(request, requireUserId(authentication));
}
@PutMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public TimelineEventView update(
@PathVariable UUID id,
@Valid @RequestBody TimelineEventRequest request,
Authentication authentication) {
return timelineEventService.update(id, request, requireUserId(authentication));
}
@DeleteMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Void> delete(@PathVariable UUID id) {
timelineEventService.delete(id);
return ResponseEntity.noContent().build();
}
private UUID requireUserId(Authentication authentication) {
return SecurityUtils.requireUserId(authentication, userService);
}
}

View File

@@ -0,0 +1,40 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Flat input DTO for creating/updating a {@link TimelineEvent}. Bean Validation fires at the
* controller boundary (via {@code @Valid}) and produces a 400 {@code VALIDATION_ERROR} for the
* presence/size constraints below; cross-field rules (the RANGE invariant), date normalization,
* id dedupe, and the title-length structured-error guard live in {@code TimelineEventService}.
*
* <p><strong>{@code createdBy}/{@code updatedBy} are intentionally absent.</strong> Authorship is
* server-populated from the session principal only — accepting it from the body would be an
* authorship-forgery / mass-assignment vector (CWE-639; see ADR-040 §7).
*
* @param version optional optimistic-lock concurrency token (the {@code @Version} the client last
* saw), applied on <em>update</em> only. This is a concurrency token, <strong>not</strong>
* an authorship field, so it is deliberately exempt from the §7 server-only audit rule.
* Null on update means "no concurrency check" (last-write-wins). No range validation —
* a stale/negative value is simply a mismatch the lock rejects at flush; the lock, not
* a validator, is the control.
*/
public record TimelineEventRequest(
@NotBlank @Size(max = 255) String title,
@NotNull EventType type,
@NotNull LocalDate eventDate,
DatePrecision precision,
LocalDate eventDateEnd,
@Size(max = 5000) String description,
Long version,
@Size(max = 50) List<UUID> personIds,
@Size(max = 50) List<UUID> documentIds
) {}

View File

@@ -0,0 +1,247 @@
package org.raddatz.familienarchiv.timeline;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Curator CRUD for {@link TimelineEvent}. Persons and documents are resolved through their own
* services (never their repositories). All four body-returning operations return a
* {@link TimelineEventView} assembled in-transaction — the entity is never serialized (ADR-040 §2).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class TimelineEventService {
private static final int MAX_TITLE_LENGTH = 255;
private final TimelineEventRepository events;
private final PersonService personService;
private final DocumentService documentService;
@Transactional
public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
DatePrecision precision = effectivePrecision(request);
validateRangeInvariant(request, precision);
validateTitleLength(request);
TimelineEvent event = TimelineEvent.builder()
.title(request.title())
.type(request.type())
.eventDate(normalizeEventDate(request.eventDate(), precision))
.precision(precision)
.eventDateEnd(request.eventDateEnd())
.description(request.description())
.persons(resolvePersons(request.personIds()))
.documents(resolveDocuments(request.documentIds()))
.createdBy(actorId)
.updatedBy(actorId)
.build();
return toView(events.saveAndFlush(event));
}
@Transactional
public TimelineEventView update(UUID id, TimelineEventRequest request, UUID actorId) {
TimelineEvent event = events.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
"Timeline event not found: " + id));
requireVersionMatch(request, event);
DatePrecision precision = effectivePrecision(request);
validateRangeInvariant(request, precision);
validateTitleLength(request);
applyUpdate(event, request, precision, actorId);
// saveAndFlush (not save) so the versioned UPDATE …WHERE version=? fires HERE, inside the
// try — a bare save() flushes at commit, after this method returns, so the exception would
// escape the catch and surface as a 500. Catch the Spring-translated type, not JPA's.
try {
return toView(events.saveAndFlush(event));
} catch (ObjectOptimisticLockingFailureException ex) {
throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
"Timeline event was modified concurrently: " + id);
}
}
@Transactional
public void delete(UUID id) {
TimelineEvent event = events.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
"Timeline event not found: " + id));
events.delete(event);
}
/**
* View-assembly read. {@code @Transactional(readOnly = true)} is load-bearing, not optional:
* the LAZY {@code persons}/{@code documents} collections are traversed during {@link #toView}
* assembly, and under {@code open-in-view: false} a closed session there is a
* {@code LazyInitializationException} (ADR-022 / {@code getDocumentDetail} precedent).
*/
@Transactional(readOnly = true)
public TimelineEventView getEvent(UUID id) {
TimelineEvent event = events.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
"Timeline event not found: " + id));
return toView(event);
}
// --- update mechanics: mutate the managed entity, never reassign collections ---
private void applyUpdate(TimelineEvent event, TimelineEventRequest request, DatePrecision precision, UUID actorId) {
event.setTitle(request.title());
event.setType(request.type());
event.setEventDate(normalizeEventDate(request.eventDate(), precision));
event.setPrecision(precision);
event.setEventDateEnd(request.eventDateEnd());
event.setDescription(request.description());
replaceLinks(event, request);
event.setUpdatedBy(actorId); // preserve createdBy — only the editor changes
}
/**
* Compares the client's concurrency token against the freshly-loaded version (the Q1
* "last-seen version" token). A mismatch means the client edited stale data → 409.
*
* <p>This explicit compare is the control — NOT {@code event.setVersion(clientVersion)} before
* flush. Setting {@code @Version} on a <em>managed</em> entity is silently ignored by Hibernate
* for the optimistic check: it uses its own loaded-version snapshot for the
* {@code UPDATE … WHERE version=?} clause, so a stale token never reaches the DB. The native
* {@code @Version} increment still happens on every save, and the {@code saveAndFlush}+catch
* below remains the backstop for two transactions flushing concurrently; this guard is what
* catches the human-timescale "B submitted a form based on a version A already superseded" case.
* A null token means no check (last-write-wins) until #9 always sends it.
*/
private void requireVersionMatch(TimelineEventRequest request, TimelineEvent event) {
if (request.version() != null && !request.version().equals(event.getVersion())) {
throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
"Timeline event was modified concurrently: " + event.getId());
}
}
/**
* Replaces (set semantics) the link collections. Mutates the existing managed collections —
* Hibernate does not track a reassigned reference, and a fresh {@code Set} risks orphan join
* rows against the {@code ON DELETE CASCADE} join tables. A null or empty list clears all links.
*/
private void replaceLinks(TimelineEvent event, TimelineEventRequest request) {
event.getPersons().clear();
event.getPersons().addAll(resolvePersons(request.personIds()));
event.getDocuments().clear();
event.getDocuments().addAll(resolveDocuments(request.documentIds()));
}
// --- validation / normalization ---
/**
* Mirrors the DB biconditional CHECK chk_timeline_event_range — both presence directions — and
* additionally enforces date ordering, which the DB CHECK does NOT: {@code eventDateEnd} may
* equal but never precede {@code eventDate}. Without this guard a reversed range (end before
* start) persists silently and renders as a negative span. Equal dates are a valid one-day
* closed range.
*/
private void validateRangeInvariant(TimelineEventRequest request, DatePrecision precision) {
boolean isRange = precision == DatePrecision.RANGE;
if (request.eventDateEnd() != null && !isRange) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
"eventDateEnd is only valid when precision is RANGE");
}
if (isRange && request.eventDateEnd() == null) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
"A RANGE event requires a non-null eventDateEnd");
}
if (isRange && request.eventDateEnd().isBefore(request.eventDate())) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
"eventDateEnd must not precede eventDate");
}
}
/**
* Load-bearing only for non-HTTP callers: the DTO {@code @Size(max = 255)} already covers HTTP
* callers, but a non-HTTP caller could otherwise push an over-long title to the VARCHAR(255)
* column and get a raw {@code DataIntegrityViolationException} → 500. Do not delete as
* "duplicate validation".
*/
private void validateTitleLength(TimelineEventRequest request) {
if (request.title() != null && request.title().length() > MAX_TITLE_LENGTH) {
throw DomainException.badRequest(ErrorCode.TIMELINE_TITLE_TOO_LONG,
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
}
}
private DatePrecision effectivePrecision(TimelineEventRequest request) {
return request.precision() != null ? request.precision() : DatePrecision.YEAR;
}
private LocalDate normalizeEventDate(LocalDate eventDate, DatePrecision precision) {
return precision == DatePrecision.YEAR ? LocalDate.of(eventDate.getYear(), 1, 1) : eventDate;
}
// --- link resolution (fail-closed, dedupe-first) ---
private Set<Person> resolvePersons(List<UUID> ids) {
if (ids == null || ids.isEmpty()) {
return new HashSet<>();
}
// Dedupe FIRST: [idA, idA] is one link, not a 404. findAllById dedupes too, so compare the
// resolved size against the DISTINCT input count — a raw ids.size() compare reports a spurious
// mismatch.
Set<UUID> distinct = new LinkedHashSet<>(ids);
List<Person> resolved = personService.getAllById(new ArrayList<>(distinct));
if (resolved.size() != distinct.size()) {
throw DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found");
}
return new HashSet<>(resolved);
}
private Set<Document> resolveDocuments(List<UUID> ids) {
if (ids == null || ids.isEmpty()) {
return new HashSet<>();
}
// Per-id loop on purpose: DocumentService has no batch fetch, and per-id gives free
// DOCUMENT_NOT_FOUND 404s. getDocumentById is @Transactional(readOnly = true) and joins this
// write tx via Spring's default REQUIRED propagation — do NOT "optimize" into a phantom batch.
Set<Document> resolved = new HashSet<>();
for (UUID documentId : new LinkedHashSet<>(ids)) {
resolved.add(documentService.getDocumentById(documentId));
}
return resolved;
}
// --- view assembly (explicit allow-list; never the raw entity) ---
private TimelineEventView toView(TimelineEvent event) {
List<PersonView> persons = event.getPersons().stream()
.map(p -> new PersonView(p.getId(), p.getFirstName(), p.getLastName()))
.toList();
List<DocumentRef> documents = event.getDocuments().stream()
.map(d -> new DocumentRef(d.getId(), d.getTitle(), d.getDocumentDate()))
.toList();
return new TimelineEventView(
event.getId(), event.getTitle(), event.getType(), event.getEventDate(),
event.getPrecision(), event.getEventDateEnd(), event.getDescription(), event.getVersion(),
event.getCreatedBy(), event.getCreatedAt(), event.getUpdatedBy(), event.getUpdatedAt(),
persons, documents);
}
}

View File

@@ -0,0 +1,59 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Response view for the timeline event endpoints — returned by GET, POST, and PUT alike.
* Assembled inside the service transaction (after {@code saveAndFlush} on the write paths, so
* {@code version} is non-null) from the managed entity's already-loaded collections. The raw
* {@link TimelineEvent} is never serialized: its LAZY {@code persons}/{@code documents}
* collections under {@code open-in-view: false} would otherwise 500 (ADR-036/ADR-040 §2), and
* splatting the entities would leak curator-internal fields ({@code Person.notes},
* {@code provisional}, transcription data) to every READ_ALL reader. The explicit field
* allow-list below is that guarantee.
*/
public record TimelineEventView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) EventType type,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDate eventDate,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
LocalDate eventDateEnd,
String description,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Long version,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID createdBy,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID updatedBy,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<PersonView> persons,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<DocumentRef> documents
) {
/** Summarised person — exposes only id, firstName, and lastName. Mirrors GeschichteView.PersonView. */
public record PersonView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
String firstName,
String lastName
) {}
/**
* Summarised linked document — id, title, and the eager {@code documentDate} only (no lazy
* sender/receiver hop, no person-name leak through the document side).
*
* <p>timeline-local by design; do not promote to {@code document/} — see #775 R7. Reusing
* {@code geschichte.journeyitem.DocumentSummary} would force a cross-domain import of a
* package-private mapper plus duplicated name-assembly logic; a 3-field local record is the
* lower-coupling choice.
*/
public record DocumentRef(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
LocalDate documentDate
) {}
}

View File

@@ -14,6 +14,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockStatic;
@@ -103,6 +106,49 @@ class GlobalExceptionHandlerTest {
});
}
@Test
void handleOptimisticLock_returns409_genericConflict_noSentry_noLeak() {
// CWE-209 regression: an optimistic-lock failure escaping a service catch must become a
// generic 409, never a 500 + Sentry + Hibernate internals. The generic CONFLICT code keeps
// it entity-agnostic — NOT TIMELINE_EVENT_CONFLICT, or every future entity's conflict is
// mislabeled a timeline one.
UUID entityId = UUID.randomUUID();
ObjectOptimisticLockingFailureException ex =
new ObjectOptimisticLockingFailureException("com.example.SomeEntity", entityId);
Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
handlerLogger.addAppender(appender);
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleOptimisticLock(ex);
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ErrorCode.CONFLICT);
// Body echoes no persistent-class name, no entity id, no version (enumeration aids).
assertThat(response.getBody().message())
.doesNotContain("SomeEntity")
.doesNotContain(entityId.toString());
// A conflict is not a system fault — no fabricated Sentry alert.
sentryMock.verifyNoInteractions();
} finally {
handlerLogger.detachAppender(appender);
}
assertThat(appender.list)
.as("WARN names the persistent class for debuggability")
.anySatisfy(e -> {
assertThat(e.getLevel()).isEqualTo(Level.WARN);
assertThat(e.getFormattedMessage()).contains("com.example.SomeEntity");
});
assertThat(appender.list)
.as("but never the entity id (would leak via getMessage())")
.noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains(entityId.toString()));
}
@Test
void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() {
// Debuggability (DevOps): the WARN must name *which* constraint fired so an

View File

@@ -0,0 +1,273 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TimelineEventController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class TimelineEventControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean TimelineEventService timelineEventService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final String VALID_JSON =
"{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}";
/** Default principal resolution for @WithMockUser's "user"; capture tests override with a known id. */
@BeforeEach
void resolveDefaultPrincipal() {
when(userService.findByEmail("user"))
.thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
}
private TimelineEventView sampleView() {
return new TimelineEventView(
UUID.randomUUID(), "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 1, 1),
org.raddatz.familienarchiv.document.DatePrecision.YEAR, null, null, 0L,
UUID.randomUUID(), LocalDateTime.now(), UUID.randomUUID(), LocalDateTime.now(),
List.of(), List.of());
}
// ─── POST /api/timeline/events ───────────────────────────────────────────
@Test
void create_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void create_returns403_whenOnlyReadAll() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns201_andViewBody_whenWriteAll() throws Exception {
when(timelineEventService.create(any(), any())).thenReturn(sampleView());
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("Hochzeit"))
.andExpect(jsonPath("$.version").value(0));
}
// ─── PUT /api/timeline/events/{id} ───────────────────────────────────────
@Test
void update_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void update_returns403_whenOnlyReadAll() throws Exception {
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void update_returns200_andViewBody_whenWriteAll() throws Exception {
when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView());
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Hochzeit"));
}
// ─── DELETE /api/timeline/events/{id} ────────────────────────────────────
@Test
void delete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void delete_returns403_whenOnlyReadAll() throws Exception {
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void delete_returns204_whenWriteAll() throws Exception {
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isNoContent());
}
// ─── GET /api/timeline/events/{id} ───────────────────────────────────────
@Test
void getEvent_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getEvent_returns200_whenAuthenticated() throws Exception {
when(timelineEventService.getEvent(any())).thenReturn(sampleView());
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Hochzeit"));
}
@Test
@WithMockUser
void getEvent_returns404_whenMissing() throws Exception {
when(timelineEventService.getEvent(any()))
.thenThrow(DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, "not found"));
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
.andExpect(status().isNotFound());
}
// ─── service-thrown link errors map to status ────────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns404_whenServiceThrowsPersonNotFound() throws Exception {
when(timelineEventService.create(any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found"));
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":[\""
+ UUID.randomUUID() + "\"]}"))
.andExpect(status().isNotFound());
}
// ─── Bean Validation 400s carry code VALIDATION_ERROR (R1) ───────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenTitleBlank() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\" \",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenTypeNull() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"eventDate\":\"1914-07-28\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenDescriptionTooLong() throws Exception {
String description = "x".repeat(5001);
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"description\":\""
+ description + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenTooManyPersonIds() throws Exception {
String ids = Stream.generate(() -> "\"" + UUID.randomUUID() + "\"").limit(51).collect(Collectors.joining(","));
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":["
+ ids + "]}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
// ─── actorId is the resolved session principal, not a body field (both write paths) ──
@Test
@WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL")
void create_passesResolvedPrincipalIdAsActor_ignoringBodyCreatedBy() throws Exception {
UUID principalId = UUID.randomUUID();
when(userService.findByEmail("curator@example.com"))
.thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build());
when(timelineEventService.create(any(), any())).thenReturn(sampleView());
// body carries a forged createdBy — it must be ignored, actor comes from the principal
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"createdBy\":\""
+ UUID.randomUUID() + "\"}"))
.andExpect(status().isCreated());
verify(timelineEventService).create(any(), eq(principalId));
}
@Test
@WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL")
void update_passesResolvedPrincipalIdAsActor_ignoringBodyUpdatedBy() throws Exception {
UUID principalId = UUID.randomUUID();
UUID eventId = UUID.randomUUID();
when(userService.findByEmail("curator@example.com"))
.thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build());
when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView());
mockMvc.perform(put("/api/timeline/events/" + eventId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"updatedBy\":\""
+ UUID.randomUUID() + "\"}"))
.andExpect(status().isOk());
verify(timelineEventService).update(eq(eventId), any(), eq(principalId));
}
}

View File

@@ -0,0 +1,109 @@
package org.raddatz.familienarchiv.timeline;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Service-level integration scope the entity/DB tests ({@link TimelineEventTest}) don't reach: the
* in-transaction view assembly survives a context clear (the {@code @Transactional(readOnly = true)}
* LazyInit guard), the serialized view leaks no curator-internal fields, and the {@code @Version}
* optimistic lock engages end-to-end (the Mockito test only proves the catch branch). Real Postgres
* (V77 CHECK constraints are Postgres-specific) via {@link PostgresContainerConfig}.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class TimelineEventServiceIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired TimelineEventService service;
@Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@PersistenceContext EntityManager em;
// Built locally — the webEnvironment=NONE context has no auto-configured ObjectMapper bean.
// findAndRegisterModules() pulls in JavaTimeModule so the view's LocalDate/LocalDateTime serialize.
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private TimelineEventRequest request(String title, Long version, List<UUID> personIds, List<UUID> documentIds) {
return new TimelineEventRequest(title, EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, version, personIds, documentIds);
}
@Test
void getEvent_after_context_clear_populates_links_and_leaks_no_internal_fields() throws Exception {
Person anna = personRepository.save(Person.builder()
.firstName("Anna").lastName("Müller").notes("GEHEIM-NOTIZ").provisional(true).build());
Document letter = documentRepository.save(Document.builder()
.title("Brief an Anna").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
UUID eventId = service.create(
request("Hochzeit", null, List.of(anna.getId()), List.of(letter.getId())), UUID.randomUUID()).id();
em.flush();
em.clear();
// Fresh read — NOT the create return value (that view was assembled while the entity was
// managed). Only a separate read after the clear proves the readOnly LazyInit guard.
TimelineEventView fresh = service.getEvent(eventId);
assertThat(fresh.persons()).singleElement().satisfies(p -> {
assertThat(p.firstName()).isEqualTo("Anna");
assertThat(p.lastName()).isEqualTo("Müller");
});
assertThat(fresh.documents()).singleElement().satisfies(d ->
assertThat(d.title()).isEqualTo("Brief an Anna"));
// Assert on the SERIALIZED JSON: a getter re-introducing a leaked field later would slip
// past a field-level check.
String json = objectMapper.writeValueAsString(fresh);
assertThat(json)
.doesNotContain("GEHEIM-NOTIZ")
.doesNotContain("notes")
.doesNotContain("provisional")
.doesNotContain("password");
}
@Test
void concurrent_update_with_stale_version_yields_TIMELINE_EVENT_CONFLICT() {
UUID editorA = UUID.randomUUID();
UUID editorB = UUID.randomUUID();
UUID eventId = service.create(request("Original", null, null, null), editorA).id();
em.flush();
em.clear();
// Editor A saves with the version they last saw (0) → succeeds, version advances to 1.
service.update(eventId, request("Edit A", 0L, null, null), editorA);
em.flush();
em.clear();
// Editor B still holds the stale version 0 → the versioned UPDATE matches no row → 409.
assertThatThrownBy(() -> service.update(eventId, request("Edit B", 0L, null, null), editorB))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
}
}

View File

@@ -0,0 +1,453 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.AdditionalAnswers.returnsFirstArg;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TimelineEventServiceTest {
@Mock TimelineEventRepository events;
@Mock PersonService personService;
@Mock DocumentService documentService;
@InjectMocks TimelineEventService service;
private final UUID actor = UUID.randomUUID();
private final UUID secondEditor = UUID.randomUUID();
/** Mirrors #774's makeEvent defaults so NOT NULL createdBy/updatedBy aren't tripped for the wrong reason. */
private TimelineEventRequest baseRequest() {
return new TimelineEventRequest(
"Hochzeit von Anna und Otto", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, null, null);
}
private Person makePerson(UUID id) {
return Person.builder().id(id).firstName("Anna").lastName("Müller").build();
}
private Document makeDocument(UUID id) {
return Document.builder().id(id).title("Brief an Anna").documentDate(LocalDate.of(1914, 6, 1)).build();
}
/** A managed, persisted event for update/delete/get paths — version 5, distinct creator. */
private TimelineEvent existingEvent(UUID id, UUID creator) {
return TimelineEvent.builder()
.id(id).title("Original").type(EventType.PERSONAL).eventDate(LocalDate.of(1914, 1, 1))
.precision(DatePrecision.YEAR).createdBy(creator).updatedBy(creator).version(5L)
.build();
}
/** Stubs saveAndFlush to mimic Hibernate setting version=0 on insert; returns the same managed entity. */
private void stubFlushSetsVersion() {
when(events.saveAndFlush(any())).thenAnswer(inv -> {
TimelineEvent e = inv.getArgument(0);
if (e.getVersion() == null) e.setVersion(0L);
return e;
});
}
private TimelineEvent captureSaved() {
ArgumentCaptor<TimelineEvent> captor = ArgumentCaptor.forClass(TimelineEvent.class);
verify(events).saveAndFlush(captor.capture());
return captor.getValue();
}
// --- create ---
@Test
void create_persists_and_sets_createdBy_and_updatedBy_from_actorId() {
stubFlushSetsVersion();
TimelineEventView view = service.create(baseRequest(), actor);
TimelineEvent persisted = captureSaved();
assertThat(persisted.getCreatedBy()).isEqualTo(actor);
assertThat(persisted.getUpdatedBy()).isEqualTo(actor);
assertThat(view.title()).isEqualTo("Hochzeit von Anna und Otto");
assertThat(view.version()).isEqualTo(0L);
}
@Test
void create_defaults_precision_to_YEAR_when_omitted() {
stubFlushSetsVersion();
TimelineEventView view = service.create(baseRequest(), actor);
assertThat(view.precision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void create_normalizes_eventDate_to_first_of_january_when_precision_is_YEAR() {
stubFlushSetsVersion();
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
DatePrecision.YEAR, null, null, null, null, null);
TimelineEventView view = service.create(request, actor);
assertThat(view.eventDate()).isEqualTo(LocalDate.of(1914, 1, 1));
}
@Test
void create_with_null_link_lists_yields_empty_collections_no_npe() {
stubFlushSetsVersion();
TimelineEventView view = service.create(baseRequest(), actor);
assertThat(view.persons()).isEmpty();
assertThat(view.documents()).isEmpty();
}
// --- RANGE invariant (both directions are separate tests; each asserts saveAndFlush never called) ---
@Test
void create_rejects_eventDateEnd_when_precision_is_not_RANGE() {
TimelineEventRequest request = new TimelineEventRequest(
"Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1),
DatePrecision.YEAR, LocalDate.of(1918, 1, 1), null, null, null, null);
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(events, never()).saveAndFlush(any());
}
@Test
void create_rejects_RANGE_with_null_eventDateEnd() {
TimelineEventRequest request = new TimelineEventRequest(
"Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1),
DatePrecision.RANGE, null, null, null, null, null);
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(events, never()).saveAndFlush(any());
}
@Test
void create_rejects_RANGE_with_eventDateEnd_before_eventDate() {
TimelineEventRequest request = new TimelineEventRequest(
"Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(events, never()).saveAndFlush(any());
}
@Test
void create_accepts_RANGE_with_eventDateEnd_equal_to_eventDate() {
stubFlushSetsVersion();
LocalDate sameDay = LocalDate.of(1914, 7, 28);
TimelineEventRequest request = new TimelineEventRequest(
"Eintägiges Ereignis", EventType.HISTORICAL, sameDay,
DatePrecision.RANGE, sameDay, null, null, null, null); // end == start is a valid closed range
TimelineEventView view = service.create(request, actor);
assertThat(view.eventDate()).isEqualTo(sameDay);
assertThat(view.eventDateEnd()).isEqualTo(sameDay);
}
// --- title-length service guard ---
@Test
void create_rejects_title_longer_than_255_with_TIMELINE_TITLE_TOO_LONG() {
String overLong = "x".repeat(256);
TimelineEventRequest request = new TimelineEventRequest(
overLong, EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, null, null);
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_TITLE_TOO_LONG);
verify(events, never()).saveAndFlush(any());
}
// --- link resolution (fail-closed, dedupe-first) ---
@Test
void create_resolves_persons_and_documents() {
stubFlushSetsVersion();
UUID personId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId)));
when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId));
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, List.of(personId), List.of(docId));
TimelineEventView view = service.create(request, actor);
assertThat(view.persons()).singleElement().satisfies(p -> assertThat(p.id()).isEqualTo(personId));
assertThat(view.documents()).singleElement().satisfies(d -> assertThat(d.id()).isEqualTo(docId));
}
@Test
void create_with_duplicate_personIds_resolves_to_single_link_not_404() {
stubFlushSetsVersion();
UUID personId = UUID.randomUUID();
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId)));
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, List.of(personId, personId), null);
TimelineEventView view = service.create(request, actor);
assertThat(view.persons()).hasSize(1);
}
@Test
void create_with_duplicate_documentIds_resolves_to_single_link_not_404() {
stubFlushSetsVersion();
UUID docId = UUID.randomUUID();
when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId));
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, null, List.of(docId, docId));
TimelineEventView view = service.create(request, actor);
assertThat(view.documents()).hasSize(1);
}
@Test
void create_rejects_unknown_personId_with_PERSON_NOT_FOUND_without_saving() {
UUID known = UUID.randomUUID();
UUID unknown = UUID.randomUUID();
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(known))); // one missing
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, List.of(known, unknown), null);
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.PERSON_NOT_FOUND);
verify(events, never()).saveAndFlush(any());
}
@Test
void create_rejects_unknown_documentId_with_DOCUMENT_NOT_FOUND_without_saving() {
UUID unknown = UUID.randomUUID();
when(documentService.getDocumentById(unknown))
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + unknown));
TimelineEventRequest request = new TimelineEventRequest(
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, null, List.of(unknown));
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND);
verify(events, never()).saveAndFlush(any());
}
// --- update ---
@Test
void update_replaces_links_preserves_createdBy_and_advances_updatedBy_to_second_editor() {
UUID id = UUID.randomUUID();
UUID creator = UUID.randomUUID();
UUID newPersonId = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, creator);
existing.getPersons().add(makePerson(UUID.randomUUID())); // pre-existing link to be replaced
when(events.findById(id)).thenReturn(Optional.of(existing));
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(newPersonId)));
TimelineEventRequest request = new TimelineEventRequest(
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, List.of(newPersonId), null);
service.update(id, request, secondEditor);
assertThat(existing.getCreatedBy()).isEqualTo(creator);
assertThat(existing.getUpdatedBy()).isEqualTo(secondEditor);
assertThat(existing.getPersons()).singleElement()
.satisfies(p -> assertThat(p.getId()).isEqualTo(newPersonId));
}
@Test
void update_with_empty_link_lists_clears_all_links() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
existing.getPersons().add(makePerson(UUID.randomUUID()));
existing.getDocuments().add(makeDocument(UUID.randomUUID()));
when(events.findById(id)).thenReturn(Optional.of(existing));
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
TimelineEventRequest request = new TimelineEventRequest(
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, null, List.of(), List.of());
service.update(id, request, secondEditor);
assertThat(existing.getPersons()).isEmpty();
assertThat(existing.getDocuments()).isEmpty();
}
@Test
void update_rejects_RANGE_with_eventDateEnd_before_eventDate_without_saving() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
when(events.findById(id)).thenReturn(Optional.of(existing));
TimelineEventRequest request = new TimelineEventRequest(
"Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start
assertThatThrownBy(() -> service.update(id, request, secondEditor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(events, never()).saveAndFlush(any());
}
@Test
void update_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
UUID id = UUID.randomUUID();
when(events.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.update(id, baseRequest(), actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
}
// --- version / optimistic lock ---
// Note: the lock control is an explicit base-version compare (requireVersionMatch), NOT
// event.setVersion(clientVersion) — Hibernate silently ignores a manually-set @Version on a
// managed entity (proven by TimelineEventServiceIntegrationTest). The saveAndFlush+catch below
// is retained as the native backstop for two transactions flushing concurrently.
@Test
void update_with_null_version_skips_the_check_and_saves_last_write_wins() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
when(events.findById(id)).thenReturn(Optional.of(existing));
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
service.update(id, baseRequest(), secondEditor); // baseRequest has null version
verify(events).saveAndFlush(existing); // no conflict despite an unknown client base
}
@Test
void update_with_in_sync_version_succeeds_and_saves() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
when(events.findById(id)).thenReturn(Optional.of(existing));
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
TimelineEventRequest request = new TimelineEventRequest(
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, 5L, null, null); // matches the loaded version
TimelineEventView view = service.update(id, request, secondEditor);
assertThat(view).isNotNull();
verify(events).saveAndFlush(existing);
}
@Test
void update_with_stale_version_throws_conflict_without_saving() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
when(events.findById(id)).thenReturn(Optional.of(existing));
TimelineEventRequest request = new TimelineEventRequest(
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, 2L, null, null); // stale: 2 != 5
assertThatThrownBy(() -> service.update(id, request, secondEditor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
verify(events, never()).saveAndFlush(any());
}
@Test
void update_translates_concurrent_flush_lock_failure_to_TIMELINE_EVENT_CONFLICT() {
// Native @Version backstop: an in-sync token passes the explicit guard, but a genuinely
// concurrent flush makes saveAndFlush throw — it must still surface as a 409, not a 500.
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
when(events.findById(id)).thenReturn(Optional.of(existing));
when(events.saveAndFlush(any()))
.thenThrow(new ObjectOptimisticLockingFailureException(TimelineEvent.class, id));
TimelineEventRequest request = new TimelineEventRequest(
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
null, null, null, 5L, null, null); // in-sync, passes the guard
assertThatThrownBy(() -> service.update(id, request, secondEditor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
}
// --- delete / getEvent ---
@Test
void delete_removes_existing_event() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
when(events.findById(id)).thenReturn(Optional.of(existing));
service.delete(id);
verify(events).delete(existing);
}
@Test
void delete_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
UUID id = UUID.randomUUID();
when(events.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.delete(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
}
@Test
void getEvent_returns_view_for_existing_event() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
when(events.findById(id)).thenReturn(Optional.of(existing));
TimelineEventView view = service.getEvent(id);
assertThat(view.id()).isEqualTo(id);
assertThat(view.title()).isEqualTo("Original");
}
@Test
void getEvent_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
UUID id = UUID.randomUUID();
when(events.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getEvent(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
}
}

View File

@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. Timeline domain codes: `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG`, plus a generic `CONFLICT` (409 optimistic-lock backstop). |
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter``PersonRegisterImporter``PersonTreeImporter``DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |

View File

@@ -0,0 +1,123 @@
# ADR-041 — Renovate runner stand-up: two-token model, OSV surfacing, digest pinning
**Date:** 2026-06-13
**Status:** Accepted
**Issue:** [#818](https://git.raddatz.cloud/marcel/familienarchiv/issues/818)
---
## Context
Issue #817 (esbuild/cookie advisory) revealed that `main` had no early-warning
mechanism for newly-published advisories. An advisory landed against already-pinned
versions, turned the `npm audit --audit-level=high --omit=dev` gate red on `main`,
and then ambushed the next unrelated PR (#774). The author who hit it did not cause
it and had no warning.
`renovate.json` existed but `renovatebot` had never actually run: there was no
`.gitea/workflows/renovate.yml` and zero Renovate-authored PRs in the repo's entire
history. The three `packageRules` (bucket4j / tiptap / privileged-digest) were
silently inert.
This ADR records the **negative space** — why specific design choices were made,
so future maintainers do not "tidy up" toward a worse outcome.
---
## Decision
### Why there is no auto-provided `GITEA_TOKEN`
Self-hosted Gitea runners do not auto-inject a `GITEA_TOKEN` equivalent.
`docs/infrastructure/ci-gitea.md` (and its current line ~251) explicitly states the
token "must be created manually." No existing workflow in this repo references
`GITEA_TOKEN` for API calls — only for container registry auth (`docker login`).
Both `RENOVATE_TOKEN` and `NIGHTLY_AUDIT_TOKEN` must be manually provisioned as
Gitea secrets by a repository admin.
### Why two tokens, not one
The two jobs have different blast radii on token compromise:
| Token | Scopes | Used by |
|-------|--------|---------|
| `RENOVATE_TOKEN` | `contents` + `pull_request` + `issues` | Renovate — must read/write files and open PRs |
| `NIGHTLY_AUDIT_TOKEN` | `issues` only | Nightly audit — only needs to file a tracking issue |
The nightly job's token appears in step `env:` and is passed to `curl -H`. A leak via
runner logs, process arguments, or a misconfigured step would expose the token.
An `issues`-only token cannot push branches, open PRs, or read repository contents —
the leaked token's blast radius is limited to creating/editing issues.
A single broad token would give any leak path full `contents` + `pull_request` write
access to the repository. That risk is asymmetric with the upside (one fewer secret).
Both tokens belong to one dedicated bot account (consistent authorship; one identity
to audit and rotate). **Branch protection on `main` must forbid the bot pushing
directly**, because a `contents`-scoped token can push to any unprotected branch.
### Why the Renovate action is digest-pinned
`renovatebot/github-action` executes with the `RENOVATE_TOKEN` in scope. That token
carries `contents` + `pull_request` + `issues` — enough to read files, open PRs, and
write issues. An unpinned `@v40` tag can be re-pointed by the upstream maintainer
(or a compromised maintainer account) at any time. A pinned digest (`@<sha>`) cannot
be silently modified; the SHA is immutable. This is the same threat model applied to
all privileged CI steps in this repo (see the `matchFileNames` rule in `renovate.json`
for `.gitea/workflows/**`).
Renovate itself will open a PR to bump the digest when a new release ships, which is
the intended update path.
### Why `osvVulnerabilityAlerts` is the load-bearing detector on Gitea
Renovate's `vulnerabilityAlerts` config key triggers off a *platform* vulnerability
graph. GitHub exposes the GitHub Advisory Database via its API; **Gitea does not
expose an equivalent vulnerability graph**. On self-hosted Gitea, `vulnerabilityAlerts`
is effectively a label carrier — it attaches the configured labels to PRs that
`osvVulnerabilityAlerts` already detected, but it is not an independent detector.
`osvVulnerabilityAlerts: true` is the load-bearing flag: Renovate queries
[OSV.dev](https://osv.dev) directly (platform-agnostic). The runner host must be able
to reach OSV.dev over HTTPS — if egress is filtered, allow `osv.dev:443` or the flag
silently no-ops.
### Why the root `schedule` does not mute security PRs
`"schedule": ["before 6am on monday"]` in `renovate.json` batches **routine** dependency
updates (version bumps outside any security context) to a weekly window. This reduces
noise from routine update PRs while still allowing review before merge.
**Security and vulnerability PRs bypass the schedule by design** — Renovate raises
them immediately regardless of the schedule window. A future "tidy-up" that removes
or widens the schedule cannot mute vulnerability alerts; this is worth stating
explicitly to prevent that misunderstanding.
### Why `lockFileMaintenance` has no `automerge`
`lockFileMaintenance` refreshes transitive pins weekly so the dependency tree drifts
into fewer advisories over time. It is explicitly set without `automerge: true` because
a weekly transitive pin refresh can silently break the build if a transitive dep
introduces a breaking change. These PRs are small and should be reviewed.
### Why there is no entry in `l2-containers.puml`
`docs/architecture/c4/l2-containers.puml` documents long-lived infrastructure
containers (services that run continuously). Renovate is a scheduled CI job that runs
on a Gitea Actions runner and exits — it is not a long-lived container. Adding it to
the container diagram would misrepresent the architecture. This omission is deliberate,
not an oversight.
---
## Consequences
- Newly-published advisories against our frontend dependencies are surfaced within
one day (daily Renovate cron) rather than at the next contributor PR.
- A nightly `npm audit` job provides an independent signal for dev-dependency advisories
that Renovate may not cover via OSV.
- Two secrets (`RENOVATE_TOKEN`, `NIGHTLY_AUDIT_TOKEN`) must be manually provisioned
and rotated annually (or on suspected compromise). See
`docs/infrastructure/ci-gitea.md` for the runbook.
- The bot account must be kept active and branch protection on `main` must forbid
it pushing directly. These are operational prerequisites, not code invariants.

View File

@@ -0,0 +1,93 @@
# ADR-041 — Adopt Spec-Driven Development (SDD)
**Status:** Accepted
**Date:** 2026-06-13
**Issue:** SDD integration (docs/sdd-integration branch)
> This is the "ADR-000" the SDD scaffold refers to, numbered 041 to fit the existing archive
> sequence rather than starting a parallel one. See [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
## Context
The project already runs a lightweight, multi-persona review loop: feature work is tracked
as dense Gitea issues, and the `review-issue` / `review-pr` / `deliver-issue` skills dispatch
the character personas in `.claude/personas/` (requirements engineer, developer, security,
devops, ui, architect, tester) to comment on issues and PRs. `COLLABORATING.md` already
mandates a Research → Plan → Implement → Validate cycle, red/green TDD, and a User-Journey +
E2E-Scenario section on every feature issue. Decisions are captured in this ADR archive.
What is missing is a **machine-readable, uniform front-end to that workflow**:
- Requirements are written in free prose, so two readers (and an AI agent) can interpret the
same issue differently. There is no enforced requirement grammar and no stable requirement
identifier to trace from spec → code → test.
- There is no single short file an AI coding agent reads on every invocation; the rules are
spread across `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, and `CONTRIBUTING.md`.
- Persona review is valuable but ad-hoc — there is no per-role checklist that gates a spec
*before* implementation, so blind spots surface during PR review instead of at spec time.
- There is no living traceability record and no CI signal that a spec is well-formed.
The project is solo + LLM-driven; spec density and machine-readability are exactly the
leverage points that make agent output reliable.
## Decision
Adopt Spec-Driven Development, layered **on top of** the existing workflow, not replacing it:
1. **EARS requirements.** Every feature requirement is written in one of the five EARS
patterns and carries a per-feature `REQ-NNN` id. (constitution §3; templates/feature-spec.md)
2. **The spec lives in the Gitea issue body — issue-only, no committed `spec.md`.** A feature's
spec (EARS requirements, acceptance criteria, scope) is authored and reviewed in the Gitea
issue, the single source of truth (consistent with the established "issue body is the source
of truth / don't commit standalone spec files" practice). The only per-feature artifact in
git is the RTM row (`REQ-ID → issue # → test`). Durable design decisions still get a
`docs/adr/` ADR; an OpenAPI contract or STRIDE threat model is drafted inline in the issue
using the `.specify/templates/` as writing aids. `.specify/features/_example/` is a committed
template/reference, not a live feature.
3. **A constitution + AGENTS.md.** `.specify/constitution.md` records the few non-negotiable
rules (semantically versioned); `.specify/AGENTS.md` is the short machine-readable file AI
agents read every invocation, cross-referencing the constitution rather than duplicating it.
4. **Persona checklists.** `.specify/personas/*.md` turn the existing rich personas into
concise, EARS-aware, pass/fail spec-review checklists that gate a spec before code.
5. **Living RTM + CI gate.** `.specify/rtm.md` traces every `REQ-NNN` to its issue and test;
`.gitea/workflows/sdd-gate.yml` validates any committed OpenAPI contract, lints any committed
spec (e.g. the `_example`), reports constitution-change impact, and surfaces RTM drift
(non-blocking initially).
6. **Reuse, don't duplicate.** ADRs stay in `docs/adr/`; persona checklists reference
`.claude/personas/`; the Gitea issue templates mirror the feature-spec template.
## Alternatives Considered
| Option | Pros | Cons | Reason rejected |
|---|---|---|---|
| Adopt SDD (EARS requirements in the issue + AGENTS.md + constitution), integrated with existing docs | Adds requirement rigor & machine-readability; reuses ADR archive, personas, and the issue-as-spec practice; incremental | Per-feature spec authoring overhead | **Chosen** |
| No change (keep free-prose issues + persona review) | Zero new process | Ambiguous requirements; no traceability; no single agent-facing file; blind spots found late | Leaves the exact gaps that hurt agent output |
| Full GitHub Spec Kit | Mature, opinionated tooling | Heavy, opinionated structure that fights the existing Gitea/skill/persona workflow; redundant ADR/persona machinery | Too much to retrofit; conflicts with what already works |
| BMAD-METHOD | Rich agent-role framework | Large conceptual surface for a solo project; overlaps the existing persona system | Over-engineered for this team size |
## Consequences
- **Cost:** ~3060 min of spec authoring + structured persona review per feature, before
implementation begins.
- **Gains:** unambiguous, testable requirements; a stable spec→code→test trace; one
authoritative agent-facing rules file; review blind spots caught at spec time; better and
more consistent AI-agent output (the project's primary delivery mechanism).
- **Obligations created:**
- The constitution is semantically versioned; any change triggers its Sync Impact review.
- A new feature follows the workflow in [SPEC_DRIVEN_DEVELOPMENT.md](../../SPEC_DRIVEN_DEVELOPMENT.md).
- `rtm.md` is kept in sync as requirements land (CI warns on drift).
- The SDD CI jobs start non-blocking and flip to blocking once adoption settles (TODO noted
in the workflow).
- **Non-disruptive:** the existing Gitea-issue, branch/PR, TDD, and persona-review practices
are unchanged in spirit — SDD formalises their inputs, it does not replace them.
## References
- [.specify/constitution.md](../../.specify/constitution.md), [.specify/AGENTS.md](../../.specify/AGENTS.md)
- [SPEC_DRIVEN_DEVELOPMENT.md](../../SPEC_DRIVEN_DEVELOPMENT.md)
- [COLLABORATING.md](../../COLLABORATING.md), [CONTRIBUTING.md](../../CONTRIBUTING.md)
- EARS: Mavin et al., "Easy Approach to Requirements Syntax" (2009)
## Revision log
- 2026-06-13 — constitution v1.0.0 ratified with this ADR.

View File

@@ -462,3 +462,82 @@ jobs:
name: e2e-results
path: frontend/test-results/e2e/
```
---
## Renovate + Nightly Audit — Token Model
> See [ADR-041](../adr/041-renovate-runner-setup.md) for full rationale.
### Two-token model
This repo uses two separate tokens for automated dependency work, both manually
provisioned as Gitea secrets. There is **no auto-provided `GITEA_TOKEN`** on
self-hosted Gitea runners — it must be created manually (see §Context Variable Names
table above).
| Secret | Scopes | Job | Reason |
|--------|--------|-----|--------|
| `RENOVATE_TOKEN` | `contents` + `pull_request` + `issues` | `renovate.yml` | Renovate needs to read/write files and open PRs |
| `NIGHTLY_AUDIT_TOKEN` | `issues` only | `nightly.yml``npm-audit` job | Only needs to file a tracking issue; an `issues`-only token cannot push branches or read contents — limits blast radius on leak |
Both tokens belong to a single dedicated bot account. **Branch protection on `main`
must forbid the bot pushing directly**, because a `contents`-scoped token can push
to any unprotected branch.
### PAT rotation cadence
Rotate both tokens:
- **Annually** (calendar reminder)
- **Immediately** on suspected compromise (runner log leak, accidental `set -x`, etc.)
Tokens are stored exclusively as Gitea secrets. Never commit them to `.env` files or
log them. The nightly audit step passes its token via `env:` at the step level, reads
it as `$NIGHTLY_AUDIT_TOKEN` in the shell, and never runs the API `curl` under
`set -x`.
### OSV vs platform alerts on Gitea
Renovate's `vulnerabilityAlerts` config key requires a *platform* vulnerability graph.
**Gitea does not expose one** — it has no equivalent to the GitHub Advisory Database
API. On this runner, `vulnerabilityAlerts` is a label carrier only: it attaches
`security` and `P1-high` labels to PRs that `osvVulnerabilityAlerts` already raised.
`osvVulnerabilityAlerts: true` is the load-bearing detector. Renovate queries
[OSV.dev](https://osv.dev) directly, which works regardless of platform. The runner
host must be able to reach `osv.dev:443`. If egress is filtered and OSV.dev is
unreachable, the flag silently no-ops — verify egress when standing up the runner.
### Nightly audit vs PR gate (divergence)
| Gate | Command | Dev deps | When |
|------|---------|----------|------|
| PR gate (`ci.yml`) | `npm audit --audit-level=high --omit=dev` | ❌ excluded | Every PR |
| Nightly audit (`nightly.yml`) | `npm audit --audit-level=high` | ✅ included | Nightly + `workflow_dispatch` |
The nightly job is **deliberately broader** — it catches dev-tooling advisories
(esbuild, Vite, vitest, etc.) that the PR gate ignores. A red nightly audit job does
**not** mean the PR gate is broken; the two signals are independent.
### Runbook: nightly-opened tracking issue
When the `npm-audit` job opens or updates the tracking issue
"Nightly npm audit: high-severity advisory":
1. **Triage severity.** Check the advisory page (link in the issue body). Is it
exploitable in production? A dev-only dep (e.g. esbuild, prettier) has no
production attack surface — treat as low urgency.
2. **Pin or upgrade.** If a non-breaking upgrade is available, update
`frontend/package.json` and regenerate the lockfile. Open a PR.
3. **Override if justified.** If the advisory does not apply (dev-only dep, no
exploitable path), add an `npm audit` override in `package.json`:
```json
"overrides": { "esbuild": ">=0.25.4" }
```
Document the rationale in the PR body. See #817 for the reference decision tree.
4. **Close the tracking issue** once the advisory is resolved or overridden and the
nightly job runs clean (verify via the `✅ npm audit clean` heartbeat in the job
summary).

View File

@@ -151,7 +151,7 @@ receivers:
name: Renovate
on:
schedule:
- cron: '0 3 * * 1' # every Monday at 3am
- cron: '0 3 * * *' # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day
workflow_dispatch:
jobs:
@@ -160,32 +160,58 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Run Renovate
uses: renovatebot/github-action@v40
# Pin by digest — this action holds contents+pull_request+issues token;
# an unpinned tag is a supply-chain risk. Update digest + renovate-version
# together when Renovate publishes a new release.
uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
with:
configurationFile: renovate.json
token: ${{ secrets.GITEA_TOKEN }}
renovate-version: latest
token: ${{ secrets.RENOVATE_TOKEN }}
renovate-version: "46.1.15"
env:
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://gitea.example.com # replace with your Gitea URL
RENOVATE_REPOSITORIES: '["org/repo"]' # replace with your repo slug
LOG_LEVEL: info
```
> **Token:** `RENOVATE_TOKEN` must be a PAT on a dedicated bot account with scopes
> `contents` + `pull_request` + `issues`. **Do not reuse** `GITEA_TOKEN` — that variable
> is not auto-provided on self-hosted Gitea runners and must be manually created anyway;
> using a single broad token violates least-privilege. See ADR-041.
### Renovate Configuration
The `renovate.json` in the repo root carries only dependency rules — platform and
endpoint config is injected via `env:` in the workflow above. Keep the two concerns
separate so the config file remains portable.
```json
// renovate.json
{
"platform": "gitea",
"endpoint": "https://gitea.example.com",
"repositories": ["org/familienarchiv"],
"automerge": true,
"automergeType": "pr",
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"osvVulnerabilityAlerts": true,
"dependencyDashboard": true,
"schedule": ["before 6am on monday"],
"vulnerabilityAlerts": {
"labels": ["security", "P1-high"]
},
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 6am on monday"]
},
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"automerge": true
"matchPackageNames": ["com.example:my-dep"],
"automerge": true,
"matchUpdateTypes": ["patch"]
}
]
}
```
> **Do not add `automerge: true` at the root.** Security and digest-bump PRs should
> always be reviewed manually. Per-rule `automerge` on patch-level routine deps is fine.
---
## Secrets Management -- age + git-crypt

View File

@@ -1237,6 +1237,10 @@
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
"error_timeline_event_not_found": "Zeitleistenereignis nicht gefunden.",
"error_timeline_event_conflict": "Dieses Ereignis wurde zwischenzeitlich geändert. Bitte neu laden.",
"error_timeline_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
"error_conflict": "Der Datensatz wurde zwischenzeitlich geändert. Bitte neu laden.",
"person_unknown": "[Unbekannt]",
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."

View File

@@ -1237,6 +1237,10 @@
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
"error_timeline_event_not_found": "Timeline event not found.",
"error_timeline_event_conflict": "This event was changed in the meantime. Please reload.",
"error_timeline_title_too_long": "The title is too long (maximum 255 characters).",
"error_conflict": "The record was changed in the meantime. Please reload.",
"person_unknown": "[Unknown]",
"error_journey_document_already_added": "This letter is already included in the reading journey.",
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."

View File

@@ -1237,6 +1237,10 @@
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
"error_timeline_event_not_found": "Evento de la línea de tiempo no encontrado.",
"error_timeline_event_conflict": "Este evento se modificó mientras tanto. Vuelve a cargar.",
"error_timeline_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
"error_conflict": "El registro se modificó mientras tanto. Vuelve a cargar.",
"person_unknown": "[Desconocido]",
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."

View File

@@ -52,6 +52,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/timeline/events/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getEvent"];
put: operations["update"];
post?: never;
delete: operations["delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tags/{id}": {
parameters: {
query?: never;
@@ -232,6 +248,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/timeline/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["create"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tags/{id}/merge": {
parameters: {
query?: never;
@@ -433,7 +465,7 @@ export interface paths {
};
get: operations["list"];
put?: never;
post: operations["create"];
post: operations["create_1"];
delete?: never;
options?: never;
head?: never;
@@ -834,10 +866,10 @@ export interface paths {
get: operations["getById"];
put?: never;
post?: never;
delete: operations["delete"];
delete: operations["delete_1"];
options?: never;
head?: never;
patch: operations["update"];
patch: operations["update_1"];
trace?: never;
};
"/api/geschichten/{id}/items/{itemId}": {
@@ -1691,6 +1723,61 @@ export interface components {
notifyOnReply?: boolean;
notifyOnMention?: boolean;
};
TimelineEventRequest: {
title: string;
/** @enum {string} */
type: "PERSONAL" | "HISTORICAL";
/** Format: date */
eventDate: string;
/** @enum {string} */
precision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
eventDateEnd?: string;
description?: string;
/** Format: int64 */
version?: number;
personIds?: string[];
documentIds?: string[];
};
DocumentRef: {
/** Format: uuid */
id: string;
title: string;
/** Format: date */
documentDate?: string;
};
PersonView: {
/** Format: uuid */
id: string;
firstName?: string;
lastName?: string;
};
TimelineEventView: {
/** Format: uuid */
id: string;
title: string;
/** @enum {string} */
type: "PERSONAL" | "HISTORICAL";
/** Format: date */
eventDate: string;
/** @enum {string} */
precision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
eventDateEnd?: string;
description?: string;
/** Format: int64 */
version: number;
/** Format: uuid */
createdBy: string;
/** Format: date-time */
createdAt: string;
/** Format: uuid */
updatedBy: string;
/** Format: date-time */
updatedAt: string;
persons: components["schemas"]["PersonView"][];
documents: components["schemas"]["DocumentRef"][];
};
TagUpdateDTO: {
name?: string;
/** Format: uuid */
@@ -2060,12 +2147,6 @@ export interface components {
/** Format: date-time */
updatedAt: string;
};
PersonView: {
/** Format: uuid */
id: string;
firstName?: string;
lastName?: string;
};
JourneyItemCreateDTO: {
/** Format: uuid */
documentId?: string;
@@ -2498,10 +2579,10 @@ export interface components {
/** Format: int32 */
number?: number;
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
/** Format: int32 */
numberOfElements?: number;
empty?: boolean;
};
PageableObject: {
@@ -2885,6 +2966,74 @@ export interface operations {
};
};
};
getEvent: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TimelineEventView"];
};
};
};
};
update: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TimelineEventRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TimelineEventView"];
};
};
};
};
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateTag: {
parameters: {
query?: never;
@@ -3325,6 +3474,30 @@ export interface operations {
};
};
};
create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TimelineEventRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TimelineEventView"];
};
};
};
};
mergeTag: {
parameters: {
query?: never;
@@ -3754,7 +3927,7 @@ export interface operations {
};
};
};
create: {
create_1: {
parameters: {
query?: never;
header?: never;
@@ -4480,7 +4653,7 @@ export interface operations {
};
};
};
delete: {
delete_1: {
parameters: {
query?: never;
header?: never;
@@ -4500,7 +4673,7 @@ export interface operations {
};
};
};
update: {
update_1: {
parameters: {
query?: never;
header?: never;

View File

@@ -56,6 +56,9 @@ export type ErrorCode =
| 'GESCHICHTE_TYPE_IMMUTABLE'
| 'GESCHICHTE_TITLE_TOO_LONG'
| 'GESCHICHTE_INTRO_TOO_LONG'
| 'TIMELINE_EVENT_NOT_FOUND'
| 'TIMELINE_EVENT_CONFLICT'
| 'TIMELINE_TITLE_TOO_LONG'
| 'INVALID_CREDENTIALS'
| 'SESSION_EXPIRED'
| 'MISSING_CREDENTIALS'
@@ -66,6 +69,7 @@ export type ErrorCode =
| 'VALIDATION_ERROR'
| 'BATCH_TOO_LARGE'
| 'BULK_EDIT_TOO_MANY_IDS'
| 'CONFLICT'
| 'INTERNAL_ERROR';
export interface BackendError {
@@ -194,6 +198,12 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_geschichte_title_too_long();
case 'GESCHICHTE_INTRO_TOO_LONG':
return m.error_geschichte_intro_too_long();
case 'TIMELINE_EVENT_NOT_FOUND':
return m.error_timeline_event_not_found();
case 'TIMELINE_EVENT_CONFLICT':
return m.error_timeline_event_conflict();
case 'TIMELINE_TITLE_TOO_LONG':
return m.error_timeline_title_too_long();
case 'INVALID_CREDENTIALS':
return m.error_invalid_credentials();
case 'SESSION_EXPIRED':
@@ -214,6 +224,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_batch_too_large();
case 'BULK_EDIT_TOO_MANY_IDS':
return m.error_bulk_edit_too_many_ids();
case 'CONFLICT':
return m.error_conflict();
default:
return m.error_internal_error();
}

View File

@@ -1,5 +1,15 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"osvVulnerabilityAlerts": true,
"dependencyDashboard": true,
"schedule": ["before 6am on monday"],
"vulnerabilityAlerts": {
"labels": ["security", "P1-high"]
},
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 6am on monday"]
},
"packageRules": [
{
"description": "bucket4j-core is manually pinned outside the Spring BOM — track patch auto-merge, minor/major as PRs.",
@@ -9,13 +19,13 @@
"matchUpdateTypes": ["patch"]
},
{
"matchPackagePatterns": ["^@tiptap/"],
"matchPackageNames": ["/^@tiptap/"],
"groupName": "tiptap",
"automerge": false
},
{
"description": "Digest bumps for images used in privileged CI steps (--privileged --pid=host) must be reviewed manually — a compromised image has root-equivalent host access. Covers .gitea/actions/** too: the reload-caddy alpine digest now lives in a composite action (#603).",
"matchPaths": [".gitea/workflows/**", ".gitea/actions/**"],
"matchFileNames": [".gitea/workflows/**", ".gitea/actions/**"],
"matchUpdateTypes": ["digest"],
"automerge": false,
"reviewersFromCodeOwners": false