From e6f4f8d23e3e44b3d8fbbbfbfe6692e8c07e23e4 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 27 Feb 2025 19:10:13 -0300 Subject: [PATCH 01/21] fix(attempt): revoke username --- packages/ditto/controllers/api/admin.ts | 21 ++++++++- .../ditto/storages/DittoRelayStore.test.ts | 46 ++++++++++++++++++- packages/ditto/storages/DittoRelayStore.ts | 31 ++++++++++++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/packages/ditto/controllers/api/admin.ts b/packages/ditto/controllers/api/admin.ts index f3611035..c09ba6fe 100644 --- a/packages/ditto/controllers/api/admin.ts +++ b/packages/ditto/controllers/api/admin.ts @@ -128,7 +128,7 @@ const adminAccountActionSchema = z.object({ }); const adminActionController: AppController = async (c) => { - const { conf, relay } = c.var; + const { conf, relay, signal } = c.var; const body = await parseBody(c.req.raw); const result = adminAccountActionSchema.safeParse(body); @@ -161,7 +161,24 @@ const adminActionController: AppController = async (c) => { if (data.type === 'revoke_name') { n.revoke_name = true; try { - await relay.remove!([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]); + const [event] = await relay.query([{ + kinds: [30360], + authors: [await conf.signer.getPublicKey()], + '#p': [authorId], + }], { signal }); + + if (event) { + await createAdminEvent({ + kind: 5, + tags: [ + ['e', event.id], + ['k', '30360'], + ['p', authorId], // NOTE: this is not in the NIP-09 spec + ], + }, c); + } else { + return c.json({ error: 'Name grant not found' }, 404); + } } catch (e) { logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); return c.json({ error: 'Unexpected runtime error' }, 500); diff --git a/packages/ditto/storages/DittoRelayStore.test.ts b/packages/ditto/storages/DittoRelayStore.test.ts index 66690efa..b073c865 100644 --- a/packages/ditto/storages/DittoRelayStore.test.ts +++ b/packages/ditto/storages/DittoRelayStore.test.ts @@ -4,9 +4,10 @@ import { genEvent, MockRelay } from '@nostrify/nostrify/test'; import { assertEquals } from '@std/assert'; import { generateSecretKey, getPublicKey } from 'nostr-tools'; -import { DittoRelayStore } from './DittoRelayStore.ts'; +import { DittoRelayStore } from '@/storages/DittoRelayStore.ts'; import type { NostrMetadata } from '@nostrify/types'; +import { nostrNow } from '@/utils.ts'; Deno.test('updateAuthorData sets nip05', async () => { const alex = generateSecretKey(); @@ -38,6 +39,48 @@ Deno.test('updateAuthorData sets nip05', async () => { assertEquals(row?.nip05_hostname, 'gleasonator.dev'); }); +Deno.test('Admin revokes nip05 grant and nip05 column gets null', async () => { + const alex = generateSecretKey(); + + await using test = setupTest((req) => { + switch (req.url) { + case 'https://gleasonator.dev/.well-known/nostr.json?name=alex': + return jsonResponse({ names: { alex: getPublicKey(alex) } }); + default: + return new Response('Not found', { status: 404 }); + } + }); + + const { db, store, conf } = test; + + const metadata: NostrMetadata = { nip05: 'alex@gleasonator.dev' }; + const event = genEvent({ kind: 0, content: JSON.stringify(metadata) }, alex); + + await store.updateAuthorData(event); + + const adminDeletion = await conf.signer.signEvent({ + kind: 5, + created_at: nostrNow(), + tags: [ + ['k', '30360'], + ['p', event.id], // NOTE: this is not in the NIP-09 spec + ], + content: '', + }); + + await store.event(adminDeletion); + + const row = await db.kysely + .selectFrom('author_stats') + .selectAll() + .where('pubkey', '=', getPublicKey(alex)) + .executeTakeFirst(); + + assertEquals(row?.nip05, null); + assertEquals(row?.nip05_domain, null); + assertEquals(row?.nip05_hostname, null); +}); + function setupTest(cb: (req: Request) => Response | Promise) { const conf = new DittoConf(Deno.env); const db = new DittoPolyPg(conf.databaseUrl); @@ -53,6 +96,7 @@ function setupTest(cb: (req: Request) => Response | Promise) { return { db, store, + conf, [Symbol.asyncDispose]: async () => { await store[Symbol.asyncDispose](); await db[Symbol.asyncDispose](); diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index 7b935d96..173d8793 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -28,7 +28,7 @@ import { DittoPush } from '@/DittoPush.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { RelayError } from '@/RelayError.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; -import { eventAge, nostrNow, Time } from '@/utils.ts'; +import { eventAge, isNostrId, nostrNow, Time } from '@/utils.ts'; import { getAmount } from '@/utils/bolt11.ts'; import { errorJson } from '@/utils/log.ts'; import { purifyEvent } from '@/utils/purify.ts'; @@ -189,6 +189,7 @@ export class DittoRelayStore implements NRelay { Promise.allSettled([ this.handleZaps(event), this.updateAuthorData(event, signal), + this.handleRevokeNip05(event, signal), this.prewarmLinkPreview(event, signal), this.generateSetEvents(event), ]) @@ -245,6 +246,34 @@ export class DittoRelayStore implements NRelay { } } + /** Sets the nip05 column to null */ + private async handleRevokeNip05(event: NostrEvent, signal?: AbortSignal) { + const { conf, relay } = this.opts; + + if (await conf.signer.getPublicKey() !== event.pubkey) { + return; + } + + if (event.kind !== 5) return; + + const kind = event.tags.find(([name, value]) => name === 'k' && value === '30360')?.[1]; + if (kind !== '30360') { + return; + } + + const authorId = event.tags.find(([name]) => name === 'p')?.[1]; + if (!authorId || !isNostrId(authorId)) { + return; + } + + const [author] = await relay.query([{ kinds: [0], authors: [authorId] }], { signal }); + if (!author) { + return; + } + + await this.updateAuthorData(author); + } + /** Parse kind 0 metadata and track indexes in the database. */ async updateAuthorData(event: NostrEvent, signal?: AbortSignal): Promise { if (event.kind !== 0) return; From 4792e568ef20207ed89f0043d1e51b6a28ab6bd5 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 27 Feb 2025 19:15:33 -0300 Subject: [PATCH 02/21] fix: event.pubkey, not event.id in p tag --- packages/ditto/storages/DittoRelayStore.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ditto/storages/DittoRelayStore.test.ts b/packages/ditto/storages/DittoRelayStore.test.ts index b073c865..5a1c020c 100644 --- a/packages/ditto/storages/DittoRelayStore.test.ts +++ b/packages/ditto/storages/DittoRelayStore.test.ts @@ -63,7 +63,7 @@ Deno.test('Admin revokes nip05 grant and nip05 column gets null', async () => { created_at: nostrNow(), tags: [ ['k', '30360'], - ['p', event.id], // NOTE: this is not in the NIP-09 spec + ['p', event.pubkey], // NOTE: this is not in the NIP-09 spec ], content: '', }); From 822f62301842c282e91c35372fd687dc2f02178a Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Fri, 28 Feb 2025 10:19:10 -0300 Subject: [PATCH 03/21] refactor: this.handleRevokeNip05 before relay.event --- packages/ditto/storages/DittoRelayStore.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index 173d8793..d0ea76ff 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -183,13 +183,13 @@ export class DittoRelayStore implements NRelay { } try { + await this.handleRevokeNip05(event, signal); await relay.event(purifyEvent(event), { signal }); } finally { // This needs to run in steps, and should not block the API from responding. Promise.allSettled([ this.handleZaps(event), this.updateAuthorData(event, signal), - this.handleRevokeNip05(event, signal), this.prewarmLinkPreview(event, signal), this.generateSetEvents(event), ]) @@ -250,14 +250,11 @@ export class DittoRelayStore implements NRelay { private async handleRevokeNip05(event: NostrEvent, signal?: AbortSignal) { const { conf, relay } = this.opts; - if (await conf.signer.getPublicKey() !== event.pubkey) { + if (event.kind !== 5 || await conf.signer.getPublicKey() !== event.pubkey) { return; } - if (event.kind !== 5) return; - - const kind = event.tags.find(([name, value]) => name === 'k' && value === '30360')?.[1]; - if (kind !== '30360') { + if (!event.tags.some(([name, value]) => name === 'k' && value === '30360')) { return; } From 7525cd6ef99c6256876871b5907f6a719cdb77e2 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 3 Mar 2025 18:42:26 -0300 Subject: [PATCH 04/21] refactor: set nip05 to null in handleRevokeNip05 function --- packages/ditto/storages/DittoRelayStore.ts | 31 +++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index d0ea76ff..de09d684 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -18,6 +18,7 @@ import { NRelay, NSchema as n, } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; import { logi } from '@soapbox/logi'; import { UpdateObject } from 'kysely'; import { LRUCache } from 'lru-cache'; @@ -41,7 +42,7 @@ import { parseNoteContent, stripimeta } from '@/utils/note.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts'; import { renderWebPushNotification } from '@/views/mastodon/push.ts'; -import { nip19 } from 'nostr-tools'; +import { refreshAuthorStats } from '@/utils/stats.ts'; interface DittoRelayStoreOpts { db: DittoDB; @@ -121,7 +122,7 @@ export class DittoRelayStore implements NRelay { * It is idempotent, so it can be called multiple times for the same event. */ async event(event: DittoEvent, opts: { publish?: boolean; signal?: AbortSignal } = {}): Promise { - const { conf, relay } = this.opts; + const { conf, relay, db } = this.opts; const { signal } = opts; // Skip events that have already been encountered. @@ -183,11 +184,11 @@ export class DittoRelayStore implements NRelay { } try { - await this.handleRevokeNip05(event, signal); await relay.event(purifyEvent(event), { signal }); } finally { // This needs to run in steps, and should not block the API from responding. Promise.allSettled([ + await this.handleRevokeNip05(event, signal), this.handleZaps(event), this.updateAuthorData(event, signal), this.prewarmLinkPreview(event, signal), @@ -246,9 +247,9 @@ export class DittoRelayStore implements NRelay { } } - /** Sets the nip05 column to null */ + /** Sets the nip05 column to null if the event is a revocation of a nip05 */ private async handleRevokeNip05(event: NostrEvent, signal?: AbortSignal) { - const { conf, relay } = this.opts; + const { conf, relay, db } = this.opts; if (event.kind !== 5 || await conf.signer.getPublicKey() !== event.pubkey) { return; @@ -268,7 +269,25 @@ export class DittoRelayStore implements NRelay { return; } - await this.updateAuthorData(author); + await db.kysely.insertInto('author_stats') + .values({ + pubkey: event.pubkey, + followers_count: 0, + following_count: 0, + notes_count: 0, + search: '', + }) + .onConflict((oc) => + oc.column('pubkey').doUpdateSet({ + nip05: null, + nip05_domain: null, + nip05_hostname: null, + nip05_last_verified_at: event.created_at, + }) + ) + .execute(); + + return author; } /** Parse kind 0 metadata and track indexes in the database. */ From 44d525eccbd218a459e150d8376424d25e77f7ae Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 3 Mar 2025 21:17:17 -0300 Subject: [PATCH 05/21] fix: use author pubkey, not admin pubkey --- packages/ditto/storages/DittoRelayStore.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index de09d684..54dc2279 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -42,7 +42,6 @@ import { parseNoteContent, stripimeta } from '@/utils/note.ts'; import { SimpleLRU } from '@/utils/SimpleLRU.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts'; import { renderWebPushNotification } from '@/views/mastodon/push.ts'; -import { refreshAuthorStats } from '@/utils/stats.ts'; interface DittoRelayStoreOpts { db: DittoDB; @@ -122,7 +121,7 @@ export class DittoRelayStore implements NRelay { * It is idempotent, so it can be called multiple times for the same event. */ async event(event: DittoEvent, opts: { publish?: boolean; signal?: AbortSignal } = {}): Promise { - const { conf, relay, db } = this.opts; + const { conf, relay } = this.opts; const { signal } = opts; // Skip events that have already been encountered. @@ -188,7 +187,7 @@ export class DittoRelayStore implements NRelay { } finally { // This needs to run in steps, and should not block the API from responding. Promise.allSettled([ - await this.handleRevokeNip05(event, signal), + this.handleRevokeNip05(event, signal), this.handleZaps(event), this.updateAuthorData(event, signal), this.prewarmLinkPreview(event, signal), @@ -271,7 +270,7 @@ export class DittoRelayStore implements NRelay { await db.kysely.insertInto('author_stats') .values({ - pubkey: event.pubkey, + pubkey: author.pubkey, followers_count: 0, following_count: 0, notes_count: 0, From c5fdc97e585589a60bb7326f4d978bc4bbf496ba Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 4 Mar 2025 10:51:10 -0300 Subject: [PATCH 06/21] refactor: stop returning author --- packages/ditto/storages/DittoRelayStore.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index 54dc2279..e5fbb051 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -285,8 +285,6 @@ export class DittoRelayStore implements NRelay { }) ) .execute(); - - return author; } /** Parse kind 0 metadata and track indexes in the database. */ From aa1311ccaeeb8fb0e8073601fb3b8dd70e082fe6 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 4 Mar 2025 11:42:42 -0300 Subject: [PATCH 07/21] fix: call store.event --- packages/ditto/storages/DittoRelayStore.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ditto/storages/DittoRelayStore.test.ts b/packages/ditto/storages/DittoRelayStore.test.ts index 5a1c020c..b1a4cf10 100644 --- a/packages/ditto/storages/DittoRelayStore.test.ts +++ b/packages/ditto/storages/DittoRelayStore.test.ts @@ -56,7 +56,7 @@ Deno.test('Admin revokes nip05 grant and nip05 column gets null', async () => { const metadata: NostrMetadata = { nip05: 'alex@gleasonator.dev' }; const event = genEvent({ kind: 0, content: JSON.stringify(metadata) }, alex); - await store.updateAuthorData(event); + await store.event(event); const adminDeletion = await conf.signer.signEvent({ kind: 5, From dfff63fab4d0a136ae420010cada5cacb881bfc3 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 5 Mar 2025 11:00:28 -0300 Subject: [PATCH 08/21] fix: await Promise.allSettled --- packages/ditto/storages/DittoRelayStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index 594a6fa4..27d3b491 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -186,7 +186,7 @@ export class DittoRelayStore implements NRelay { await relay.event(purifyEvent(event), { signal }); } finally { // This needs to run in steps, and should not block the API from responding. - Promise.allSettled([ + await Promise.allSettled([ this.handleRevokeNip05(event, signal), this.handleZaps(event), this.updateAuthorData(event, signal), @@ -281,7 +281,7 @@ export class DittoRelayStore implements NRelay { nip05: null, nip05_domain: null, nip05_hostname: null, - nip05_last_verified_at: event.created_at, + nip05_last_verified_at: author.created_at, }) ) .execute(); From dd5397e79546e493f14bf701539c4646d5cb7cd1 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 5 Mar 2025 11:02:13 -0300 Subject: [PATCH 09/21] test: check if nip05 exists and then check again to see if it's null --- .../ditto/storages/DittoRelayStore.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.test.ts b/packages/ditto/storages/DittoRelayStore.test.ts index cba7e164..62d2742e 100644 --- a/packages/ditto/storages/DittoRelayStore.test.ts +++ b/packages/ditto/storages/DittoRelayStore.test.ts @@ -85,6 +85,16 @@ Deno.test('Admin revokes nip05 grant and nip05 column gets null', async () => { await store.event(event); + const row = await db.kysely + .selectFrom('author_stats') + .selectAll() + .where('pubkey', '=', getPublicKey(alex)) + .executeTakeFirst(); + + assertEquals(row?.nip05, 'alex@gleasonator.dev'); + assertEquals(row?.nip05_domain, 'gleasonator.dev'); + assertEquals(row?.nip05_hostname, 'gleasonator.dev'); + const adminDeletion = await conf.signer.signEvent({ kind: 5, created_at: nostrNow(), @@ -97,15 +107,15 @@ Deno.test('Admin revokes nip05 grant and nip05 column gets null', async () => { await store.event(adminDeletion); - const row = await db.kysely + const nullRow = await db.kysely .selectFrom('author_stats') .selectAll() .where('pubkey', '=', getPublicKey(alex)) .executeTakeFirst(); - assertEquals(row?.nip05, null); - assertEquals(row?.nip05_domain, null); - assertEquals(row?.nip05_hostname, null); + assertEquals(nullRow?.nip05, null); + assertEquals(nullRow?.nip05_domain, null); + assertEquals(nullRow?.nip05_hostname, null); }); function setupTest(cb?: (req: Request) => Response | Promise) { From d8a0eca8917f050df042bba60d23d34f00860fd4 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 5 Mar 2025 11:22:07 -0300 Subject: [PATCH 10/21] refactor: get author from grant event (30360), before doing the admin deletion --- .../ditto/storages/DittoRelayStore.test.ts | 20 +++++++++++++++++-- packages/ditto/storages/DittoRelayStore.ts | 14 +++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.test.ts b/packages/ditto/storages/DittoRelayStore.test.ts index 62d2742e..9940ba05 100644 --- a/packages/ditto/storages/DittoRelayStore.test.ts +++ b/packages/ditto/storages/DittoRelayStore.test.ts @@ -95,13 +95,29 @@ Deno.test('Admin revokes nip05 grant and nip05 column gets null', async () => { assertEquals(row?.nip05_domain, 'gleasonator.dev'); assertEquals(row?.nip05_hostname, 'gleasonator.dev'); + const grant = await conf.signer.signEvent({ + kind: 30360, + tags: [ + ['d', 'alex@gleasonator.dev'], + ['r', 'alex@gleasonator.dev'], + ['L', 'nip05.domain'], + ['l', 'gleasonator.dev', 'nip05.domain'], + ['p', event.pubkey], + ['e', 'whatever'], + ], + created_at: nostrNow(), + content: '', + }); + + await store.event(grant); + const adminDeletion = await conf.signer.signEvent({ kind: 5, - created_at: nostrNow(), tags: [ ['k', '30360'], - ['p', event.pubkey], // NOTE: this is not in the NIP-09 spec + ['e', grant.id], ], + created_at: nostrNow(), content: '', }); diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index 27d3b491..2f5a1fb7 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -183,11 +183,11 @@ export class DittoRelayStore implements NRelay { } try { + await this.handleRevokeNip05(event, signal); await relay.event(purifyEvent(event), { signal }); } finally { // This needs to run in steps, and should not block the API from responding. await Promise.allSettled([ - this.handleRevokeNip05(event, signal), this.handleZaps(event), this.updateAuthorData(event, signal), this.prewarmLinkPreview(event, signal), @@ -258,7 +258,17 @@ export class DittoRelayStore implements NRelay { return; } - const authorId = event.tags.find(([name]) => name === 'p')?.[1]; + const eventId = event.tags.find(([name]) => name === 'e')?.[1]; + if (!eventId || !isNostrId(eventId)) { + return; + } + + const [grant] = await relay.query([{ kinds: [30360], ids: [eventId] }], { signal }); + if (!grant) { + return; + } + + const authorId = grant.tags.find(([name]) => name === 'p')?.[1]; if (!authorId || !isNostrId(authorId)) { return; } From 842150b6e2d6b4395b4e815cb3129dcd59845c1d Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 5 Mar 2025 11:33:57 -0300 Subject: [PATCH 11/21] fix: remove useless 'p' tag in admin kind 5 event --- packages/ditto/controllers/api/admin.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ditto/controllers/api/admin.ts b/packages/ditto/controllers/api/admin.ts index 34e54ac2..774157f0 100644 --- a/packages/ditto/controllers/api/admin.ts +++ b/packages/ditto/controllers/api/admin.ts @@ -173,7 +173,6 @@ const adminActionController: AppController = async (c) => { tags: [ ['e', event.id], ['k', '30360'], - ['p', authorId], // NOTE: this is not in the NIP-09 spec ], }, c); } else { From f3bdabc13aa27d7fd3c5795664c9f778c5de1d94 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 5 Mar 2025 12:44:16 -0600 Subject: [PATCH 12/21] DittoRelayStore: improve error handling around this.listen(), remove `await` --- packages/ditto/storages/DittoRelayStore.ts | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index 7ff0b07f..fad27682 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -69,10 +69,6 @@ export class DittoRelayStore implements NRelay { this.push = new DittoPush(opts); this.policyWorker = new PolicyWorker(conf); - this.listen().catch((e: unknown) => { - logi({ level: 'error', ns: this.ns, source: 'listen', error: errorJson(e) }); - }); - this.faviconCache = new SimpleLRU( async (domain, { signal }) => { const row = await queryFavicon(db.kysely, domain); @@ -94,17 +90,30 @@ export class DittoRelayStore implements NRelay { }, { ...conf.caches.nip05, gauge: cachedNip05sSizeGauge }, ); + + this.listen().catch((e: unknown) => { + if (e instanceof Error && e.name === 'AbortError') { + return; // `this.close()` was called. This is expected. + } + + throw e; + }); } /** Open a firehose to the relay. */ private async listen(): Promise { const { relay } = this.opts; - const { signal } = this.controller; + const { signal } = this.controller; // this controller only aborts when `this.close()` is called for await (const msg of relay.req([{ limit: 0 }], { signal })) { if (msg[0] === 'EVENT') { const [, , event] = msg; - await this.event(event, { signal }); + const { id, kind } = event; + try { + await this.event(event, { signal }); + } catch (e) { + logi({ level: 'error', ns: this.ns, id, kind, source: 'listen', error: errorJson(e) }); + } } } } @@ -127,7 +136,7 @@ export class DittoRelayStore implements NRelay { // Skip events that have already been encountered. if (this.encounters.get(event.id)) { - throw new RelayError('duplicate', 'already have this event'); + return; // NIP-01: duplicate events should have ok `true` } // Reject events that are too far in the future. if (eventAge(event) < -Time.minutes(1)) { @@ -188,7 +197,7 @@ export class DittoRelayStore implements NRelay { await relay.event(purifyEvent(event), { signal }); } finally { // This needs to run in steps, and should not block the API from responding. - await Promise.allSettled([ + Promise.allSettled([ this.handleZaps(event), this.updateAuthorData(event, signal), this.prewarmLinkPreview(event, signal), @@ -253,7 +262,7 @@ export class DittoRelayStore implements NRelay { } /** Sets the nip05 column to null if the event is a revocation of a nip05 */ - private async handleRevokeNip05(event: NostrEvent, signal?: AbortSignal) { + private async handleRevokeNip05(event: NostrEvent, signal?: AbortSignal): Promise { const { conf, relay, db } = this.opts; if (event.kind !== 5 || await conf.signer.getPublicKey() !== event.pubkey) { From c7175f8301386ade91f40bb22fae5cb08d77ac7c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 5 Mar 2025 12:55:50 -0600 Subject: [PATCH 13/21] ci: add 2 minute timeout --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c15e8907..db3b54cc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,7 @@ image: denoland/deno:2.2.2 default: interruptible: true + timeout: 2 minutes stages: - test From 4e0479f7c84bc09ed353a3383044c107103a6ef8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 5 Mar 2025 13:27:34 -0600 Subject: [PATCH 14/21] Add DittoPostgres test --- packages/db/adapters/DittoPostgres.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/db/adapters/DittoPostgres.test.ts diff --git a/packages/db/adapters/DittoPostgres.test.ts b/packages/db/adapters/DittoPostgres.test.ts new file mode 100644 index 00000000..ea362ab0 --- /dev/null +++ b/packages/db/adapters/DittoPostgres.test.ts @@ -0,0 +1,11 @@ +import { DittoConf } from '@ditto/conf'; + +import { DittoPostgres } from './DittoPostgres.ts'; + +const conf = new DittoConf(Deno.env); +const isPostgres = /^postgres(?:ql)?:/.test(conf.databaseUrl); + +Deno.test('DittoPostgres', { ignore: !isPostgres }, async () => { + await using db = new DittoPostgres(conf.databaseUrl); + await db.migrate(); +}); From 0b72533b05ebc75d09b9b069fc18ac9de2cc1ac6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 5 Mar 2025 13:37:24 -0600 Subject: [PATCH 15/21] DittoPglite: test that queries reject after it's closed --- packages/db/adapters/DittoPglite.test.ts | 18 +++++++++++++----- packages/db/adapters/DittoPglite.ts | 12 +++++++++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/db/adapters/DittoPglite.test.ts b/packages/db/adapters/DittoPglite.test.ts index b0d9f4d1..4cea878f 100644 --- a/packages/db/adapters/DittoPglite.test.ts +++ b/packages/db/adapters/DittoPglite.test.ts @@ -1,14 +1,22 @@ -import { assertEquals } from '@std/assert'; +import { assertEquals, assertRejects } from '@std/assert'; import { DittoPglite } from './DittoPglite.ts'; Deno.test('DittoPglite', async () => { - const db = new DittoPglite('memory://'); + await using db = new DittoPglite('memory://'); await db.migrate(); assertEquals(db.poolSize, 1); assertEquals(db.availableConnections, 1); - - await db.kysely.destroy(); - await new Promise((resolve) => setTimeout(resolve, 100)); +}); + +Deno.test('DittoPglite query after closing', async () => { + const db = new DittoPglite('memory://'); + await db[Symbol.asyncDispose](); + + await assertRejects( + () => db.kysely.selectFrom('nostr_events').selectAll().execute(), + Error, + 'PGlite is closed', + ); }); diff --git a/packages/db/adapters/DittoPglite.ts b/packages/db/adapters/DittoPglite.ts index 7fcd5bab..bac1ebaa 100644 --- a/packages/db/adapters/DittoPglite.ts +++ b/packages/db/adapters/DittoPglite.ts @@ -47,6 +47,16 @@ export class DittoPglite implements DittoDB { } async [Symbol.asyncDispose](): Promise { - await this.kysely.destroy(); + try { + // FIXME: `kysely.destroy()` calls `pglite.close()` internally, but it doesn't work. + await this.pglite.close(); + await this.kysely.destroy(); + } catch (e) { + if (e instanceof Error && e.message === 'PGlite is closed') { + // Make dispose idempotent. + } else { + throw e; + } + } } } From 9b422d8e31547d05ec80c325e5e8951d1c957f5a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 5 Mar 2025 14:47:35 -0600 Subject: [PATCH 16/21] Add note about hanging queries in DittoPostgres test --- packages/db/adapters/DittoPostgres.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/db/adapters/DittoPostgres.test.ts b/packages/db/adapters/DittoPostgres.test.ts index ea362ab0..48e97340 100644 --- a/packages/db/adapters/DittoPostgres.test.ts +++ b/packages/db/adapters/DittoPostgres.test.ts @@ -9,3 +9,14 @@ Deno.test('DittoPostgres', { ignore: !isPostgres }, async () => { await using db = new DittoPostgres(conf.databaseUrl); await db.migrate(); }); + +// FIXME: There is a problem with postgres-js where queries just hang after the database is closed. + +// Deno.test('DittoPostgres query after closing', { ignore: !isPostgres }, async () => { +// const db = new DittoPostgres(conf.databaseUrl); +// await db[Symbol.asyncDispose](); +// +// await assertRejects( +// () => db.kysely.selectFrom('nostr_events').selectAll().execute(), +// ); +// }); From 811a56e406306d0ccc6272b6be6ba48cc74b4653 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 5 Mar 2025 15:05:26 -0600 Subject: [PATCH 17/21] ci: try moving the timeout to the actual test job --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index db3b54cc..2b9f0555 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,13 +2,13 @@ image: denoland/deno:2.2.2 default: interruptible: true - timeout: 2 minutes stages: - test test: stage: test + timeout: 2 minutes script: - deno fmt --check - deno task lint From 19244aec2cb800c785d67dd752e98bedb6c98b8a Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 5 Mar 2025 19:33:40 -0300 Subject: [PATCH 18/21] test/fix: use waitFor function --- .../ditto/storages/DittoRelayStore.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.test.ts b/packages/ditto/storages/DittoRelayStore.test.ts index 689ddab4..bdf59dab 100644 --- a/packages/ditto/storages/DittoRelayStore.test.ts +++ b/packages/ditto/storages/DittoRelayStore.test.ts @@ -85,15 +85,19 @@ Deno.test('Admin revokes nip05 grant and nip05 column gets null', async () => { await store.event(event); - const row = await db.kysely - .selectFrom('author_stats') - .selectAll() - .where('pubkey', '=', getPublicKey(alex)) - .executeTakeFirst(); + await waitFor(async () => { + const row = await db.kysely + .selectFrom('author_stats') + .selectAll() + .where('pubkey', '=', getPublicKey(alex)) + .executeTakeFirst(); - assertEquals(row?.nip05, 'alex@gleasonator.dev'); - assertEquals(row?.nip05_domain, 'gleasonator.dev'); - assertEquals(row?.nip05_hostname, 'gleasonator.dev'); + assertEquals(row?.nip05, 'alex@gleasonator.dev'); + assertEquals(row?.nip05_domain, 'gleasonator.dev'); + assertEquals(row?.nip05_hostname, 'gleasonator.dev'); + + return true; + }, 3000); const grant = await conf.signer.signEvent({ kind: 30360, From 1eb1f4206dbf390067748823830a93638db4d744 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 5 Mar 2025 20:12:42 -0300 Subject: [PATCH 19/21] refactor: use db.kysely.updateTable rather than db.kysely.insertInto --- packages/ditto/storages/DittoRelayStore.ts | 30 +++++++++------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index fad27682..6f3cd791 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -161,7 +161,7 @@ export class DittoRelayStore implements NRelay { } // Recheck encountered after async ops. if (this.encounters.has(event.id)) { - throw new RelayError('duplicate', 'already have this event'); + return; } // Set the event as encountered after verifying the signature. this.encounters.set(event.id, true); @@ -293,23 +293,17 @@ export class DittoRelayStore implements NRelay { return; } - await db.kysely.insertInto('author_stats') - .values({ - pubkey: author.pubkey, - followers_count: 0, - following_count: 0, - notes_count: 0, - search: '', - }) - .onConflict((oc) => - oc.column('pubkey').doUpdateSet({ - nip05: null, - nip05_domain: null, - nip05_hostname: null, - nip05_last_verified_at: author.created_at, - }) - ) - .execute(); + try { + await db.kysely.updateTable('author_stats').set({ + nip05: null, + nip05_domain: null, + nip05_hostname: null, + nip05_last_verified_at: author.created_at, + }).where('pubkey', '=', author.pubkey) + .execute(); + } catch { + // nothing hahah + } } /** Parse kind 0 metadata and track indexes in the database. */ From 9e726baa2aab162521600dabff946772799b9d47 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 5 Mar 2025 20:15:16 -0300 Subject: [PATCH 20/21] refactor: remove try catch --- packages/ditto/storages/DittoRelayStore.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index 6f3cd791..daf21f6c 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -293,17 +293,13 @@ export class DittoRelayStore implements NRelay { return; } - try { - await db.kysely.updateTable('author_stats').set({ - nip05: null, - nip05_domain: null, - nip05_hostname: null, - nip05_last_verified_at: author.created_at, - }).where('pubkey', '=', author.pubkey) - .execute(); - } catch { - // nothing hahah - } + await db.kysely.updateTable('author_stats').set({ + nip05: null, + nip05_domain: null, + nip05_hostname: null, + nip05_last_verified_at: author.created_at, + }).where('pubkey', '=', author.pubkey) + .execute(); } /** Parse kind 0 metadata and track indexes in the database. */ From e549a9d34a47f2d1aafa8503de0740a2053fc7fb Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 5 Mar 2025 20:28:22 -0300 Subject: [PATCH 21/21] refactor: stop fetching author and set nip05_last_verified_at to null --- packages/ditto/storages/DittoRelayStore.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index daf21f6c..3b8db534 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -288,17 +288,12 @@ export class DittoRelayStore implements NRelay { return; } - const [author] = await relay.query([{ kinds: [0], authors: [authorId] }], { signal }); - if (!author) { - return; - } - await db.kysely.updateTable('author_stats').set({ nip05: null, nip05_domain: null, nip05_hostname: null, - nip05_last_verified_at: author.created_at, - }).where('pubkey', '=', author.pubkey) + nip05_last_verified_at: null, + }).where('pubkey', '=', authorId) .execute(); }