diff --git a/app/components/app-menu/settings/nostr.gjs b/app/components/app-menu/settings/nostr.gjs index a0b72fc..7e1ffeb 100644 --- a/app/components/app-menu/settings/nostr.gjs +++ b/app/components/app-menu/settings/nostr.gjs @@ -10,6 +10,7 @@ import { excludeRequiredRelays, mergeRequiredRelays, normalizeRelayUrl, + uniqNormalizedRelays, } from '../../../utils/nostr'; import { DEFAULT_BLOSSOM_SERVER } from '../../../services/blossom'; @@ -49,6 +50,10 @@ export default class AppMenuSettingsNostr extends Component { return new Set(this.nostrData.requiredReadRelays.filter(Boolean)); } + get trustedRelaySet() { + return new Set(this.nostrData.trustedRelays.filter(Boolean)); + } + get requiredWriteRelaySet() { return new Set(this.nostrData.requiredWriteRelays.filter(Boolean)); } @@ -78,6 +83,7 @@ export default class AppMenuSettingsNostr extends Component { return { url, isRequired: this.requiredReadRelaySet.has(url), + isTrusted: this.trustedRelaySet.has(url), }; }); } @@ -182,6 +188,26 @@ export default class AppMenuSettingsNostr extends Component { this.settings.update('nostrReadRelayExclusions', null); } + @action + toggleRelayTrust(url) { + const normalized = normalizeRelayUrl(url); + if (!normalized) return; + // Required relays are always trusted and cannot be untrusted. + if (this.requiredReadRelaySet.has(normalized)) return; + + const current = uniqNormalizedRelays( + this.settings.nostrTrustedRelays || [] + ); + const idx = current.indexOf(normalized); + let next; + if (idx >= 0) { + next = current.filter((r) => r !== normalized); + } else { + next = [...current, normalized]; + } + this.settings.update('nostrTrustedRelays', next.length > 0 ? next : null); + } + @action addWriteRelay() { const url = normalizeRelayUrl(this.newWriteRelay); @@ -260,21 +286,51 @@ export default class AppMenuSettingsNostr extends Component {
+

Mark relays as trusted to show photos and + reviews from them. Required relays are always trusted.

@@ -311,17 +367,22 @@ export default class AppMenuSettingsNostr extends Component { {{#each this.writeRelaysForDisplay as |relay|}}
  • {{stripProtocol relay.url}} - {{#unless relay.isRequired}} +
    - {{/unless}} +
  • {{/each}} diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index 7dfe87c..98cdb72 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -113,6 +113,17 @@ export default class PlaceDetails extends Component { })); } + get hasUntrustedContent() { + return ( + this.nostrData.untrustedContentCount > 0 && + !this.nostrData.showUntrustedContent + ); + } + + get hasMultipleUntrusted() { + return this.nostrData.untrustedContentCount > 1; + } + @action startEditing() { if (!this.isSaved) return; // Only allow editing saved places @@ -618,6 +629,23 @@ export default class PlaceDetails extends Component {

    + {{#if this.hasUntrustedContent}} +

    + + + + +

    + {{/if}}
    {{/if}}
    diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index 7df69be..45f6784 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -1,17 +1,20 @@ import Service, { service } from '@ember/service'; +import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; -import { EMPTY, from } from 'rxjs'; +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 { 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'; import { NostrIDB, openDB } from 'nostr-idb'; import { excludeRequiredRelays, mergeRequiredRelays, normalizeRelayUrl, + relayUrlMatches, uniqNormalizedRelays, } from '../utils/nostr'; import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage'; @@ -24,12 +27,30 @@ const DIRECTORY_RELAYS = [ ]; const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org']; -const DEFAULT_WRITE_RELAYS = []; +const DEFAULT_WRITE_RELAYS = []; // TODO nostr.kosmos.org/marco + +// Content event kinds subject to relay-trust filtering. Adding `30360` +// (place reviews) here is the only change needed to extend the feature. +const TRUSTED_CONTENT_KINDS = [360]; + +// localforage store name for persisted relay provenance. +const PROVENANCE_STORE = 'event-relay-provenance'; + +// Replicates applesauce-relay's `completeWhen`: completes the source when the +// `operator` emits a truthy value. `completeWhen` is used internally by +// RelayGroup but not re-exported, so we recreate it from rxjs primitives. +function completeWhen(operator) { + return connect((shared$) => { + const complete$ = shared$.pipe(operator, filter(Boolean), take(1)); + return shared$.pipe(takeUntil(complete$)); + }); +} export default class NostrDataService extends Service { @service nostrRelay; @service nostrAuth; @service settings; + @service localForage; store = new EventStore(); @@ -41,11 +62,26 @@ export default class NostrDataService extends Service { @tracked profiles = {}; @tracked zapReceipts = {}; + // Relay-provenance trust state. `_eventRelays` maps eventId -> Set + // and accumulates every relay an event was seen on (unconditionally, so an + // event first seen on an untrusted relay can be upgraded to trusted when it + // later arrives from a trusted one). Hydrated from IDB at init so cached + // events render with correct trust state instantly. + _eventRelays = new Map(); + _provenanceReady = null; + + // Session-only reveal toggle for untrusted content. Not persisted. + @tracked showUntrustedContent = false; + // Count of currently-hidden (untrusted) place photos for the selected place. + @tracked untrustedContentCount = 0; + _allPlacePhotos = []; + _profileSub = null; _mailboxesSub = null; _blossomSub = null; _photosSub = null; _contributionsSub = null; + _deletionsSub = null; _profileModelSubs = new Map(); _zapReceiptsSub = null; @@ -109,6 +145,25 @@ export default class NostrDataService extends Service { ); }); + // Hydrate relay provenance from IDB so cached events can be trust-checked + // 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]); + } + } + } + }); + // Feed events from the relay pool into the event store this.nostrRelay.pool.relays$.subscribe(() => { // Setup relay subscription tracking if needed, or we just rely on request() @@ -174,6 +229,130 @@ export default class NostrDataService extends Service { ); } + // Relays treated as "moderated" for content-trust filtering. Always includes + // the required read relays (e.g. nostr.kosmos.org); `nostrTrustedRelays` + // adds user-marked custom relays on top. Default (null) = required relays + // only, so the app only surfaces content verified by those relays. + get trustedRelays() { + const configured = this.settings.nostrTrustedRelays || []; + return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]); + } + + /** + * Returns true if an event should be considered trusted: + * - authored by the connected user (own uploads), OR + * - seen on at least one trusted (moderated) relay. + */ + isTrustedEvent(event) { + if (!event) return false; + const myPubkey = this.nostrAuth?.pubkey; + if (myPubkey && event.pubkey === myPubkey) return true; + + const relays = this._eventRelays.get(event.id); + if (!relays || relays.size === 0) return false; + const trusted = this.trustedRelays; + for (const url of relays) { + if (relayUrlMatches(url, trusted)) return true; + } + return false; + } + + /** + * Splits content events into trusted / untrusted buckets. Non-content kinds + * (e.g. kind 5 deletions) are always treated as trusted so they pass through. + */ + partitionByTrust(events) { + const trusted = []; + const untrusted = []; + for (const event of events) { + if ( + !TRUSTED_CONTENT_KINDS.includes(event.kind) || + this.isTrustedEvent(event) + ) { + trusted.push(event); + } else { + untrusted.push(event); + } + } + return { trusted, untrusted }; + } + + // Hydrate the in-memory provenance map from the IDB store. + async _hydrateProvenance() { + try { + await this.localForage.iterate(PROVENANCE_STORE, (value, key) => { + this._eventRelays.set(key, new Set(value || [])); + }); + } catch (e) { + console.debug('[nostr-data] Failed to hydrate relay provenance', e); + } + } + + // Record that `eventId` was seen on `relayUrl`. Merges into the existing + // set (accumulates across sightings) and persists fire-and-forget. + _recordProvenance(eventId, relayUrl) { + if (!eventId || !relayUrl) return; + let set = this._eventRelays.get(eventId); + if (!set) { + set = new Set(); + this._eventRelays.set(eventId, set); + } + if (set.has(relayUrl)) return; + set.add(relayUrl); + const urls = Array.from(set); + // Fire-and-forget persistence; never blocks the render path. + this.localForage.set(PROVENANCE_STORE, eventId, urls).catch((e) => { + console.debug('[nostr-data] Failed to persist relay provenance', e); + }); + } + + // Remove provenance for a deleted event (called on kind-5 processing). + _removeProvenance(eventId) { + if (!eventId) return; + if (!this._eventRelays.delete(eventId)) return; + this.localForage.remove(PROVENANCE_STORE, eventId).catch((e) => { + console.debug('[nostr-data] Failed to remove relay provenance', e); + }); + } + + /** + * Request content events from relays while capturing full provenance. + * + * This mirrors `RelayGroup.request()` completion semantics (wait for the + * first relay EOSE + 5s grace period, or all relays EOSE; 30s hard + * timeout) but uses `pool.req()` so each `EVENT` message retains its + * `from` relay URL. Every sighting is recorded in the provenance map + * (unconditionally, including duplicates) so an event first seen on an + * untrusted relay can be upgraded to trusted when it later arrives from + * a trusted one. Events are always added to the store regardless of trust + * status; filtering happens at presentation time. + */ + _requestContentWithProvenance(relays, filters, errorLabel) { + const complete = RelayGroup.completeOnAny( + RelayGroup.completeAfterFirstRelay(5_000), + RelayGroup.completeOnAllEose() + ); + return this.nostrRelay.pool + .req(relays, filters) + .pipe( + completeWhen(complete), + timeout({ first: 30_000 }), + // Only EVENT messages carry content; ignore OPEN/EOSE/CLOSED/ERROR. + // We deliberately do NOT dedupe so we see every relay's copy and can + // accumulate full provenance across relays. + filter((message) => message.type === 'EVENT') + ) + .subscribe({ + next: (message) => { + this._recordProvenance(message.event.id, message.from); + this.store.add(message.event); + }, + error: (err) => { + console.error(errorLabel, err); + }, + }); + } + async loadPlacesInBounds(bbox) { const requiredPrefixes = getGeohashPrefixesInBbox(bbox); @@ -212,25 +391,13 @@ export default class NostrDataService extends Service { ); } - // Fire network request for new prefixes - this.nostrRelay.pool - .request(this.activeReadRelays, [ - { - kinds: [360], - '#g': missingPrefixes, - }, - ]) - .subscribe({ - next: (event) => { - this.store.add(event); - }, - error: (err) => { - console.error( - '[nostr-data] Error fetching place photos by geohash:', - err - ); - }, - }); + // Fire network request for new prefixes (captures relay provenance for + // trust filtering; events are added to the store regardless of trust). + this._requestContentWithProvenance( + this.activeReadRelays, + [{ kinds: [360], '#g': missingPrefixes }], + '[nostr-data] Error fetching place photos by geohash:' + ); for (const p of missingPrefixes) { this.loadedGeohashPrefixes.add(p); @@ -277,7 +444,8 @@ export default class NostrDataService extends Service { }, ]) .subscribe((events) => { - this.placePhotos = events; + this._allPlacePhotos = events; + this._updatePlacePhotos(); const pubkeys = [...new Set(events.map((e) => e.pubkey))]; this.loadProfiles(pubkeys); this._scheduleZapReceiptRefresh(events); @@ -305,25 +473,28 @@ export default class NostrDataService extends Service { ); } - // Fire network request specifically for this place - this.nostrRelay.pool - .request(this.activeReadRelays, [ - { - kinds: [360, 5], - '#i': [entityId], - }, - ]) - .subscribe({ - next: (event) => { - this.store.add(event); - }, - error: (err) => { - console.error( - '[nostr-data] Error fetching place photos for place:', - err - ); - }, - }); + // Fire network request specifically for this place (captures provenance). + this._requestContentWithProvenance( + this.activeReadRelays, + [{ kinds: [360, 5], '#i': [entityId] }], + '[nostr-data] Error fetching place photos for place:' + ); + } + + // Recompute `placePhotos` from `_allPlacePhotos` applying the trust filter + // and the session-only reveal toggle. Called whenever the timeline emits or + // the user flips `showUntrustedContent`. + _updatePlacePhotos() { + const events = this._allPlacePhotos; + const { trusted, untrusted } = this.partitionByTrust(events); + this.untrustedContentCount = untrusted.length; + this.placePhotos = this.showUntrustedContent ? events : trusted; + } + + @action + toggleShowUntrustedContent() { + this.showUntrustedContent = !this.showUntrustedContent; + this._updatePlacePhotos(); } async loadMyContributions(pubkey) { @@ -363,18 +534,13 @@ export default class NostrDataService extends Service { ); } - // 3. Request fresh events from the network in the background - this.nostrRelay.pool.request(this.activeReadRelays, filters).subscribe({ - next: (event) => { - this.store.add(event); - }, - error: (err) => { - console.error( - '[nostr-data] Error fetching my contribution events:', - err - ); - }, - }); + // 3. Request fresh events from the network in the background (captures + // provenance; own-author events bypass trust filtering anyway). + this._requestContentWithProvenance( + this.activeReadRelays, + filters, + '[nostr-data] Error fetching my contribution events:' + ); } loadProfiles(pubkeys) { diff --git a/app/services/settings.js b/app/services/settings.js index 4249307..de85849 100644 --- a/app/services/settings.js +++ b/app/services/settings.js @@ -14,6 +14,10 @@ const DEFAULT_SETTINGS = { nostrWriteRelays: null, nostrReadRelayExclusions: null, nostrWriteRelayExclusions: null, + // Relays treated as "moderated" for content-trust filtering. When null, + // the app falls back to the required read relays (e.g. nostr.kosmos.org), + // so default behavior only surfaces content verified by those relays. + nostrTrustedRelays: null, experimentalEnablePhotoDeletion: false, }; @@ -30,6 +34,7 @@ export default class SettingsService extends Service { @tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions; @tracked nostrWriteRelayExclusions = DEFAULT_SETTINGS.nostrWriteRelayExclusions; + @tracked nostrTrustedRelays = DEFAULT_SETTINGS.nostrTrustedRelays; @tracked experimentalEnablePhotoDeletion = DEFAULT_SETTINGS.experimentalEnablePhotoDeletion; @@ -123,6 +128,7 @@ export default class SettingsService extends Service { this.nostrWriteRelays = finalSettings.nostrWriteRelays; this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions; this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions; + this.nostrTrustedRelays = finalSettings.nostrTrustedRelays; this.experimentalEnablePhotoDeletion = finalSettings.experimentalEnablePhotoDeletion; @@ -142,6 +148,7 @@ export default class SettingsService extends Service { nostrWriteRelays: this.nostrWriteRelays, nostrReadRelayExclusions: this.nostrReadRelayExclusions, nostrWriteRelayExclusions: this.nostrWriteRelayExclusions, + nostrTrustedRelays: this.nostrTrustedRelays, experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion, }; localStorage.setItem('marco:settings', JSON.stringify(settings)); diff --git a/app/styles/app.css b/app/styles/app.css index e692050..7f35a8a 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -13,6 +13,10 @@ --danger-color: var(--marker-color-primary); --danger-color-dark: var(--marker-color-dark); --default-list-color: #fc3; + --secondary-text-color: #898989; + --border-color: #d8d8d8; + --success-color: #2e7d32; + --secondary-background-color: #f0f0f0; } html, @@ -611,14 +615,28 @@ body { .relay-list li { display: flex; - justify-content: space-between; align-items: center; + gap: 8px; padding: 0.25rem 0; border-radius: 4px; font-size: 0.9rem; word-break: break-all; } +.relay-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + margin-left: auto; +} + +.relay-list-hint { + margin: 0 0 0.5rem; + font-size: 0.8rem; + color: var(--secondary-text-color); +} + .btn-remove-relay { display: flex; align-items: center; @@ -639,12 +657,19 @@ body { stroke: currentcolor; } -.btn-remove-relay:hover, -.btn-remove-relay:active { +.btn-remove-relay:hover:not(:disabled), +.btn-remove-relay:active:not(:disabled) { background-color: var(--danger-color); color: var(--primary-background-color); } +.btn-remove-relay:disabled { + border-color: var(--border-color); + color: var(--secondary-text-color); + cursor: default; + opacity: 0.6; +} + .add-relay-input { display: flex; gap: 0.5rem; @@ -655,6 +680,41 @@ body { font-size: 0.85rem; } +.btn-trust-relay { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--primary-background-color); + border: 1px solid var(--border-color); + color: var(--secondary-text-color); + cursor: pointer; + padding: 0; + transition: all 0.1s ease; + flex-shrink: 0; +} + +.btn-trust-relay svg { + stroke: currentcolor; +} + +.btn-trust-relay.is-trusted { + border-color: var(--success-color); + color: var(--success-color); +} + +.btn-trust-relay:hover, +.btn-trust-relay:active { + background-color: var(--secondary-background-color); +} + +.btn-trust-relay:disabled { + cursor: default; + opacity: 0.6; +} + @keyframes details-slide-down { from { opacity: 0; diff --git a/app/utils/icons.js b/app/utils/icons.js index c212685..3b0884c 100644 --- a/app/utils/icons.js +++ b/app/utils/icons.js @@ -34,6 +34,7 @@ import plus from 'feather-icons/dist/icons/plus.svg?raw'; import search from 'feather-icons/dist/icons/search.svg?raw'; import server from 'feather-icons/dist/icons/server.svg?raw'; import settings from 'feather-icons/dist/icons/settings.svg?raw'; +import shield from 'feather-icons/dist/icons/shield.svg?raw'; import target from 'feather-icons/dist/icons/target.svg?raw'; import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw'; import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw'; @@ -295,6 +296,7 @@ const ICONS = { 'castle-keep': castleKeep, x, zap, + shield, 'loading-ring': loadingRing, }; diff --git a/app/utils/nostr.js b/app/utils/nostr.js index 825af44..abf3fb5 100644 --- a/app/utils/nostr.js +++ b/app/utils/nostr.js @@ -38,6 +38,27 @@ export function excludeRequiredRelays(customRelays = [], requiredRelays = []) { }); } +/** + * Returns true if `relayUrl` matches any entry in `trustedRelays`. + * + * Comparison is performed on normalized relay URLs (lowercased, `wss://` + * scheme ensured, trailing slashes stripped) via `normalizeRelayUrl`, so + * callers may pass raw `wss://` URLs received from relays or bare hostnames. + * + * @param {string} relayUrl The relay URL a content event was seen on + * @param {Array} trustedRelays List of trusted relay URLs + * @returns {boolean} + */ +export function relayUrlMatches(relayUrl, trustedRelays = []) { + if (!relayUrl) return false; + const target = normalizeRelayUrl(relayUrl); + if (!target) return false; + const trustedSet = new Set( + uniqNormalizedRelays(trustedRelays || []).filter(Boolean) + ); + return trustedSet.has(target); +} + /** * Extracts and normalizes photo data from NIP-360 (Place Photos) events. * Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0. diff --git a/doc/nostr/relay-trust.md b/doc/nostr/relay-trust.md new file mode 100644 index 0000000..0c621fb --- /dev/null +++ b/doc/nostr/relay-trust.md @@ -0,0 +1,71 @@ +# Relay Trust in Marco + +## Purpose + +Marco fetches Nostr content (currently kind 360 place photos, later kind 30360 reviews) from the user's read relays. Because anyone can run a relay and publish spam, Marco only displays content by default when it was seen on a **trusted relay** — a relay the user (or Marco's defaults) treats as sufficiently moderated. + +## Trust Model + +| Rule | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------ | +| **Required relays** | Always trusted (e.g. `wss://nostr.kosmos.org`). Cannot be untrusted. | +| **Custom relays** | Untrusted by default. Users toggle them to trusted in **Settings → Nostr → Read Relays**. | +| **Own content** | Events authored by the connected user's pubkey are always trusted. | +| **Non-content kinds** | Kind 5 deletions and metadata always pass through unfiltered. | +| **Provenance upgrades** | If an event is first seen on an untrusted relay and later on a trusted one, it becomes visible. | +| **Hiding, not blocking** | Untrusted content is cached and stored; it is filtered at presentation time so it can be revealed on demand. | + +## How It Works Under the Hood + +### 1. Provenance Capture + +- Instead of `pool.request()` (which drops relay origin via an internal `map(m => m.event)`), Marco uses `RelayPool.req()` from `applesauce-relay`. +- Each `EVENT` message includes `from: relayUrl`. +- Every sighting is recorded in an in-memory map: `eventId → Set`. +- Duplicate deliveries are _not_ deduplicated at the stream level, so full provenance across all responding relays is preserved. + +### 2. Persistence + +- Provenance is persisted to IndexedDB via `localForage` under the store `event-relay-provenance`. +- On app start, it is hydrated into memory so cached photos render instantly with correct trust state before relay connections are established. + +### 3. Trust Evaluation + +- `trustedRelays` = required read relays + `settings.nostrTrustedRelays` (user-marked custom relays). +- An event is trusted if any relay URL in its provenance set matches `trustedRelays` (compared via normalized URLs). +- Trust is evaluated when presenting the photo timeline for a place. + +### 4. Presentation Filtering + +- `NostrDataService.placePhotos` contains only trusted events. +- `untrustedContentCount` tracks how many are hidden. +- A session-only toggle `showUntrustedContent` reveals them via the "Show N hidden photo(s) (untrusted relays)" button in place details. + +### 5. Deletions + +- When a kind 5 deletion event enters the store, provenance entries for the referenced event IDs are removed from memory and IndexedDB. + +## Key Code Paths + +| File | What it does | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `app/services/nostr-data.js` | `_requestContentWithProvenance()`, `isTrustedEvent()`, `partitionByTrust()`, `trustedRelays` getter, provenance hydration/persistence | +| `app/services/settings.js` | `nostrTrustedRelays` setting (null = required relays only) | +| `app/components/app-menu/settings/nostr.gjs` | Per-relay shield toggle in read-relay list | +| `app/components/place-details.gjs` | Hidden-content reveal link in meta section | +| `app/utils/nostr.js` | `relayUrlMatches()` normalization/matching helper | +| `app/utils/icons.js` | `shield` icon used for trusted state | +| `app/styles/app.css` | `.btn-trust-relay` styles in relay settings | + +## Extending the Feature + +- **Add kind 30360 reviews:** Add `30360` to `TRUSTED_CONTENT_KINDS` in `app/services/nostr-data.js`. Reuse `partitionByTrust` in the reviews timeline. +- **Followee trust:** Extend `isTrustedEvent()` to also check `event.pubkey` against a contact/follow list. +- **Spam eviction from cache:** Provenance data already supports this; future work can purge cached events from `nostr-idb` if they only appear on relays marked untrusted. +- **Provenance UI:** The stored `eventId → relayUrls` map can power a "Seen on these relays" tooltip or details pane. + +## Known Limitations / Future Work + +- Provenance store is currently unbounded; add LRU/TTL cleanup. +- Untrusting a relay hides its content immediately, but does not purge the `nostr-idb` event cache. +- Relay settings UI is currently the only way to manage trust; no global "trust all" toggle. diff --git a/tests/integration/components/app-menu/settings/nostr-test.gjs b/tests/integration/components/app-menu/settings/nostr-test.gjs index 6fb0672..83b0c96 100644 --- a/tests/integration/components/app-menu/settings/nostr-test.gjs +++ b/tests/integration/components/app-menu/settings/nostr-test.gjs @@ -59,6 +59,11 @@ class MockNostrDataService extends Service { ); } + get trustedRelays() { + const configured = this.settings.nostrTrustedRelays || []; + return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]); + } + async clearCache() {} } @@ -108,8 +113,17 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) { const requiredRow = rowByText(rows, 'nostr.kosmos.org'); const mailboxRow = rowByText(rows, 'mailbox.example.com'); - assert.dom(requiredRow.querySelector('.btn-remove-relay')).doesNotExist(); - assert.dom(mailboxRow.querySelector('.btn-remove-relay')).exists(); + const requiredRemoveBtn = requiredRow.querySelector('.btn-remove-relay'); + assert.dom(requiredRemoveBtn).exists(); + assert.true(requiredRemoveBtn.disabled, 'required relay cannot be removed'); + assert.strictEqual( + requiredRemoveBtn.title, + 'Required relays cannot be removed' + ); + + const mailboxRemoveBtn = mailboxRow.querySelector('.btn-remove-relay'); + assert.dom(mailboxRemoveBtn).exists(); + assert.false(mailboxRemoveBtn.disabled); }); test('removing mailbox read relay stores exclusion override', async function (assert) { @@ -217,4 +231,55 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) { .dom('#nostr-photo-fallback-uploads') .exists('fallback toggle visible with multiple media servers'); }); + + test('required read relay is trusted and its trust toggle is disabled', async function (assert) { + const element = await renderAndOpenDetails(this); + const requiredRow = rowByText(readRows(element), 'nostr.kosmos.org'); + + const trustBtn = requiredRow.querySelector('.btn-trust-relay'); + assert.dom(trustBtn).exists('trust toggle renders for required relay'); + assert.true(trustBtn.classList.contains('is-trusted')); + assert.true(trustBtn.disabled, 'required relay toggle is disabled'); + }); + + test('toggling trust on a custom read relay marks it trusted', async function (assert) { + this.settings.update('nostrReadRelays', ['wss://custom.example.com']); + + const element = await renderAndOpenDetails(this); + const customRow = rowByText(readRows(element), 'custom.example.com'); + + const trustBtn = customRow.querySelector('.btn-trust-relay'); + assert.false(trustBtn.classList.contains('is-trusted'), 'starts untrusted'); + assert.strictEqual(this.settings.nostrTrustedRelays, null); + + await click(trustBtn); + + assert.deepEqual(this.settings.nostrTrustedRelays, [ + 'wss://custom.example.com', + ]); + const updatedBtn = rowByText( + readRows(element), + 'custom.example.com' + ).querySelector('.btn-trust-relay'); + assert.true(updatedBtn.classList.contains('is-trusted'), 'now trusted'); + }); + + test('toggling trust off removes relay from trusted list', async function (assert) { + this.settings.update('nostrReadRelays', ['wss://custom.example.com']); + this.settings.update('nostrTrustedRelays', ['wss://custom.example.com']); + + const element = await renderAndOpenDetails(this); + const customRow = rowByText(readRows(element), 'custom.example.com'); + const trustBtn = customRow.querySelector('.btn-trust-relay'); + + assert.true(trustBtn.classList.contains('is-trusted')); + + await click(trustBtn); + + assert.strictEqual( + this.settings.nostrTrustedRelays, + null, + 'empty list collapses to null' + ); + }); }); diff --git a/tests/unit/utils/nostr-test.js b/tests/unit/utils/nostr-test.js index dfd770d..2ecd110 100644 --- a/tests/unit/utils/nostr-test.js +++ b/tests/unit/utils/nostr-test.js @@ -4,6 +4,7 @@ import { mergeRequiredRelays, normalizeRelayUrl, parsePlacePhotos, + relayUrlMatches, uniqNormalizedRelays, } from 'marco/utils/nostr'; @@ -324,4 +325,44 @@ module('Unit | Utility | nostr', function () { 'wss://custom.example.com', ]); }); + + test('relayUrlMatches returns true for normalized trusted relays', function (assert) { + const trusted = ['wss://nostr.kosmos.org', 'wss://relay.damus.io']; + + assert.true( + relayUrlMatches('wss://nostr.kosmos.org', trusted), + 'exact match' + ); + assert.true( + relayUrlMatches('wss://nostr.kosmos.org/', trusted), + 'trailing slash normalized' + ); + assert.true( + relayUrlMatches('WSS://Nostr.Kosmos.org/', trusted), + 'case-insensitive' + ); + assert.true( + relayUrlMatches('nostr.kosmos.org', trusted), + 'bare hostname gets wss:// prefix' + ); + }); + + test('relayUrlMatches returns false for non-trusted relays', function (assert) { + const trusted = ['wss://nostr.kosmos.org']; + + assert.false( + relayUrlMatches('wss://spam.example.com', trusted), + 'untrusted relay' + ); + assert.false(relayUrlMatches('', trusted), 'empty url'); + assert.false(relayUrlMatches(null, trusted), 'null url'); + assert.false( + relayUrlMatches('wss://nostr.kosmos.org', []), + 'empty trust list' + ); + assert.false( + relayUrlMatches('wss://nostr.kosmos.org', null), + 'null trust list' + ); + }); });