diff --git a/frontend/src/lib/timeline/EventCluster.svelte b/frontend/src/lib/timeline/EventCluster.svelte index bbf7da3c..024ba168 100644 --- a/frontend/src/lib/timeline/EventCluster.svelte +++ b/frontend/src/lib/timeline/EventCluster.svelte @@ -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. diff --git a/frontend/src/lib/timeline/EventPill.svelte b/frontend/src/lib/timeline/EventPill.svelte index b8573119..5a4afb17 100644 --- a/frontend/src/lib/timeline/EventPill.svelte +++ b/frontend/src/lib/timeline/EventPill.svelte @@ -1,6 +1,6 @@
diff --git a/frontend/src/lib/timeline/WorldBand.svelte b/frontend/src/lib/timeline/WorldBand.svelte index 9dbab74f..ff3da75f 100644 --- a/frontend/src/lib/timeline/WorldBand.svelte +++ b/frontend/src/lib/timeline/WorldBand.svelte @@ -1,6 +1,6 @@
diff --git a/frontend/src/lib/timeline/eventCardConfig.spec.ts b/frontend/src/lib/timeline/eventCardConfig.spec.ts index 8fd33355..ac1b49cb 100644 --- a/frontend/src/lib/timeline/eventCardConfig.spec.ts +++ b/frontend/src/lib/timeline/eventCardConfig.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/timeline/eventCardConfig.ts b/frontend/src/lib/timeline/eventCardConfig.ts index cb11d7b2..7d0aeb1f 100644 --- a/frontend/src/lib/timeline/eventCardConfig.ts +++ b/frontend/src/lib/timeline/eventCardConfig.ts @@ -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; +}