Trust content from followed pubkeys
CI / Lint (pull_request) Successful in 1m18s
CI / Test (pull_request) Successful in 1m35s
Release Drafter / Update release notes draft (pull_request) Successful in 5s

Fetch kind 3 and trust content from any contained pubkeys on all read
relays
This commit is contained in:
2026-08-27 09:48:58 -06:00
parent ac7a4eb262
commit 572213eda2
2 changed files with 163 additions and 0 deletions
+11
View File
@@ -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();
+152
View File
@@ -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'
);
});
});