Fix kind 5 deletion provenance cleanup, add nostr-data tests
Listen to the store's deletes stream instead of a timeline (kind 5 events never enter the event database), and unsubscribe the subscription on destroy. Add unit tests for relay getters, provenance handling, and trust partitioning.
This commit is contained in:
+16
-14
@@ -6,7 +6,7 @@ import { EventStore } from 'applesauce-core/event-store';
|
||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||
import { ContactsModel } from 'applesauce-core/models/contacts';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { isEventPointer, 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';
|
||||
@@ -157,20 +157,17 @@ export default class NostrDataService extends Service {
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
// Centralized kind-5 deletion handling: the store routes kind-5 events to
|
||||
// its DeleteManager (never into the event database), so listen to the
|
||||
// deletion stream rather than a timeline. Drop provenance entries for
|
||||
// deleted events so the trust map and IDB don't accumulate dead entries.
|
||||
this._deletionsSub = this.store.deletes.deleted$.subscribe(
|
||||
({ pointer }) => {
|
||||
if (isEventPointer(pointer)) {
|
||||
this._removeProvenance(pointer.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Feed events from the relay pool into the event store
|
||||
this.nostrRelay.pool.relays$.subscribe(() => {
|
||||
@@ -853,6 +850,11 @@ export default class NostrDataService extends Service {
|
||||
this._cleanupSubscriptions();
|
||||
this._clearProfileSubs();
|
||||
|
||||
if (this._deletionsSub) {
|
||||
this._deletionsSub.unsubscribe();
|
||||
this._deletionsSub = null;
|
||||
}
|
||||
|
||||
if (this._stopPersisting) {
|
||||
this._stopPersisting();
|
||||
}
|
||||
|
||||
@@ -43,6 +43,23 @@ function makePhotoEvent(pubkey, placeId, opts = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeletionEvent(pubkey, eventIds, opts = {}) {
|
||||
const id = opts.id || makeEventId(50);
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
kind: 5,
|
||||
created_at: opts.created_at || 5000,
|
||||
tags: eventIds.map((eid) => ['e', eid]),
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
module('Unit | Service | nostr-data | contacts', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
@@ -307,3 +324,280 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | relays and trust', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
const requestedFilters = [];
|
||||
const reqMessages = new Subject();
|
||||
|
||||
class StubNostrRelayService extends Service {
|
||||
pool = {
|
||||
relays$: new Subject(),
|
||||
request: (_relays, filters) => {
|
||||
requestedFilters.push(...filters);
|
||||
return EMPTY;
|
||||
},
|
||||
req: () => reqMessages,
|
||||
publish: () => Promise.resolve([{ ok: true }]),
|
||||
};
|
||||
}
|
||||
|
||||
this.owner.register('service:nostrRelay', StubNostrRelayService);
|
||||
this.owner.register('service:nostrData', NostrDataService);
|
||||
|
||||
this.requestedFilters = requestedFilters;
|
||||
this.reqMessages = reqMessages;
|
||||
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
service.store.verifyEvent = undefined;
|
||||
});
|
||||
|
||||
hooks.afterEach(async function () {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
await service.clearCache();
|
||||
await service.localForage.clear('event-relay-provenance');
|
||||
});
|
||||
|
||||
// ─── Relay getters ─────────────────────────────────────────────────────────
|
||||
|
||||
test('mailboxReadRelays normalizes mailbox inbox URLs', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(service.mailboxReadRelays, [], 'empty when no mailboxes');
|
||||
|
||||
service.mailboxes = {
|
||||
inboxes: ['WSS://Relay.Example.COM/', 'relay.two.example'],
|
||||
outboxes: [],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
service.mailboxReadRelays,
|
||||
['wss://relay.example.com', 'wss://relay.two.example'],
|
||||
'normalizes and filters invalid URLs'
|
||||
);
|
||||
});
|
||||
|
||||
test('mailboxWriteRelays returns empty array without mailboxes', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(service.mailboxWriteRelays, [], 'empty when no mailboxes');
|
||||
|
||||
service.mailboxes = {
|
||||
inboxes: [],
|
||||
outboxes: ['WSS://Outbox.Example.COM/'],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
service.mailboxWriteRelays,
|
||||
['wss://outbox.example.com'],
|
||||
'normalizes outbox URLs'
|
||||
);
|
||||
});
|
||||
|
||||
test('configuredReadRelays merges mailbox and custom relays with dedupe and exclusions', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.mailboxes = { inboxes: ['wss://a.example'], outboxes: [] };
|
||||
service.settings.nostrReadRelays = ['wss://a.example/', 'wss://b.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredReadRelays,
|
||||
['wss://a.example', 'wss://b.example'],
|
||||
'merges and deduplicates (normalized)'
|
||||
);
|
||||
|
||||
service.settings.nostrReadRelayExclusions = ['wss://a.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredReadRelays,
|
||||
['wss://b.example'],
|
||||
'exclusions remove mailbox relays'
|
||||
);
|
||||
});
|
||||
|
||||
test('configuredWriteRelays merges mailbox outboxes with custom write relays', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.mailboxes = { inboxes: [], outboxes: ['wss://out.example'] };
|
||||
service.settings.nostrWriteRelays = ['wss://custom.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredWriteRelays,
|
||||
['wss://out.example', 'wss://custom.example'],
|
||||
'merges mailbox and custom write relays'
|
||||
);
|
||||
|
||||
service.settings.nostrWriteRelayExclusions = ['wss://out.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredWriteRelays,
|
||||
['wss://custom.example'],
|
||||
'exclusions apply to write relays'
|
||||
);
|
||||
});
|
||||
|
||||
test('activeReadRelays puts required relays first and appends custom', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(
|
||||
service.activeReadRelays,
|
||||
['wss://nostr.kosmos.org'],
|
||||
'default is required only'
|
||||
);
|
||||
|
||||
service.settings.nostrReadRelays = ['wss://custom.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.activeReadRelays,
|
||||
['wss://nostr.kosmos.org', 'wss://custom.example'],
|
||||
'required first, custom appended'
|
||||
);
|
||||
});
|
||||
|
||||
test('activeWriteRelays returns empty when nothing configured', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(
|
||||
service.activeWriteRelays,
|
||||
[],
|
||||
'no required write relays by default'
|
||||
);
|
||||
});
|
||||
|
||||
test('trustedRelays includes required read relays plus user-marked trusted relays', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(
|
||||
service.trustedRelays,
|
||||
['wss://nostr.kosmos.org'],
|
||||
'default is required read relays only'
|
||||
);
|
||||
|
||||
service.settings.nostrTrustedRelays = ['wss://custom.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.trustedRelays,
|
||||
['wss://nostr.kosmos.org', 'wss://custom.example'],
|
||||
'merges custom trusted relays'
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Provenance ────────────────────────────────────────────────────────────
|
||||
|
||||
test('_recordProvenance accumulates relays for an event and persists them', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(900);
|
||||
|
||||
service._recordProvenance(eventId, 'wss://one.example');
|
||||
service._recordProvenance(eventId, 'wss://two.example');
|
||||
service._recordProvenance(eventId, 'wss://one.example'); // duplicate
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.strictEqual(relays.size, 2, 'accumulates unique relays');
|
||||
assert.true(relays.has('wss://one.example'), 'has first relay');
|
||||
assert.true(relays.has('wss://two.example'), 'has second relay');
|
||||
|
||||
const persisted = await service.localForage.get(
|
||||
'event-relay-provenance',
|
||||
eventId
|
||||
);
|
||||
assert.deepEqual(
|
||||
persisted.sort(),
|
||||
['wss://one.example', 'wss://two.example'],
|
||||
'persists to localForage'
|
||||
);
|
||||
});
|
||||
|
||||
test('kind 5 deletion events remove provenance for referenced events', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const deadId = makeEventId(901);
|
||||
const author = makePubkey(50);
|
||||
|
||||
service._recordProvenance(deadId, 'wss://one.example');
|
||||
|
||||
// The store routes kind-5 events to its DeleteManager, which emits
|
||||
// deleted$ synchronously from add() — provenance is dropped immediately.
|
||||
service.store.add(makeDeletionEvent(author, [deadId]));
|
||||
|
||||
assert.false(
|
||||
service._eventRelays.has(deadId),
|
||||
'provenance removed from memory'
|
||||
);
|
||||
const persisted = await service.localForage.get(
|
||||
'event-relay-provenance',
|
||||
deadId
|
||||
);
|
||||
assert.strictEqual(persisted, null, 'provenance removed from localForage');
|
||||
});
|
||||
|
||||
test('_hydrateProvenance restores persisted provenance', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(902);
|
||||
|
||||
await service.localForage.set('event-relay-provenance', eventId, [
|
||||
'wss://x.example',
|
||||
]);
|
||||
|
||||
await service._hydrateProvenance();
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.ok(relays, 'provenance restored');
|
||||
assert.true(relays.has('wss://x.example'), 'contains the relay');
|
||||
});
|
||||
|
||||
test('_requestContentWithProvenance records provenance and adds events to the store', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(903);
|
||||
const pubkey = makePubkey(60);
|
||||
|
||||
service._requestContentWithProvenance(
|
||||
['wss://relay.test'],
|
||||
[{ kinds: [360] }],
|
||||
'test'
|
||||
);
|
||||
|
||||
const photoEvent = makePhotoEvent(pubkey, 'osm:node:1', { id: eventId });
|
||||
this.reqMessages.next({
|
||||
type: 'EVENT',
|
||||
event: photoEvent,
|
||||
from: 'wss://relay.test',
|
||||
});
|
||||
|
||||
await wait(50);
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.ok(relays, 'provenance recorded');
|
||||
assert.true(
|
||||
relays.has('wss://relay.test'),
|
||||
'contains the relay the event came from'
|
||||
);
|
||||
assert.true(service.store.hasEvent(eventId), 'event added to store');
|
||||
});
|
||||
|
||||
// ─── partitionByTrust ──────────────────────────────────────────────────────
|
||||
|
||||
test('partitionByTrust always passes kind 5 deletions through as trusted', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const untrustedPk = makePubkey(70);
|
||||
const authorPk = makePubkey(71);
|
||||
|
||||
const untrustedPhoto = makePhotoEvent(untrustedPk, 'osm:node:1', {
|
||||
id: makeEventId(904),
|
||||
});
|
||||
const deletion = makeDeletionEvent(authorPk, [untrustedPhoto.id], {
|
||||
id: makeEventId(905),
|
||||
});
|
||||
|
||||
const { trusted, untrusted } = service.partitionByTrust([
|
||||
untrustedPhoto,
|
||||
deletion,
|
||||
]);
|
||||
|
||||
assert.strictEqual(trusted.length, 1, 'one trusted event');
|
||||
assert.strictEqual(trusted[0].kind, 5, 'deletion is trusted');
|
||||
assert.strictEqual(untrusted.length, 1, 'one untrusted event');
|
||||
assert.strictEqual(untrusted[0].kind, 360, 'photo is untrusted');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user