Only trust content from trusted relays by default #85

Merged
raucao merged 2 commits from feature/relay_trust into master 2026-08-27 14:05:12 +00:00
11 changed files with 688 additions and 66 deletions
+75 -7
View File
@@ -10,8 +10,10 @@ import {
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
uniqNormalizedRelays,
} from '../../../utils/nostr';
import { DEFAULT_BLOSSOM_SERVER } from '../../../services/blossom';
import tooltip from '../../../modifiers/tooltip';
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
@@ -49,6 +51,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 +84,7 @@ export default class AppMenuSettingsNostr extends Component {
return {
url,
isRequired: this.requiredReadRelaySet.has(url),
isTrusted: this.trustedRelaySet.has(url),
};
});
}
@@ -182,6 +189,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);
@@ -251,7 +278,7 @@ export default class AppMenuSettingsNostr extends Component {
}
<template>
{{! template-lint-disable no-nested-interactive }}
{{! template-lint-disable no-nested-interactive no-unsupported-role-attributes }}
<details>
<summary>
<Icon @name="zap" @size={{20}} />
@@ -260,21 +287,56 @@ export default class AppMenuSettingsNostr extends Component {
<div class="details-content form-layout">
<div class="form-group">
<label for="new-read-relay">Read Relays</label>
<p class="relay-list-hint">
Mark relays as trusted to show
<strong>all</strong>
photos and reviews from them by default (risk of spam).
</p>
<ul class="relay-list">
{{#each this.readRelaysForDisplay as |relay|}}
<li>
<span>{{stripProtocol relay.url}}</span>
{{#unless relay.isRequired}}
<div class="relay-actions">
<button
type="button"
class="btn-trust-relay {{if relay.isTrusted 'is-trusted'}}"
aria-label="Toggle trust"
aria-description={{if
relay.isRequired
"Required relay — always trusted"
(if
relay.isTrusted
"Trusted: show all content from this relay"
"Untrusted: hide content from outside of my circles"
)
}}
disabled={{relay.isRequired}}
{{tooltip}}
{{on "click" (fn this.toggleRelayTrust relay.url)}}
>
<Icon
@name="shield"
@size={{14}}
@color="currentColor"
@filled={{relay.isTrusted}}
/>
</button>
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
aria-description={{if
relay.isRequired
"Required relays cannot be removed"
"Remove relay"
}}
disabled={{relay.isRequired}}
{{tooltip}}
{{on "click" (fn this.removeReadRelay relay.url)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
{{/unless}}
</div>
</li>
{{/each}}
</ul>
@@ -311,17 +373,23 @@ export default class AppMenuSettingsNostr extends Component {
{{#each this.writeRelaysForDisplay as |relay|}}
<li>
<span>{{stripProtocol relay.url}}</span>
{{#unless relay.isRequired}}
<div class="relay-actions">
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
aria-description={{if
relay.isRequired
"Required relays cannot be removed"
"Remove relay"
}}
disabled={{relay.isRequired}}
{{tooltip}}
{{on "click" (fn this.removeWriteRelay relay.url)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
{{/unless}}
</div>
</li>
{{/each}}
</ul>
+28
View File
@@ -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 {
</button>
</span>
</p>
{{#if this.hasUntrustedContent}}
<p class="content-with-icon">
<Icon @name="shield" />
<span>
<button
type="button"
class="btn-link"
{{on "click" this.nostrData.toggleShowUntrustedContent}}
>
Show
{{this.nostrData.untrustedContentCount}}
hidden photo{{if this.hasMultipleUntrusted "s" ""}}
(untrusted relays)
</button>
</span>
</p>
{{/if}}
</div>
{{/if}}
</div>
+49
View File
@@ -0,0 +1,49 @@
import { modifier } from 'ember-modifier';
export default modifier((element) => {
let tooltipEl = null;
const portal = document.getElementById('modal-portal') || document.body;
const show = () => {
const text = element.getAttribute('aria-description');
if (!text || tooltipEl) return;
tooltipEl = document.createElement('div');
tooltipEl.className = 'tooltip';
tooltipEl.setAttribute('role', 'tooltip');
tooltipEl.textContent = text;
portal.appendChild(tooltipEl);
const rect = element.getBoundingClientRect();
const tipRect = tooltipEl.getBoundingClientRect();
const margin = 6;
let top = rect.top - tipRect.height - margin;
if (top < margin) top = rect.bottom + margin;
let left = rect.left + rect.width / 2 - tipRect.width / 2;
left = Math.max(
margin,
Math.min(left, window.innerWidth - tipRect.width - margin)
);
tooltipEl.style.top = `${top}px`;
tooltipEl.style.left = `${left}px`;
};
const hide = () => {
tooltipEl?.remove();
tooltipEl = null;
};
element.addEventListener('mouseenter', show);
element.addEventListener('mouseleave', hide);
element.addEventListener('focus', show);
element.addEventListener('blur', hide);
return () => {
element.removeEventListener('mouseenter', show);
element.removeEventListener('mouseleave', hide);
element.removeEventListener('focus', show);
element.removeEventListener('blur', hide);
hide();
};
});
+219 -53
View File
@@ -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<relayUrl>
// 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) {
+7
View File
@@ -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));
+86 -3
View File
@@ -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,64 @@ 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;
}
.tooltip {
position: fixed;
padding: 4px 8px;
border-radius: 4px;
background: var(--body-text-color);
color: var(--primary-background-color);
font-size: 0.85rem;
white-space: nowrap;
pointer-events: none;
z-index: 9999;
animation: tooltip-fade-in 0.15s ease;
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes details-slide-down {
from {
opacity: 0;
+2
View File
@@ -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,
};
+21
View File
@@ -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<string>} 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.
+71
View File
@@ -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<relayUrl>`.
- 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.
@@ -1,6 +1,6 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { click, fillIn, render } from '@ember/test-helpers';
import { click, fillIn, render, settled } from '@ember/test-helpers';
import Service, { service } from '@ember/service';
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
import {
@@ -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.getAttribute('aria-description'),
'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,76 @@ 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'
);
});
test('tooltip appears on hover and disappears on mouseleave', async function (assert) {
const element = await renderAndOpenDetails(this);
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
const removeBtn = mailboxRow.querySelector('.btn-remove-relay');
assert.dom('.tooltip', document.body).doesNotExist('no tooltip initially');
removeBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await settled();
assert.dom('.tooltip', document.body).exists('tooltip appears on hover');
assert.dom('.tooltip', document.body).hasText('Remove relay');
removeBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
await settled();
assert
.dom('.tooltip', document.body)
.doesNotExist('tooltip removed on mouseleave');
});
});
+41
View File
@@ -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'
);
});
});