From c1bb652aafb603538629dda56e5a3cdbff7e7661 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 22:36:49 +0200 Subject: [PATCH] refactor(timeline): single-source the curator edit gate via canEditEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/lib/timeline/EventCluster.svelte | 4 ++-- frontend/src/lib/timeline/EventPill.svelte | 4 ++-- frontend/src/lib/timeline/WorldBand.svelte | 8 +++---- .../src/lib/timeline/eventCardConfig.spec.ts | 23 ++++++++++++++++++- frontend/src/lib/timeline/eventCardConfig.ts | 13 +++++++++++ 5 files changed, 43 insertions(+), 9 deletions(-) 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; +}