feat(geschichten): show blog writers' own drafts on the Geschichten overview (#807) #813

Merged
marcel merged 13 commits from feat/issue-807-drafts-overview into main 2026-06-12 19:46:05 +02:00
Owner

Closes #807.

Why this PR exists (again)

The #807 implementation was completed on feat/issue-807-drafts-overview on top of the lesereisen integration branch, but the branch was never pushed or merged — the lesereisen squash (b33d0eb8) landed without it, and the issue was closed anyway. This PR rebases the original 9 commits onto current main and adds the one specced item the original implementation missed.

Security fix — CWE-639 (broken access control)

GeschichteService.list() forwarded a null status straight to the repository for BLOG_WRITE users, returning all stories from all authors including other writers' DRAFTs. Now null always resolves to PUBLISHED; only an explicit status=DRAFT request (blog writers only) returns drafts, scoped to the caller's own. The two false-safety-net tests that asserted the vulnerable behaviour were rewritten into real regression fixtures (eq(...) argument verification, @DisplayName("security: ...")).

Feature

  • Blog writers see an Entwürfe section at the top of /geschichten, showing their own drafts unfiltered (separate from the filtered published list), with an (alle Entwürfe) caption
  • A gated Veröffentlicht heading appears above the published list whenever the Entwürfe section is visible (balanced h2 outline for screen readers)
  • Each draft row carries an Entwurf badge (desktop meta column + mobile row, reusing the journey badge tokens)
  • The DRAFT fetch uses Promise.allSettled and degrades gracefully to drafts: [] — the overview never 500s on a drafts-fetch failure
  • settled<T>() extracted to $lib/shared/server/settled.ts and reused by both the home and geschichten loaders
  • 4 new i18n keys (de/en/es)

Rebase conflict resolutions (vs. the original branch)

  • +page.server.ts: combined the drafts fetch with main's documentFilter title resolution (from #803) in one Promise.allSettled with fixed-position placeholders instead of slice-offset arithmetic
  • page.server.test.ts: kept main's richer mockApi (path-keyed), took the new callLoad(url, parentData) signature
  • messages/*.json: kept both key sets (#803 chip keys + #807 draft keys)

New on top of the original branch

The original implementation skipped the spec's gated "Veröffentlicht" heading (the i18n key existed but was unused). Added via red/green TDD as the final two commits.

Verification

  • GeschichteServiceTest: 45/45 pass (includes the 2 new security regression tests)
  • page.server.test.ts: 16/16 pass
  • Browser specs page.svelte.spec.ts + GeschichteListRow.svelte.spec.ts: 25 + 12 pass
  • svelte-check: no errors in any touched file (baseline errors in untouched home/admin specs remain)

🤖 Generated with Claude Code

Closes #807. ## Why this PR exists (again) The #807 implementation was completed on `feat/issue-807-drafts-overview` on top of the lesereisen integration branch, but the branch was **never pushed or merged** — the lesereisen squash (b33d0eb8) landed without it, and the issue was closed anyway. This PR rebases the original 9 commits onto current `main` and adds the one specced item the original implementation missed. ## Security fix — CWE-639 (broken access control) `GeschichteService.list()` forwarded a `null` status straight to the repository for `BLOG_WRITE` users, returning **all stories from all authors including other writers' DRAFTs**. Now `null` always resolves to `PUBLISHED`; only an explicit `status=DRAFT` request (blog writers only) returns drafts, scoped to the caller's own. The two false-safety-net tests that asserted the vulnerable behaviour were rewritten into real regression fixtures (`eq(...)` argument verification, `@DisplayName("security: ...")`). ## Feature - Blog writers see an **Entwürfe** section at the top of `/geschichten`, showing their own drafts unfiltered (separate from the filtered published list), with an `(alle Entwürfe)` caption - A gated **Veröffentlicht** heading appears above the published list whenever the Entwürfe section is visible (balanced h2 outline for screen readers) - Each draft row carries an **Entwurf** badge (desktop meta column + mobile row, reusing the journey badge tokens) - The DRAFT fetch uses `Promise.allSettled` and degrades gracefully to `drafts: []` — the overview never 500s on a drafts-fetch failure - `settled<T>()` extracted to `$lib/shared/server/settled.ts` and reused by both the home and geschichten loaders - 4 new i18n keys (de/en/es) ## Rebase conflict resolutions (vs. the original branch) - `+page.server.ts`: combined the drafts fetch with main's documentFilter title resolution (from #803) in one `Promise.allSettled` with fixed-position placeholders instead of slice-offset arithmetic - `page.server.test.ts`: kept main's richer `mockApi` (path-keyed), took the new `callLoad(url, parentData)` signature - `messages/*.json`: kept both key sets (#803 chip keys + #807 draft keys) ## New on top of the original branch The original implementation skipped the spec's gated "Veröffentlicht" heading (the i18n key existed but was unused). Added via red/green TDD as the final two commits. ## Verification - `GeschichteServiceTest`: 45/45 pass (includes the 2 new security regression tests) - `page.server.test.ts`: 16/16 pass - Browser specs `page.svelte.spec.ts` + `GeschichteListRow.svelte.spec.ts`: 25 + 12 pass - `svelte-check`: no errors in any touched file (baseline errors in untouched home/admin specs remain) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 11 commits 2026-06-12 18:56:18 +02:00
Rename list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible
to list_with_null_status_and_BLOG_WRITE_returns_PUBLISHED_not_all_stories and
rewrite to verify eq(PUBLISHED) is passed — this test is now RED against the
vulnerable list() implementation.

Strengthen list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE with
eq(PUBLISHED) and isNull() matchers — both tests are now real regression fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A blog writer passing null status previously forwarded null to the repository,
returning all stories including other authors' drafts. Now only an explicit
DRAFT request (blog writer only) scopes to the caller's own stories.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RED: loader does not yet call parent() or fetch DRAFT stories.
Also extracts settled<T>() helper to $lib/shared/server/settled.ts
and seeds makeData/callLoad factories with drafts/parent defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blog writers now get a separate resilient DRAFT fetch alongside the
PUBLISHED list. A network failure degrades to drafts: [] rather than
a 500, so the overview stays usable even if the draft fetch times out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RED: component has no draft badge yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RED: page does not yet render a drafts section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drafts appear in a separate unfiltered section at the top of the overview,
clearly separated by a divider and labelled with the draft badge on each row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(geschichten): show Veröffentlicht heading when Entwürfe section is visible
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 5m52s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Successful in 4m52s
CI / fail2ban Regex (pull_request) Successful in 50s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
52019f7e69
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
marcel added 2 commits 2026-06-12 19:45:52 +02:00
list_with_null_status_and_BLOG_WRITE_returns_PUBLISHED_not_all_stories
was byte-for-byte identical to the @DisplayName("security: ...") variant;
keep the named one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(shared): null-harden settled() against placeholder slots
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m6s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m11s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
17b0625a73
A Promise.resolve(null) placeholder (e.g. the gated drafts slot) fulfils
with a null value; settled() dereferenced v.response unconditionally and
threw. Now any nullish value resolves to null. Adds unit tests for all
settled() branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
marcel merged commit 38a6d6b0fc into main 2026-06-12 19:46:05 +02:00
marcel deleted branch feat/issue-807-drafts-overview 2026-06-12 19:46:05 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#813