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 {
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'
+ );
+ });
});