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;