refactor(timeline): single-source the curator edit gate via canEditEvent

The security-relevant edit-affordance gate (canWrite && !derived && eventId
!= null) was copied into EventPill, WorldBand, and EventCluster — three
places for one load-bearing contract, inviting drift. It now lives once as
canEditEvent(entry, canWrite) in eventCardConfig, and all three call it. No
behavior change (HISTORICAL is never derived, so WorldBand's gate is
unchanged). First half of review finding #5 (Architect-1).

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-15 22:36:49 +02:00
parent 81e0dfb9e6
commit d450f97bff
5 changed files with 43 additions and 9 deletions

View File

@@ -3,7 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte';
import GlyphLabel from './GlyphLabel.svelte';
import { entryKey } from './entryKey';
import { getAccentConfig } from './eventCardConfig';
import { getAccentConfig, canEditEvent } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import { CLUSTER_PREVIEW } from './eventClustering';
import type { components } from '$lib/generated/api';
@@ -51,7 +51,7 @@ const provenance = $derived(
event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
);
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
const canEdit = $derived(!!event && canEditEvent(event, canWrite));
// First-5 preview + show-more (REQ-003): a large cluster stays readable instead of dumping every
// card into the timeline.

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getAccentConfig } from './eventCardConfig';
import { getAccentConfig, canEditEvent } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
@@ -26,7 +26,7 @@ const provenance = $derived(
// Provenance always shows; the date is an optional prefix so an undated event
// still reads "abgeleitet"/"kuratiert" (REQ-007).
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
const canEdit = $derived(canWrite && !entry.derived && entry.eventId != null);
const canEdit = $derived(canEditEvent(entry, canWrite));
</script>
<div class="flex justify-center">

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getAccentConfig } from './eventCardConfig';
import { getAccentConfig, canEditEvent } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
@@ -26,9 +26,9 @@ const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYe
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register
// always trails the subtitle as plain text — never a second pill (REQ-009).
const historical = $derived(m.timeline_layer_historical_suffix());
// A HISTORICAL event is never derived, so the edit gate is just the curator
// flag plus a real eventId (#842 REQ-006/008).
const canEdit = $derived(canWrite && entry.eventId != null);
// A HISTORICAL event is never derived, so canEditEvent's derived check is a
// no-op here — the gate is the curator flag plus a real eventId (#842 REQ-006/008).
const canEdit = $derived(canEditEvent(entry, canWrite));
</script>
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getAccentConfig } from './eventCardConfig';
import { getAccentConfig, canEditEvent } from './eventCardConfig';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
@@ -51,3 +51,24 @@ describe('getAccentConfig', () => {
expect(cfg.accent).toBe('curated');
});
});
// The single source of the curator edit-affordance gate (CLAUDE.md's TimelineEntryDTO contract):
// a curated event shows its edit pencil only for a writer, never for a derived life-event or a
// null eventId. Shared by EventPill, WorldBand, and EventCluster (#850 finding #5).
describe('canEditEvent', () => {
it('allows a writer to edit a curated event with an eventId', () => {
expect(canEditEvent(event({ derived: false, eventId: 'e-1' }), true)).toBe(true);
});
it('denies a viewer without write permission', () => {
expect(canEditEvent(event({ derived: false, eventId: 'e-1' }), false)).toBe(false);
});
it('denies a derived life-event even for a writer', () => {
expect(canEditEvent(event({ derived: true, eventId: 'e-1' }), true)).toBe(false);
});
it('denies an event with no eventId even for a writer', () => {
expect(canEditEvent(event({ derived: false, eventId: undefined }), true)).toBe(false);
});
});

View File

@@ -36,3 +36,16 @@ export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig {
}
return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' };
}
/**
* The curator edit-affordance gate, in one place — the security-relevant contract documented on
* CLAUDE.md's `TimelineEntryDTO` row (`derived || eventId == null` → no edit link). A curated
* event's edit pencil shows only for a viewer with WRITE_ALL (`canWrite`), and only when it is a
* real curated event: never a derived life-event (nothing to edit) and never a null `eventId`.
* HISTORICAL events are never derived, so this also covers the world band. The gate is UX only —
* the #781 route guard + backend permission are the real boundary. Shared by EventPill, WorldBand,
* and EventCluster so the gate has a single source of truth (#850 finding #5).
*/
export function canEditEvent(entry: TimelineEntryDTO, canWrite: boolean): boolean {
return canWrite && !entry.derived && entry.eventId != null;
}