From ac7a4eb262257867a8c5d26b7964cb3dbb69b98c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 27 Aug 2026 08:40:21 -0600 Subject: [PATCH 1/2] Fetch kind 3 follow list for connected user --- app/services/nostr-data.js | 21 +++- tests/unit/services/nostr-data-test.js | 157 +++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 tests/unit/services/nostr-data-test.js diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index 45f6784..5d47d01 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -5,6 +5,7 @@ import { EMPTY, from, timeout, filter, connect, take, takeUntil } from 'rxjs'; 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 { persistEventsToCache } from 'applesauce-core/helpers/event-cache'; import { createEventLoaderForStore } from 'applesauce-loaders/loaders'; @@ -56,6 +57,7 @@ export default class NostrDataService extends Service { @tracked profile = null; @tracked mailboxes = null; + @tracked contacts = null; @tracked blossomServers = []; @tracked placePhotos = []; @tracked myContributionEvents = []; @@ -78,6 +80,7 @@ export default class NostrDataService extends Service { _profileSub = null; _mailboxesSub = null; + _contactsSub = null; _blossomSub = null; _photosSub = null; _contributionsSub = null; @@ -123,10 +126,11 @@ export default class NostrDataService extends Service { this._stopPersisting = persistEventsToCache( this.store, async (events) => { - // Only cache profiles, mailboxes, blossom servers, and place photos, and deletions + // Only cache profiles, mailboxes, contacts, blossom servers, place photos, and deletions const toCache = events.filter( (e) => e.kind === 0 || + e.kind === 3 || e.kind === 5 || e.kind === 10002 || e.kind === 10063 || @@ -576,6 +580,7 @@ export default class NostrDataService extends Service { // Reset state this.profile = null; this.mailboxes = null; + this.contacts = null; this.blossomServers = []; this._cleanupSubscriptions(); @@ -594,6 +599,12 @@ export default class NostrDataService extends Service { this.mailboxes = mailboxesData; }); + this._contactsSub = this.store + .model(ContactsModel, pubkey) + .subscribe((contacts) => { + this.contacts = contacts; + }); + this._blossomSub = this.store .replaceable(10063, pubkey) .subscribe((event) => { @@ -623,7 +634,7 @@ export default class NostrDataService extends Service { const cachedEvents = await this.cache.query([ { authors: [pubkey], - kinds: [0, 10002, 10063], + kinds: [0, 3, 10002, 10063], }, ]); @@ -644,7 +655,7 @@ export default class NostrDataService extends Service { .request(profileRelays, [ { authors: [pubkey], - kinds: [0, 10002, 10063], + kinds: [0, 3, 10002, 10063], }, ]) .subscribe({ @@ -806,6 +817,10 @@ export default class NostrDataService extends Service { this._mailboxesSub.unsubscribe(); this._mailboxesSub = null; } + if (this._contactsSub) { + this._contactsSub.unsubscribe(); + this._contactsSub = null; + } if (this._blossomSub) { this._blossomSub.unsubscribe(); this._blossomSub = null; diff --git a/tests/unit/services/nostr-data-test.js b/tests/unit/services/nostr-data-test.js new file mode 100644 index 0000000..1e1f26c --- /dev/null +++ b/tests/unit/services/nostr-data-test.js @@ -0,0 +1,157 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'marco/tests/helpers'; +import { Subject, EMPTY } from 'rxjs'; +import Service from '@ember/service'; +import NostrDataService from 'marco/services/nostr-data'; + +function makePubkey(n) { + return n.toString(16).padStart(64, '0'); +} + +function makeEventId(n) { + return `e${n.toString(16).padStart(63, '0')}`; +} + +function makeContactsEvent(pubkey, contactPubkeys, opts = {}) { + const id = opts.id || makeEventId(1); + const createdAt = opts.created_at || 1000; + return { + id, + pubkey, + kind: 3, + created_at: createdAt, + tags: contactPubkeys.map((pk) => ['p', pk, 'wss://relay.example']), + content: '', + sig: 'sig', + }; +} + +module('Unit | Service | nostr-data | contacts', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.requestedFilters = []; + const requestedFilters = this.requestedFilters; + + class StubNostrRelayService extends Service { + pool = { + relays$: new Subject(), + request: (_relays, filters) => { + requestedFilters.push(...filters); + return EMPTY; + }, + req: () => EMPTY, + publish: () => Promise.resolve([{ ok: true }]), + }; + } + + this.owner.register('service:nostrRelay', StubNostrRelayService); + this.owner.register('service:nostrData', NostrDataService); + }); + + test('loadProfile populates contacts from store via ContactsModel', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + + const pubkey = makePubkey(1); + const contactA = makePubkey(2); + const contactB = makePubkey(3); + + service.store.add(makeContactsEvent(pubkey, [contactA, contactB])); + + await service.loadProfile(pubkey); + + assert.ok(service.contacts, 'contacts is populated'); + assert.strictEqual(service.contacts.length, 2, 'two contacts'); + const pubkeys = service.contacts.map((c) => c.pubkey).sort(); + assert.deepEqual( + pubkeys, + [contactA, contactB].sort(), + 'contact pubkeys match' + ); + }); + + test('loadProfile tears down previous contacts subscription when called with a different pubkey', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + + const pubkeyA = makePubkey(1); + const pubkeyB = makePubkey(4); + const contactA = makePubkey(2); + + service.store.add(makeContactsEvent(pubkeyA, [contactA])); + await service.loadProfile(pubkeyA); + + assert.strictEqual( + service.contacts.length, + 1, + 'contacts populated for pubkeyA' + ); + + // Switch to a different pubkey — this should tear down pubkeyA's subscription + await service.loadProfile(pubkeyB); + + assert.deepEqual( + service.contacts, + [], + 'contacts is empty for pubkeyB (no kind 3 event)' + ); + + // Add a new contacts event for pubkeyA after the switch + const newerEvent = makeContactsEvent(pubkeyA, [makePubkey(9)], { + created_at: 2000, + }); + service.store.add(newerEvent); + + // Give the subscription a tick to propagate + await new Promise((r) => setTimeout(r, 50)); + + assert.deepEqual( + service.contacts, + [], + 'contacts remains empty — old subscription was torn down' + ); + }); + + test('loadProfile network request includes kind 3', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + + const pubkey = makePubkey(1); + await service.loadProfile(pubkey); + + const kindsFilter = this.requestedFilters.find((f) => + f.authors?.includes(pubkey) + ); + assert.ok(kindsFilter, 'filter with authors was requested'); + assert.ok(kindsFilter.kinds.includes(3), 'kinds includes 3 (contacts)'); + assert.deepEqual( + kindsFilter.kinds.sort(), + [0, 3, 10002, 10063].sort(), + 'kinds match expected set' + ); + }); + + test('kind 3 events are persisted to IDB cache', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + + // Wait for the IDB cache to be ready before adding events + await service._cachePromise; + + const pubkey = makePubkey(1); + const contactA = makePubkey(2); + const event = makeContactsEvent(pubkey, [contactA], { id: makeEventId(5) }); + + service.store.add(event); + + // Wait for the persistEventsToCache batch (1s) plus some margin + await new Promise((r) => setTimeout(r, 1500)); + + const cached = await service.cache.query([ + { kinds: [3], authors: [pubkey] }, + ]); + assert.strictEqual(cached.length, 1, 'kind 3 event is in IDB cache'); + assert.strictEqual(cached[0].id, event.id, 'cached event id matches'); + }); +}); -- 2.50.1 From 572213eda2119e39bf28d47a85d9bb01b6fd8b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 27 Aug 2026 09:48:58 -0600 Subject: [PATCH 2/2] Trust content from followed pubkeys Fetch kind 3 and trust content from any contained pubkeys on all read relays --- app/services/nostr-data.js | 11 ++ tests/unit/services/nostr-data-test.js | 152 +++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index 5d47d01..d78ee4d 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -72,6 +72,10 @@ export default class NostrDataService extends Service { _eventRelays = new Map(); _provenanceReady = null; + // Set of pubkeys from the user's follow list (kind 3 contacts) for O(1) + // trust lookups. Rebuilt whenever contacts change. + _contactPubkeys = null; + // Session-only reveal toggle for untrusted content. Not persisted. @tracked showUntrustedContent = false; // Count of currently-hidden (untrusted) place photos for the selected place. @@ -245,6 +249,7 @@ export default class NostrDataService extends Service { /** * Returns true if an event should be considered trusted: * - authored by the connected user (own uploads), OR + * - authored by a pubkey the user follows (kind 3 contacts), OR * - seen on at least one trusted (moderated) relay. */ isTrustedEvent(event) { @@ -252,6 +257,8 @@ export default class NostrDataService extends Service { const myPubkey = this.nostrAuth?.pubkey; if (myPubkey && event.pubkey === myPubkey) return true; + if (this._contactPubkeys?.has(event.pubkey)) return true; + const relays = this._eventRelays.get(event.id); if (!relays || relays.size === 0) return false; const trusted = this.trustedRelays; @@ -581,6 +588,7 @@ export default class NostrDataService extends Service { this.profile = null; this.mailboxes = null; this.contacts = null; + this._contactPubkeys = null; this.blossomServers = []; this._cleanupSubscriptions(); @@ -603,6 +611,8 @@ export default class NostrDataService extends Service { .model(ContactsModel, pubkey) .subscribe((contacts) => { this.contacts = contacts; + this._contactPubkeys = new Set(contacts.map((c) => c.pubkey)); + this._updatePlacePhotos(); }); this._blossomSub = this.store @@ -820,6 +830,7 @@ export default class NostrDataService extends Service { if (this._contactsSub) { this._contactsSub.unsubscribe(); this._contactsSub = null; + this._contactPubkeys = null; } if (this._blossomSub) { this._blossomSub.unsubscribe(); diff --git a/tests/unit/services/nostr-data-test.js b/tests/unit/services/nostr-data-test.js index 1e1f26c..d7eeed8 100644 --- a/tests/unit/services/nostr-data-test.js +++ b/tests/unit/services/nostr-data-test.js @@ -26,6 +26,23 @@ function makeContactsEvent(pubkey, contactPubkeys, opts = {}) { }; } +function makePhotoEvent(pubkey, placeId, opts = {}) { + const id = opts.id || makeEventId(100); + const createdAt = opts.created_at || 2000; + return { + id, + pubkey, + kind: 360, + created_at: createdAt, + tags: [ + ['i', placeId], + ['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'], + ], + content: '', + sig: 'sig', + }; +} + module('Unit | Service | nostr-data | contacts', function (hooks) { setupTest(hooks); @@ -49,6 +66,12 @@ module('Unit | Service | nostr-data | contacts', function (hooks) { this.owner.register('service:nostrData', NostrDataService); }); + hooks.afterEach(async function () { + // Clear the real IDB cache between tests to prevent cross-test contamination + const service = this.owner.lookup('service:nostr-data'); + await service.clearCache(); + }); + test('loadProfile populates contacts from store via ContactsModel', async function (assert) { const service = this.owner.lookup('service:nostr-data'); service.store.verifyEvent = undefined; @@ -154,4 +177,133 @@ module('Unit | Service | nostr-data | contacts', function (hooks) { assert.strictEqual(cached.length, 1, 'kind 3 event is in IDB cache'); assert.strictEqual(cached[0].id, event.id, 'cached event id matches'); }); + + test('isTrustedEvent trusts content from followed contacts', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + + const userPubkey = makePubkey(1); + const followedPubkey = makePubkey(2); + + // User follows followedPubkey + service.store.add(makeContactsEvent(userPubkey, [followedPubkey])); + await service.loadProfile(userPubkey); + + // Photo from followed contact (no trusted relay provenance) + const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:123', { + id: makeEventId(200), + }); + service.store.add(photoEvent); + + assert.true( + service.isTrustedEvent(photoEvent), + 'photo from followed contact is trusted' + ); + }); + + test('isTrustedEvent does not trust content from unfollowed pubkeys', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + + const userPubkey = makePubkey(1); + const followedPubkey = makePubkey(2); + const unfollowedPubkey = makePubkey(3); + + // User follows followedPubkey (but NOT unfollowedPubkey) + service.store.add(makeContactsEvent(userPubkey, [followedPubkey])); + await service.loadProfile(userPubkey); + + // Photo from unfollowed pubkey (no trusted relay provenance) + const photoEvent = makePhotoEvent(unfollowedPubkey, 'osm:node:123', { + id: makeEventId(201), + }); + service.store.add(photoEvent); + + assert.false( + service.isTrustedEvent(photoEvent), + 'photo from unfollowed pubkey is not trusted' + ); + }); + + test('isTrustedEvent still trusts own content (regression)', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + + const userPubkey = makePubkey(1); + service.nostrAuth = { pubkey: userPubkey }; + + await service.loadProfile(userPubkey); + + // Photo from the user themselves + const photoEvent = makePhotoEvent(userPubkey, 'osm:node:123', { + id: makeEventId(202), + }); + service.store.add(photoEvent); + + assert.true(service.isTrustedEvent(photoEvent), 'own photo is trusted'); + }); + + test('isTrustedEvent still trusts content from trusted relays (regression)', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + + const userPubkey = makePubkey(1); + const randomPubkey = makePubkey(99); + + await service.loadProfile(userPubkey); + + // Photo from random pubkey + const photoEvent = makePhotoEvent(randomPubkey, 'osm:node:123', { + id: makeEventId(203), + }); + service.store.add(photoEvent); + + // Simulate provenance: photo was seen on a trusted relay + const trustedRelay = 'wss://nostr.kosmos.org'; + service._recordProvenance(photoEvent.id, trustedRelay); + + assert.true( + service.isTrustedEvent(photoEvent), + 'photo from trusted relay is trusted' + ); + }); + + test('isTrustedEvent re-evaluates when contacts change', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + + const userPubkey = makePubkey(1); + const followedPubkey = makePubkey(2); + + // Load profile first (no contacts yet) + await service.loadProfile(userPubkey); + + // Create a photo from followedPubkey (who is not yet followed) + const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:456', { + id: makeEventId(204), + }); + + // Photo should be untrusted initially (no contacts loaded yet) + assert.false( + service.isTrustedEvent(photoEvent), + 'photo is untrusted before contacts load' + ); + + // Now load contacts (user follows followedPubkey) + service.store.add( + makeContactsEvent(userPubkey, [followedPubkey], { + id: makeEventId(300), + created_at: 3000, + }) + ); + + // Give the contacts subscription a tick to propagate + await new Promise((r) => setTimeout(r, 50)); + + // Photo should now be trusted (contacts loaded) + assert.true( + service.isTrustedEvent(photoEvent), + 'photo is trusted after contacts load' + ); + }); }); -- 2.50.1