From 2ae9a78f5d928ce3af2a1d2e9ecefd4b42362681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 27 Aug 2026 10:32:35 -0600 Subject: [PATCH] Fix kind 5 deletion provenance cleanup, add nostr-data tests Listen to the store's deletes stream instead of a timeline (kind 5 events never enter the event database), and unsubscribe the subscription on destroy. Add unit tests for relay getters, provenance handling, and trust partitioning. --- app/services/nostr-data.js | 30 +-- tests/unit/services/nostr-data-test.js | 294 +++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 14 deletions(-) diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index d78ee4d..95280d6 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -6,7 +6,7 @@ import { EventStore } from 'applesauce-core/event-store'; import { ProfileModel } from 'applesauce-core/models/profile'; import { MailboxesModel } from 'applesauce-core/models/mailboxes'; import { ContactsModel } from 'applesauce-core/models/contacts'; -import { npubEncode } from 'applesauce-core/helpers/pointers'; +import { isEventPointer, npubEncode } from 'applesauce-core/helpers/pointers'; import { persistEventsToCache } from 'applesauce-core/helpers/event-cache'; import { createEventLoaderForStore } from 'applesauce-loaders/loaders'; import { RelayGroup } from 'applesauce-relay'; @@ -157,20 +157,17 @@ export default class NostrDataService extends Service { // instantly without waiting for relay connections. this._provenanceReady = this._hydrateProvenance(); - // Centralized kind-5 deletion handling: when a deletion event enters the - // store, drop the provenance entries for the events it deletes so the - // trust map and IDB don't accumulate dead entries. - this._deletionsSub = this.store - .timeline([{ kinds: [5] }]) - .subscribe((events) => { - for (const event of events) { - for (const tag of event.tags || []) { - if (tag[0] === 'e' && tag[1]) { - this._removeProvenance(tag[1]); - } - } + // Centralized kind-5 deletion handling: the store routes kind-5 events to + // its DeleteManager (never into the event database), so listen to the + // deletion stream rather than a timeline. Drop provenance entries for + // deleted events so the trust map and IDB don't accumulate dead entries. + this._deletionsSub = this.store.deletes.deleted$.subscribe( + ({ pointer }) => { + if (isEventPointer(pointer)) { + this._removeProvenance(pointer.id); } - }); + } + ); // Feed events from the relay pool into the event store this.nostrRelay.pool.relays$.subscribe(() => { @@ -853,6 +850,11 @@ export default class NostrDataService extends Service { this._cleanupSubscriptions(); this._clearProfileSubs(); + if (this._deletionsSub) { + this._deletionsSub.unsubscribe(); + this._deletionsSub = null; + } + if (this._stopPersisting) { this._stopPersisting(); } diff --git a/tests/unit/services/nostr-data-test.js b/tests/unit/services/nostr-data-test.js index d7eeed8..f7e61e5 100644 --- a/tests/unit/services/nostr-data-test.js +++ b/tests/unit/services/nostr-data-test.js @@ -43,6 +43,23 @@ function makePhotoEvent(pubkey, placeId, opts = {}) { }; } +function makeDeletionEvent(pubkey, eventIds, opts = {}) { + const id = opts.id || makeEventId(50); + return { + id, + pubkey, + kind: 5, + created_at: opts.created_at || 5000, + tags: eventIds.map((eid) => ['e', eid]), + content: '', + sig: 'sig', + }; +} + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + module('Unit | Service | nostr-data | contacts', function (hooks) { setupTest(hooks); @@ -307,3 +324,280 @@ module('Unit | Service | nostr-data | contacts', function (hooks) { ); }); }); + +module('Unit | Service | nostr-data | relays and trust', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + const requestedFilters = []; + const reqMessages = new Subject(); + + class StubNostrRelayService extends Service { + pool = { + relays$: new Subject(), + request: (_relays, filters) => { + requestedFilters.push(...filters); + return EMPTY; + }, + req: () => reqMessages, + publish: () => Promise.resolve([{ ok: true }]), + }; + } + + this.owner.register('service:nostrRelay', StubNostrRelayService); + this.owner.register('service:nostrData', NostrDataService); + + this.requestedFilters = requestedFilters; + this.reqMessages = reqMessages; + + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + }); + + hooks.afterEach(async function () { + const service = this.owner.lookup('service:nostr-data'); + await service.clearCache(); + await service.localForage.clear('event-relay-provenance'); + }); + + // ─── Relay getters ───────────────────────────────────────────────────────── + + test('mailboxReadRelays normalizes mailbox inbox URLs', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + assert.deepEqual(service.mailboxReadRelays, [], 'empty when no mailboxes'); + + service.mailboxes = { + inboxes: ['WSS://Relay.Example.COM/', 'relay.two.example'], + outboxes: [], + }; + + assert.deepEqual( + service.mailboxReadRelays, + ['wss://relay.example.com', 'wss://relay.two.example'], + 'normalizes and filters invalid URLs' + ); + }); + + test('mailboxWriteRelays returns empty array without mailboxes', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + assert.deepEqual(service.mailboxWriteRelays, [], 'empty when no mailboxes'); + + service.mailboxes = { + inboxes: [], + outboxes: ['WSS://Outbox.Example.COM/'], + }; + + assert.deepEqual( + service.mailboxWriteRelays, + ['wss://outbox.example.com'], + 'normalizes outbox URLs' + ); + }); + + test('configuredReadRelays merges mailbox and custom relays with dedupe and exclusions', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.mailboxes = { inboxes: ['wss://a.example'], outboxes: [] }; + service.settings.nostrReadRelays = ['wss://a.example/', 'wss://b.example']; + + assert.deepEqual( + service.configuredReadRelays, + ['wss://a.example', 'wss://b.example'], + 'merges and deduplicates (normalized)' + ); + + service.settings.nostrReadRelayExclusions = ['wss://a.example']; + + assert.deepEqual( + service.configuredReadRelays, + ['wss://b.example'], + 'exclusions remove mailbox relays' + ); + }); + + test('configuredWriteRelays merges mailbox outboxes with custom write relays', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + service.mailboxes = { inboxes: [], outboxes: ['wss://out.example'] }; + service.settings.nostrWriteRelays = ['wss://custom.example']; + + assert.deepEqual( + service.configuredWriteRelays, + ['wss://out.example', 'wss://custom.example'], + 'merges mailbox and custom write relays' + ); + + service.settings.nostrWriteRelayExclusions = ['wss://out.example']; + + assert.deepEqual( + service.configuredWriteRelays, + ['wss://custom.example'], + 'exclusions apply to write relays' + ); + }); + + test('activeReadRelays puts required relays first and appends custom', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + assert.deepEqual( + service.activeReadRelays, + ['wss://nostr.kosmos.org'], + 'default is required only' + ); + + service.settings.nostrReadRelays = ['wss://custom.example']; + + assert.deepEqual( + service.activeReadRelays, + ['wss://nostr.kosmos.org', 'wss://custom.example'], + 'required first, custom appended' + ); + }); + + test('activeWriteRelays returns empty when nothing configured', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + assert.deepEqual( + service.activeWriteRelays, + [], + 'no required write relays by default' + ); + }); + + test('trustedRelays includes required read relays plus user-marked trusted relays', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + assert.deepEqual( + service.trustedRelays, + ['wss://nostr.kosmos.org'], + 'default is required read relays only' + ); + + service.settings.nostrTrustedRelays = ['wss://custom.example']; + + assert.deepEqual( + service.trustedRelays, + ['wss://nostr.kosmos.org', 'wss://custom.example'], + 'merges custom trusted relays' + ); + }); + + // ─── Provenance ──────────────────────────────────────────────────────────── + + test('_recordProvenance accumulates relays for an event and persists them', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + const eventId = makeEventId(900); + + service._recordProvenance(eventId, 'wss://one.example'); + service._recordProvenance(eventId, 'wss://two.example'); + service._recordProvenance(eventId, 'wss://one.example'); // duplicate + + const relays = service._eventRelays.get(eventId); + assert.strictEqual(relays.size, 2, 'accumulates unique relays'); + assert.true(relays.has('wss://one.example'), 'has first relay'); + assert.true(relays.has('wss://two.example'), 'has second relay'); + + const persisted = await service.localForage.get( + 'event-relay-provenance', + eventId + ); + assert.deepEqual( + persisted.sort(), + ['wss://one.example', 'wss://two.example'], + 'persists to localForage' + ); + }); + + test('kind 5 deletion events remove provenance for referenced events', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + const deadId = makeEventId(901); + const author = makePubkey(50); + + service._recordProvenance(deadId, 'wss://one.example'); + + // The store routes kind-5 events to its DeleteManager, which emits + // deleted$ synchronously from add() — provenance is dropped immediately. + service.store.add(makeDeletionEvent(author, [deadId])); + + assert.false( + service._eventRelays.has(deadId), + 'provenance removed from memory' + ); + const persisted = await service.localForage.get( + 'event-relay-provenance', + deadId + ); + assert.strictEqual(persisted, null, 'provenance removed from localForage'); + }); + + test('_hydrateProvenance restores persisted provenance', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + const eventId = makeEventId(902); + + await service.localForage.set('event-relay-provenance', eventId, [ + 'wss://x.example', + ]); + + await service._hydrateProvenance(); + + const relays = service._eventRelays.get(eventId); + assert.ok(relays, 'provenance restored'); + assert.true(relays.has('wss://x.example'), 'contains the relay'); + }); + + test('_requestContentWithProvenance records provenance and adds events to the store', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + const eventId = makeEventId(903); + const pubkey = makePubkey(60); + + service._requestContentWithProvenance( + ['wss://relay.test'], + [{ kinds: [360] }], + 'test' + ); + + const photoEvent = makePhotoEvent(pubkey, 'osm:node:1', { id: eventId }); + this.reqMessages.next({ + type: 'EVENT', + event: photoEvent, + from: 'wss://relay.test', + }); + + await wait(50); + + const relays = service._eventRelays.get(eventId); + assert.ok(relays, 'provenance recorded'); + assert.true( + relays.has('wss://relay.test'), + 'contains the relay the event came from' + ); + assert.true(service.store.hasEvent(eventId), 'event added to store'); + }); + + // ─── partitionByTrust ────────────────────────────────────────────────────── + + test('partitionByTrust always passes kind 5 deletions through as trusted', function (assert) { + const service = this.owner.lookup('service:nostr-data'); + const untrustedPk = makePubkey(70); + const authorPk = makePubkey(71); + + const untrustedPhoto = makePhotoEvent(untrustedPk, 'osm:node:1', { + id: makeEventId(904), + }); + const deletion = makeDeletionEvent(authorPk, [untrustedPhoto.id], { + id: makeEventId(905), + }); + + const { trusted, untrusted } = service.partitionByTrust([ + untrustedPhoto, + deletion, + ]); + + assert.strictEqual(trusted.length, 1, 'one trusted event'); + assert.strictEqual(trusted[0].kind, 5, 'deletion is trusted'); + assert.strictEqual(untrusted.length, 1, 'one untrusted event'); + assert.strictEqual(untrusted[0].kind, 360, 'photo is untrusted'); + }); +});