Event lists show duplicate cards when a d-tag exists under more than one pubkey #175

Closed
opened 2026-09-27 21:19:41 +00:00 by padreug · 2 comments
Owner

The events store keys by coordinate, and list views iterate the map directly. When the same calendar event has been published under two different author pubkeys, both land in the map and the feed renders it twice — once correct, once stale.

// stores/events.ts:27
return `${eventKind(event)}:${event.organizer.pubkey}:${event.id}`

// stores/events.ts:61
const events = computed(() => Array.from(eventsMap.value.values()))

getEventById (:183) already handles this correctly — it scans for matching d-tags and keeps the newest by createdAt — so the detail page is right while the list is wrong. upsertEvent's reconciliation doesn't help either: it only fires for the draft↔published pair (empty pubkey vs real), and two genuinely published copies are both non-drafts.

Live on aio-demo

Of 24 distinct d-tags on the relay, 13 exist under two pubkeys — 37 calendar events for 24 logical ones:

oJYPLaPXaE4raoswug3xSy  'Cacao Ecstatic Dance'
   1790543828  368d96e54bf5ea87  avail=46     <- current
   1780492940  598b56dfb9481911  avail=46     <- orphaned

PKBVuusKikfJFU4PYGtBTW  'Alpaca Therapy'
   1790543827  2288503f8a03f6f1  avail=0      <- current
   1780492940  212300fa064089dc  avail=None   <- orphaned

Note the second one: the orphaned copy still advertises no tickets_available, which this app renders as "Unlimited tickets". That's precisely the lie aiolabs/events#62 was released to stop — and it is still being told on the feed by the stale copy, because the fix only reaches events the current identity republishes.

Why there are two

The old copies all share created_at 1780492940 — a single bulk publish around early June — and their pubkeys recur across events, so specific organizer accounts were re-keyed at some point. NIP-52 addressable replacement is scoped to (kind, pubkey, d), so a new key means a new coordinate and the old event is orphaned rather than replaced. The relay is behaving correctly.

Fix

Dedupe by d-tag in the list computeds with the same newest-wins rule getEventById already applies:

const events = computed(() => {
  const byDTag = new Map<string, Event>()
  for (const e of eventsMap.value.values()) {
    const seen = byDTag.get(e.id)
    if (!seen || e.createdAt >= seen.createdAt) byDTag.set(e.id, e)
  }
  return Array.from(byDTag.values())
})

upcomingEvents and pastEvents both derive from events, so they inherit it. Worth checking any other direct eventsMap.values() consumer at the same time (:185 is inside getEventById, which is already correct).

Keeping eventsMap keyed by coordinate is right — it is the addressable identity, and collapsing the key would lose the ability to tell the copies apart. The dedupe belongs at the read side.

The orphaned relay events cannot be cleaned up from our side: a NIP-09 delete must be signed by the key that published them, and those accounts no longer hold it (the bunker may, which is worth checking before assuming they are permanent). Raising that separately against the backend — it is the residue of an identity change, and the same thing will happen again on the next re-key unless something deletes or re-signs on transition.

Independent of #143, which is about the value of the total on a single correct event rather than about duplicates.

The events store keys by **coordinate**, and list views iterate the map directly. When the same calendar event has been published under two different author pubkeys, both land in the map and the feed renders it twice — once correct, once stale. ```ts // stores/events.ts:27 return `${eventKind(event)}:${event.organizer.pubkey}:${event.id}` // stores/events.ts:61 const events = computed(() => Array.from(eventsMap.value.values())) ``` `getEventById` (`:183`) already handles this correctly — it scans for matching d-tags and keeps the newest by `createdAt` — so the **detail page is right while the list is wrong**. `upsertEvent`'s reconciliation doesn't help either: it only fires for the draft↔published pair (empty pubkey vs real), and two genuinely published copies are both non-drafts. ## Live on aio-demo Of 24 distinct d-tags on the relay, **13 exist under two pubkeys** — 37 calendar events for 24 logical ones: ``` oJYPLaPXaE4raoswug3xSy 'Cacao Ecstatic Dance' 1790543828 368d96e54bf5ea87 avail=46 <- current 1780492940 598b56dfb9481911 avail=46 <- orphaned PKBVuusKikfJFU4PYGtBTW 'Alpaca Therapy' 1790543827 2288503f8a03f6f1 avail=0 <- current 1780492940 212300fa064089dc avail=None <- orphaned ``` Note the second one: the orphaned copy still advertises **no `tickets_available`**, which this app renders as *"Unlimited tickets"*. That's precisely the lie `aiolabs/events#62` was released to stop — and it is still being told on the feed by the stale copy, because the fix only reaches events the current identity republishes. ## Why there are two The old copies all share `created_at 1780492940` — a single bulk publish around early June — and their pubkeys recur across events, so specific **organizer accounts were re-keyed** at some point. NIP-52 addressable replacement is scoped to `(kind, pubkey, d)`, so a new key means a new coordinate and the old event is orphaned rather than replaced. The relay is behaving correctly. ## Fix Dedupe by d-tag in the list computeds with the same newest-wins rule `getEventById` already applies: ```ts const events = computed(() => { const byDTag = new Map<string, Event>() for (const e of eventsMap.value.values()) { const seen = byDTag.get(e.id) if (!seen || e.createdAt >= seen.createdAt) byDTag.set(e.id, e) } return Array.from(byDTag.values()) }) ``` `upcomingEvents` and `pastEvents` both derive from `events`, so they inherit it. Worth checking any other direct `eventsMap.values()` consumer at the same time (`:185` is inside `getEventById`, which is already correct). Keeping `eventsMap` keyed by coordinate is right — it is the addressable identity, and collapsing the key would lose the ability to tell the copies apart. The dedupe belongs at the read side. ## Related, not fixed here The orphaned relay events cannot be cleaned up from our side: a NIP-09 delete must be signed by the key that published them, and those accounts no longer hold it (the bunker may, which is worth checking before assuming they are permanent). Raising that separately against the backend — it is the residue of an identity change, and the same thing will happen again on the next re-key unless something deletes or re-signs on transition. Independent of #143, which is about the *value* of the total on a single correct event rather than about duplicates.
Author
Owner

Retracting the proposed fix — it would be a security regression

The dedupe I suggested (by d-tag, newest createdAt wins) is wrong. stores/events.spec.ts:69 already tests the property it would break:

it('does NOT let a different author overwrite a same-d-tag event (cross-author hijack)', () => {
  // Mallory republishes the same d-tag with a newer created_at — must
  // NOT clobber Alice's event; both are kept under their own coordinate.
  ...
  expect(store.events).toHaveLength(2)
})

Keeping both coordinates is deliberate. My fix would have collapsed them and handed the win to whoever published most recently — which is exactly the impostor. I'd have traded a cosmetic duplicate for an event-hijack vector.

The underlying reason is protocol-level: in Nostr the addressable identity is (kind, pubkey, d). The d-tag alone is not an identity. Two same-d-tag events from different pubkeys are genuinely different events, and no client can tell "the organizer rotated keys" from "someone is impersonating them" without an attestation. NIP-41 is the answer, and it doesn't exist yet — the migration script that caused this cited it as what would close the gap in a later phase.

getEventById's newest-wins has the same weakness, incidentally, which its own comment half-acknowledges ("Use getByCoordinate when the author is known"). Not making it worse by spreading that rule to the lists.

The actual fix is at the relay, not the client

These 13 orphans are our own junk on our own relay, not foreign events we have to tolerate. nostrrelay soft-deletes, and its query builder excludes deleted rows from every read:

# nostrrelay/relay/filter.py:77
where = ["deleted=false", "nostrrelay.events.relay_id = :relay_id"]

So marking the 13 orphaned event ids deleted=true on demo's relay removes them at source. No protocol change, no client change, no security property weakened, and it's reversible with one UPDATE.

That also scopes the problem correctly: this is a one-off artifact of the June local→bunker migration on a single staging instance. The other hosts never ran it and have no orphans. Building client-side machinery for it would be solving a recurring problem we don't have.

Proposed close

Prune demo's relay, leave the store semantics alone, and close this as not-a-client-bug. The genuinely useful residue is on aiolabs/lnbits#57: repairs must preserve identity, because provision() re-keying an account is what creates orphans in the first place.

## Retracting the proposed fix — it would be a security regression The dedupe I suggested (by d-tag, newest `createdAt` wins) is wrong. `stores/events.spec.ts:69` already tests the property it would break: ```ts it('does NOT let a different author overwrite a same-d-tag event (cross-author hijack)', () => { // Mallory republishes the same d-tag with a newer created_at — must // NOT clobber Alice's event; both are kept under their own coordinate. ... expect(store.events).toHaveLength(2) }) ``` Keeping both coordinates is **deliberate**. My fix would have collapsed them and handed the win to whoever published most recently — which is exactly the impostor. I'd have traded a cosmetic duplicate for an event-hijack vector. The underlying reason is protocol-level: in Nostr the addressable identity is `(kind, pubkey, d)`. **The d-tag alone is not an identity.** Two same-d-tag events from different pubkeys are genuinely different events, and no client can tell "the organizer rotated keys" from "someone is impersonating them" without an attestation. NIP-41 is the answer, and it doesn't exist yet — the migration script that caused this cited it as what would close the gap in a later phase. `getEventById`'s newest-wins has the same weakness, incidentally, which its own comment half-acknowledges ("Use `getByCoordinate` when the author is known"). Not making it worse by spreading that rule to the lists. ## The actual fix is at the relay, not the client These 13 orphans are **our own junk on our own relay**, not foreign events we have to tolerate. `nostrrelay` soft-deletes, and its query builder excludes deleted rows from every read: ```python # nostrrelay/relay/filter.py:77 where = ["deleted=false", "nostrrelay.events.relay_id = :relay_id"] ``` So marking the 13 orphaned event ids `deleted=true` on demo's relay removes them at source. No protocol change, no client change, no security property weakened, and it's reversible with one `UPDATE`. That also scopes the problem correctly: this is a one-off artifact of the June local→bunker migration on a single staging instance. The other hosts never ran it and have no orphans. Building client-side machinery for it would be solving a recurring problem we don't have. ## Proposed close Prune demo's relay, leave the store semantics alone, and close this as not-a-client-bug. The genuinely useful residue is on `aiolabs/lnbits#57`: repairs must preserve identity, because `provision()` re-keying an account is what creates orphans in the first place.
Author
Owner

Closing — resolved at the relay, no client change warranted.

Pruned demo's relay: 13 superseded events marked deleted=true, so 37 → 24 calendar events, 24 distinct d-tags, zero orphans. Each event now resolves to exactly one copy under its current identity, and "Alpaca Therapy" publishes avail=0 rather than an absent tag.

The prune only touched an event when its pubkey was not a current account and the same d-tag existed under one that was — so genuine third-party events with unrecognised pubkeys were left alone. DB backed up to /root/ext_nostrrelay.sqlite3.pre-prune, ids in /root/relay-prune-backup.txt, reversible with a single UPDATE.

Store semantics deliberately unchanged: keeping both coordinates is the anti-hijack property tested at stores/events.spec.ts:69, and the dedupe I originally proposed would have handed the win to the later publisher.

The durable lesson is recorded on aiolabs/lnbits#57 — repairs must preserve identity, because provision() re-keying an account is what produces orphans, and that is the live path now that the one-off migration is done.

Closing — resolved at the relay, no client change warranted. Pruned demo's relay: 13 superseded events marked `deleted=true`, so **37 → 24 calendar events, 24 distinct d-tags, zero orphans**. Each event now resolves to exactly one copy under its current identity, and "Alpaca Therapy" publishes `avail=0` rather than an absent tag. The prune only touched an event when its pubkey was not a current account **and** the same d-tag existed under one that was — so genuine third-party events with unrecognised pubkeys were left alone. DB backed up to `/root/ext_nostrrelay.sqlite3.pre-prune`, ids in `/root/relay-prune-backup.txt`, reversible with a single `UPDATE`. Store semantics deliberately unchanged: keeping both coordinates is the anti-hijack property tested at `stores/events.spec.ts:69`, and the dedupe I originally proposed would have handed the win to the later publisher. The durable lesson is recorded on `aiolabs/lnbits#57` — repairs must preserve identity, because `provision()` re-keying an account is what produces orphans, and that is the live path now that the one-off migration is done.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/webapp#175
No description provided.