From 4b3b905225a442ca4b7b052fda127a9e972be46b Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 13:28:18 +0200 Subject: [PATCH] fix(events): key the events store by addressable coordinate (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-52 calendar events (kinds 31922/31923) are addressable: their d-tag is author-scoped, so the replacement key is kind:pubkey:d-tag, not the bare d-tag. The store keyed `eventsMap` by `event.id` (d-tag) and replaced on newer `created_at` ignoring pubkey, so a different author republishing the same d-tag could overwrite a legit event in the store (cross-author hijack). NDK (`event.coordinate()`) and welshman (`eventsByAddress`) both key addressable events by the full coordinate. - Key `eventsMap` by `eventCoordinate()` = `${kind}:${pubkey}:${dtag}`; same-coordinate-newer-wins replacement, different authors stored apart. - Keep the d-tag as the route identifier: `getEventById(dtag)` scans and returns the newest match (single-publisher in practice). Add `getByCoordinate()` for precise, author-known lookups. - `removeEvent(dtag)` deletes every coordinate sharing that d-tag. Client-side only — the store is rebuilt from relays each session, so no demo-DB surgery. Covered by vitest unit tests including the cross-author no-overwrite case. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/modules/events/stores/events.spec.ts | 120 +++++++++++++++++++++++ src/modules/events/stores/events.ts | 72 ++++++++++++-- 2 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 src/modules/events/stores/events.spec.ts diff --git a/src/modules/events/stores/events.spec.ts b/src/modules/events/stores/events.spec.ts new file mode 100644 index 0000000..e6e90e7 --- /dev/null +++ b/src/modules/events/stores/events.spec.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, it, expect } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useEventsStore, eventCoordinate, eventKind } from './events' +import type { Event } from '../types/event' + +// Minimal Event factory — only the fields the store touches matter; the +// rest are filled with inert defaults and cast to the full type. +function makeEvent(overrides: Partial = {}): Event { + return { + id: 'd-tag-1', + nostrEventId: 'nostr-id', + type: 'time', + organizer: { pubkey: 'pubkey-alice' }, + title: 'Test Event', + description: '', + startDate: new Date('2026-07-01T18:00:00Z'), + tags: [], + isPrivate: false, + createdAt: new Date('2026-06-01T00:00:00Z'), + ...overrides, + } as Event +} + +describe('eventKind / eventCoordinate', () => { + it('maps date events to 31922 and time events to 31923', () => { + expect(eventKind(makeEvent({ type: 'date' }))).toBe(31922) + expect(eventKind(makeEvent({ type: 'time' }))).toBe(31923) + }) + + it('builds kind:pubkey:d-tag coordinates', () => { + const e = makeEvent({ type: 'time', id: 'abc', organizer: { pubkey: 'pk' } }) + expect(eventCoordinate(e)).toBe('31923:pk:abc') + }) + + it('distinguishes same d-tag across authors', () => { + const a = makeEvent({ id: 'same', organizer: { pubkey: 'alice' } }) + const b = makeEvent({ id: 'same', organizer: { pubkey: 'mallory' } }) + expect(eventCoordinate(a)).not.toBe(eventCoordinate(b)) + }) +}) + +describe('useEventsStore.upsertEvent', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('keeps the newer version of the same coordinate', () => { + const store = useEventsStore() + const older = makeEvent({ createdAt: new Date('2026-06-01T00:00:00Z'), title: 'old' }) + const newer = makeEvent({ createdAt: new Date('2026-06-02T00:00:00Z'), title: 'new' }) + + store.upsertEvent(older) + store.upsertEvent(newer) + + expect(store.events).toHaveLength(1) + expect(store.getEventById('d-tag-1')?.title).toBe('new') + }) + + it('ignores an older version of the same coordinate', () => { + const store = useEventsStore() + const newer = makeEvent({ createdAt: new Date('2026-06-02T00:00:00Z'), title: 'new' }) + const older = makeEvent({ createdAt: new Date('2026-06-01T00:00:00Z'), title: 'old' }) + + store.upsertEvent(newer) + store.upsertEvent(older) + + expect(store.events).toHaveLength(1) + expect(store.getEventById('d-tag-1')?.title).toBe('new') + }) + + it('does NOT let a different author overwrite a same-d-tag event (cross-author hijack)', () => { + const store = useEventsStore() + const legit = makeEvent({ + id: 'concert', + organizer: { pubkey: 'alice' }, + title: 'Alice concert', + createdAt: new Date('2026-06-01T00:00:00Z'), + }) + // Mallory republishes the same d-tag with a newer created_at — must + // NOT clobber Alice's event; both are kept under their own coordinate. + const impostor = makeEvent({ + id: 'concert', + organizer: { pubkey: 'mallory' }, + title: 'Mallory hijack', + createdAt: new Date('2026-06-10T00:00:00Z'), + }) + + store.upsertEvent(legit) + store.upsertEvent(impostor) + + expect(store.events).toHaveLength(2) + expect(store.getByCoordinate('31923:alice:concert')?.title).toBe('Alice concert') + expect(store.getByCoordinate('31923:mallory:concert')?.title).toBe('Mallory hijack') + }) +}) + +describe('useEventsStore lookups & removal', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('getEventById resolves by d-tag (route identifier)', () => { + const store = useEventsStore() + store.upsertEvent(makeEvent({ id: 'party', organizer: { pubkey: 'alice' } })) + expect(store.getEventById('party')?.id).toBe('party') + expect(store.getEventById('missing')).toBeUndefined() + }) + + it('getEventById returns the newest when a d-tag is shared across authors', () => { + const store = useEventsStore() + store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'a' }, title: 'older', createdAt: new Date('2026-06-01') })) + store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'b' }, title: 'newer', createdAt: new Date('2026-06-05') })) + expect(store.getEventById('x')?.title).toBe('newer') + }) + + it('removeEvent deletes every coordinate sharing the d-tag', () => { + const store = useEventsStore() + store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'a' } })) + store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'b' } })) + expect(store.events).toHaveLength(2) + store.removeEvent('x') + expect(store.events).toHaveLength(0) + }) +}) diff --git a/src/modules/events/stores/events.ts b/src/modules/events/stores/events.ts index 97ab88b..7a4d437 100644 --- a/src/modules/events/stores/events.ts +++ b/src/modules/events/stores/events.ts @@ -3,12 +3,37 @@ import { ref, computed } from 'vue' import type { Event } from '../types/event' import type { TicketedEvent } from '../types/ticket' +/** NIP-52 calendar event kinds. Date-based = 31922, time-based = 31923. */ +export const EVENT_KIND_DATE = 31922 +export const EVENT_KIND_TIME = 31923 + +/** The NIP-52 kind for an event, derived from its date/time type. */ +export function eventKind(event: Pick): number { + return event.type === 'date' ? EVENT_KIND_DATE : EVENT_KIND_TIME +} + +/** + * Addressable-event coordinate `kind:pubkey:d-tag` (NIP-01 `a` tag form). + * + * NIP-52 calendar events are *addressable* (parameterized-replaceable): + * their d-tag is scoped to the **author**, so the replacement key MUST + * include the pubkey. Keying by the bare d-tag alone lets a different + * author publishing the same d-tag overwrite a legit event in the store. + * This mirrors NDK's `event.coordinate()` and welshman's `eventsByAddress`. + */ +export function eventCoordinate( + event: Pick, +): string { + return `${eventKind(event)}:${event.organizer.pubkey}:${event.id}` +} + /** * Pinia store for cached events from Nostr relays. - * Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag). + * Deduplicates by NIP-52 addressable coordinate (kind:pubkey:d-tag). */ export const useEventsStore = defineStore('events', () => { - // State + // State — keyed by addressable coordinate, NOT bare d-tag, so two + // authors using the same d-tag are stored independently. const eventsMap = ref>(new Map()) const isLoading = ref(false) const lastUpdated = ref(null) @@ -43,14 +68,19 @@ export const useEventsStore = defineStore('events', () => { /** * Add or update an event in the store. - * Deduplicates by id (d-tag). Newer events replace older ones. + * + * Deduplicates by addressable coordinate (kind:pubkey:d-tag). A newer + * version (by `created_at`) replaces an older one *for the same + * coordinate only* — a same-d-tag event from a different author lands + * under its own coordinate and never clobbers another author's event. */ function upsertEvent(event: Event) { - const existing = eventsMap.value.get(event.id) + const key = eventCoordinate(event) + const existing = eventsMap.value.get(key) - // Only update if this is a newer version + // Only update if this is a newer version of the same coordinate. if (!existing || event.createdAt >= existing.createdAt) { - eventsMap.value.set(event.id, event) + eventsMap.value.set(key, event) lastUpdated.value = new Date() } } @@ -65,10 +95,13 @@ export const useEventsStore = defineStore('events', () => { } /** - * Remove an event from the store. + * Remove an event by its d-tag. Deletes every stored coordinate whose + * d-tag matches (normally one — our calendar events are single-publisher). */ function removeEvent(id: string) { - eventsMap.value.delete(id) + for (const [key, event] of eventsMap.value) { + if (event.id === id) eventsMap.value.delete(key) + } } /** @@ -80,10 +113,28 @@ export const useEventsStore = defineStore('events', () => { } /** - * Get a single event by its id (d-tag). + * Get a single event by its full addressable coordinate (kind:pubkey:d-tag). + * The precise, unambiguous lookup. + */ + function getByCoordinate(coordinate: string): Event | undefined { + return eventsMap.value.get(coordinate) + } + + /** + * Get a single event by its d-tag (the route identifier). + * + * Calendar events in this app are single-publisher, so a d-tag resolves + * to one event in practice. If multiple authors ever share a d-tag, the + * newest (by `created_at`) wins — deterministic rather than first-seen. + * Use {@link getByCoordinate} when the author is known. */ function getEventById(id: string): Event | undefined { - return eventsMap.value.get(id) + let match: Event | undefined + for (const event of eventsMap.value.values()) { + if (event.id !== id) continue + if (!match || event.createdAt >= match.createdAt) match = event + } + return match } return { @@ -104,6 +155,7 @@ export const useEventsStore = defineStore('events', () => { upsertEvents, removeEvent, clearAll, + getByCoordinate, getEventById, } })