diff --git a/migrations/20260410_230000_add_is_vanished_to_users_table.js b/migrations/20260410_230000_add_is_vanished_to_users_table.js new file mode 100644 index 00000000..2deb8252 --- /dev/null +++ b/migrations/20260410_230000_add_is_vanished_to_users_table.js @@ -0,0 +1,22 @@ +exports.up = async function (knex) { + await knex.schema.alterTable('users', (table) => { + table.boolean('is_vanished').notNullable().defaultTo(false) + }) + + await knex.raw(` + UPDATE users u + SET is_vanished = true + WHERE EXISTS ( + SELECT 1 FROM events e + WHERE e.event_pubkey = u.pubkey + AND e.event_kind = 62 + AND e.deleted_at IS NULL + ) + `) +} + +exports.down = function (knex) { + return knex.schema.alterTable('users', (table) => { + table.dropColumn('is_vanished') + }) +} diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 4341bb58..ce72d5c9 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -47,4 +47,6 @@ export interface IUserRepository { findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise upsert(user: Partial, client?: DatabaseClient): Promise getBalanceByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise + isVanished(pubkey: Pubkey, client?: DatabaseClient): Promise + setVanished(pubkey: Pubkey, vanished: boolean, client?: DatabaseClient): Promise } diff --git a/src/@types/user.ts b/src/@types/user.ts index 83a5237c..983a3e4a 100644 --- a/src/@types/user.ts +++ b/src/@types/user.ts @@ -3,6 +3,7 @@ import { Pubkey } from './base' export interface User { pubkey: Pubkey isAdmitted: boolean + isVanished: boolean balance: bigint tosAcceptedAt?: Date | null createdAt: Date @@ -12,6 +13,7 @@ export interface User { export interface DBUser { pubkey: Buffer is_admitted: boolean + is_vanished: boolean balance: bigint created_at: Date updated_at: Date diff --git a/src/factories/controllers/get-admission-check-controller-factory.ts b/src/factories/controllers/get-admission-check-controller-factory.ts index c7d2d47e..c1bdd265 100644 --- a/src/factories/controllers/get-admission-check-controller-factory.ts +++ b/src/factories/controllers/get-admission-check-controller-factory.ts @@ -1,12 +1,15 @@ +import { getMasterDbClient, getReadReplicaDbClient } from '../../database/client' import { createSettings } from '../settings-factory' -import { getMasterDbClient } from '../../database/client' +import { EventRepository } from '../../repositories/event-repository' import { GetSubmissionCheckController } from '../../controllers/admission/get-admission-check-controller' import { slidingWindowRateLimiterFactory } from '../rate-limiter-factory' import { UserRepository } from '../../repositories/user-repository' export const createGetAdmissionCheckController = () => { const dbClient = getMasterDbClient() - const userRepository = new UserRepository(dbClient) + const readReplicaDbClient = getReadReplicaDbClient() + const eventRepository = new EventRepository(dbClient, readReplicaDbClient) + const userRepository = new UserRepository(dbClient, eventRepository) return new GetSubmissionCheckController( userRepository, diff --git a/src/factories/controllers/post-invoice-controller-factory.ts b/src/factories/controllers/post-invoice-controller-factory.ts index 50331572..1d5b6593 100644 --- a/src/factories/controllers/post-invoice-controller-factory.ts +++ b/src/factories/controllers/post-invoice-controller-factory.ts @@ -1,6 +1,7 @@ +import { getMasterDbClient, getReadReplicaDbClient } from '../../database/client' import { createPaymentsService } from '../payments-service-factory' import { createSettings } from '../settings-factory' -import { getMasterDbClient } from '../../database/client' +import { EventRepository } from '../../repositories/event-repository' import { IController } from '../../@types/controllers' import { PostInvoiceController } from '../../controllers/invoices/post-invoice-controller' import { slidingWindowRateLimiterFactory } from '../rate-limiter-factory' @@ -8,7 +9,9 @@ import { UserRepository } from '../../repositories/user-repository' export const createPostInvoiceController = (): IController => { const dbClient = getMasterDbClient() - const userRepository = new UserRepository(dbClient) + const readReplicaDbClient = getReadReplicaDbClient() + const eventRepository = new EventRepository(dbClient, readReplicaDbClient) + const userRepository = new UserRepository(dbClient, eventRepository) const paymentsService = createPaymentsService() return new PostInvoiceController( diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 1e4b5af9..52709ac5 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -1,10 +1,10 @@ +import { IEventRepository, IUserRepository } from '../@types/repositories' import { isDeleteEvent, isEphemeralEvent, isParameterizedReplaceableEvent, isReplaceableEvent, isRequestToVanishEvent } from '../utils/event' import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy' import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy' import { Event } from '../@types/event' import { Factory } from '../@types/base' -import { IEventRepository } from '../@types/repositories' import { IEventStrategy } from '../@types/message-handlers' import { IWebSocketAdapter } from '../@types/adapters' import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy' @@ -13,10 +13,11 @@ import { VanishEventStrategy } from '../handlers/event-strategies/vanish-event-s export const eventStrategyFactory = ( eventRepository: IEventRepository, + userRepository: IUserRepository, ): Factory>, [Event, IWebSocketAdapter]> => ([event, adapter]: [Event, IWebSocketAdapter]) => { if (isRequestToVanishEvent(event)) { - return new VanishEventStrategy(adapter, eventRepository) + return new VanishEventStrategy(adapter, eventRepository, userRepository) } else if (isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) } else if (isEphemeralEvent(event)) { diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 34c37493..a5c1c9fe 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -17,7 +17,7 @@ export const messageHandlerFactory = ( { return new EventMessageHandler( adapter, - eventStrategyFactory(eventRepository), + eventStrategyFactory(eventRepository, userRepository), eventRepository, userRepository, createSettings, diff --git a/src/factories/payments-service-factory.ts b/src/factories/payments-service-factory.ts index abd97ddd..1a762e44 100644 --- a/src/factories/payments-service-factory.ts +++ b/src/factories/payments-service-factory.ts @@ -10,9 +10,9 @@ export const createPaymentsService = () => { const dbClient = getMasterDbClient() const rrDbClient = getReadReplicaDbClient() const invoiceRepository = new InvoiceRepository(dbClient) - const userRepository = new UserRepository(dbClient) - const paymentsProcessor = createPaymentsProcessor() const eventRepository = new EventRepository(dbClient, rrDbClient) + const userRepository = new UserRepository(dbClient, eventRepository) + const paymentsProcessor = createPaymentsProcessor() return new PaymentsService( dbClient, diff --git a/src/factories/static-mirroring.worker-factory.ts b/src/factories/static-mirroring.worker-factory.ts index 234430e4..67f7028e 100644 --- a/src/factories/static-mirroring.worker-factory.ts +++ b/src/factories/static-mirroring.worker-factory.ts @@ -8,7 +8,7 @@ export const staticMirroringWorkerFactory = () => { const dbClient = getMasterDbClient() const readReplicaDbClient = getReadReplicaDbClient() const eventRepository = new EventRepository(dbClient, readReplicaDbClient) - const userRepository = new UserRepository(dbClient) + const userRepository = new UserRepository(dbClient, eventRepository) return new StaticMirroringWorker( eventRepository, diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index 123e59d2..ec358798 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -16,7 +16,7 @@ export const workerFactory = (): AppWorker => { const dbClient = getMasterDbClient() const readReplicaDbClient = getReadReplicaDbClient() const eventRepository = new EventRepository(dbClient, readReplicaDbClient) - const userRepository = new UserRepository(dbClient) + const userRepository = new UserRepository(dbClient, eventRepository) const settings = createSettings() diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 43d1ba22..52320fbc 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -224,8 +224,8 @@ export class EventMessageHandler implements IMessageHandler { return } - const existingVanishRequest = await this.eventRepository.hasActiveRequestToVanish(event.pubkey) - if (existingVanishRequest) { + const isVanished = await this.userRepository.isVanished(event.pubkey) + if (isVanished) { return 'blocked: request to vanish active for pubkey' } } diff --git a/src/handlers/event-strategies/vanish-event-strategy.ts b/src/handlers/event-strategies/vanish-event-strategy.ts index 32dbb494..ed8ca73a 100644 --- a/src/handlers/event-strategies/vanish-event-strategy.ts +++ b/src/handlers/event-strategies/vanish-event-strategy.ts @@ -1,8 +1,8 @@ +import { IEventRepository, IUserRepository } from '../../@types/repositories' import { createCommandResult } from '../../utils/messages' import { createLogger } from '../../factories/logger-factory' import { Event } from '../../@types/event' import { EventKinds } from '../../constants/base' -import { IEventRepository } from '../../@types/repositories' import { IEventStrategy } from '../../@types/message-handlers' import { IWebSocketAdapter } from '../../@types/adapters' import { WebSocketAdapterEvent } from '../../constants/adapter' @@ -13,6 +13,7 @@ export class VanishEventStrategy implements IEventStrategy> public constructor( private readonly webSocket: IWebSocketAdapter, private readonly eventRepository: IEventRepository, + private readonly userRepository: IUserRepository, ) {} public async execute(event: Event): Promise { @@ -25,6 +26,8 @@ export class VanishEventStrategy implements IEventStrategy> const count = await this.eventRepository.create(event) + await this.userRepository.setVanished(event.pubkey, true) + this.webSocket.emit( WebSocketAdapterEvent.Message, createCommandResult(event.id, true, count ? '' : 'duplicate:') diff --git a/src/repositories/user-repository.ts b/src/repositories/user-repository.ts index fedf40b4..6fa5c76d 100644 --- a/src/repositories/user-repository.ts +++ b/src/repositories/user-repository.ts @@ -1,15 +1,18 @@ -import { always, applySpec, omit, pipe, prop } from 'ramda' - +import { always, applySpec, defaultTo, omit, pipe, prop } from 'ramda' import { DatabaseClient, Pubkey } from '../@types/base' import { DBUser, User } from '../@types/user' import { fromDBUser, toBuffer } from '../utils/transform' +import { IEventRepository, IUserRepository } from '../@types/repositories' import { createLogger } from '../factories/logger-factory' -import { IUserRepository } from '../@types/repositories' + const debug = createLogger('user-repository') export class UserRepository implements IUserRepository { - public constructor(private readonly dbClient: DatabaseClient) { } + public constructor( + private readonly dbClient: DatabaseClient, + private readonly eventRepository: IEventRepository, + ) { } public async findByPubkey( pubkey: Pubkey, @@ -28,7 +31,7 @@ export class UserRepository implements IUserRepository { } public async upsert( - user: User, + user: Partial, client: DatabaseClient = this.dbClient, ): Promise { debug('upsert: %o', user) @@ -37,7 +40,8 @@ export class UserRepository implements IUserRepository { const row = applySpec({ pubkey: pipe(prop('pubkey'), toBuffer), - is_admitted: prop('isAdmitted'), + is_admitted: pipe(prop('isAdmitted'), defaultTo(false)), + is_vanished: pipe(prop('isVanished'), defaultTo(false)), tos_accepted_at: prop('tosAcceptedAt'), updated_at: always(date), created_at: always(date), @@ -61,6 +65,62 @@ export class UserRepository implements IUserRepository { } as Promise } + /** + * Returns vanish state from users.is_vanished, or lazily hydrates a user row from events once + * when no users row exists (single upsert; no duplicate inserts). + */ + public async isVanished( + pubkey: Pubkey, + client: DatabaseClient = this.dbClient + ): Promise { + const existing = await this.findByPubkey(pubkey, client) + if (existing) { + return existing.isVanished + } + + const vanishedFromEvents = await this.eventRepository.hasActiveRequestToVanish(pubkey) + await this.upsertVanishState(pubkey, vanishedFromEvents, client) + return vanishedFromEvents + } + + public setVanished( + pubkey: Pubkey, + vanished: boolean, + client: DatabaseClient = this.dbClient + ): Promise { + return this.upsertVanishState(pubkey, vanished, client) + } + + private upsertVanishState( + pubkey: Pubkey, + isVanished: boolean, + client: DatabaseClient, + ): Promise { + debug('upsert vanish state for %s: %o', pubkey, isVanished) + const date = new Date() + + const query = client('users') + .insert({ + pubkey: toBuffer(pubkey), + is_admitted: false, + balance: 0n, + is_vanished: isVanished, + created_at: date, + updated_at: date, + }) + .onConflict('pubkey') + .merge({ + is_vanished: isVanished, + updated_at: date, + }) + + return { + then: (onfulfilled: (value: number) => T1 | PromiseLike, onrejected: (reason: any) => T2 | PromiseLike) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected), + catch: (onrejected: (reason: any) => T | PromiseLike) => query.catch(onrejected), + toString: (): string => query.toString(), + } as Promise + } + public async getBalanceByPubkey( pubkey: Pubkey, client: DatabaseClient = this.dbClient diff --git a/src/utils/transform.ts b/src/utils/transform.ts index 33aa9244..9c9d01b2 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -39,6 +39,7 @@ export const fromDBInvoice = applySpec({ export const fromDBUser = applySpec({ pubkey: pipe(prop('pubkey') as () => Buffer, fromBuffer), isAdmitted: prop('is_admitted'), + isVanished: prop('is_vanished'), balance: prop('balance'), createdAt: prop('created_at'), updatedAt: prop('updated_at'), diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index 46140807..29c7c818 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -1,5 +1,6 @@ import { expect } from 'chai' +import { IEventRepository, IUserRepository } from '../../../src/@types/repositories' import { DefaultEventStrategy } from '../../../src/handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../../../src/handlers/event-strategies/delete-event-strategy' import { EphemeralEventStrategy } from '../../../src/handlers/event-strategies/ephemeral-event-strategy' @@ -7,7 +8,6 @@ import { Event } from '../../../src/@types/event' import { EventKinds } from '../../../src/constants/base' import { eventStrategyFactory } from '../../../src/factories/event-strategy-factory' import { Factory } from '../../../src/@types/base' -import { IEventRepository } from '../../../src/@types/repositories' import { IEventStrategy } from '../../../src/@types/message-handlers' import { IWebSocketAdapter } from '../../../src/@types/adapters' import { ParameterizedReplaceableEventStrategy } from '../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy' @@ -16,16 +16,18 @@ import { VanishEventStrategy } from '../../../src/handlers/event-strategies/vani describe('eventStrategyFactory', () => { let eventRepository: IEventRepository + let userRepository: IUserRepository let event: Event let adapter: IWebSocketAdapter let factory: Factory>, [Event, IWebSocketAdapter]> beforeEach(() => { eventRepository = {} as any + userRepository = {} as any event = {} as any adapter = {} as any - factory = eventStrategyFactory(eventRepository) + factory = eventStrategyFactory(eventRepository, userRepository) }) it('returns ReplaceableEvent given a set_metadata event', () => { diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 6895ec7c..983dc7d2 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -49,6 +49,9 @@ describe('EventMessageHandler', () => { sig: 'f'.repeat(128), tags: [], } + userRepository = { + isVanished: async () => false, + } as any }) afterEach(() => { @@ -70,7 +73,10 @@ describe('EventMessageHandler', () => { canAcceptEventStub = sandbox.stub(EventMessageHandler.prototype, 'canAcceptEvent' as any) isEventValidStub = sandbox.stub(EventMessageHandler.prototype, 'isEventValid' as any) isUserAdmitted = sandbox.stub(EventMessageHandler.prototype, 'isUserAdmitted' as any) - eventRepository = { hasActiveRequestToVanish: sandbox.stub().resolves(false) } + eventRepository = {} as any + userRepository = { + isVanished: sandbox.stub().resolves(false), + } as any strategyExecuteStub = sandbox.stub() strategyFactoryStub = sandbox.stub().returns({ execute: strategyExecuteStub, @@ -125,11 +131,11 @@ describe('EventMessageHandler', () => { it('rejects event if request to vanish is active for pubkey', async () => { canAcceptEventStub.returns(undefined) isEventValidStub.resolves(undefined) - eventRepository.hasActiveRequestToVanish.resolves(true) + ;(userRepository.isVanished as any).resolves(true) await handler.handleMessage(message) - expect(eventRepository.hasActiveRequestToVanish).to.have.been.calledOnceWithExactly(event.pubkey) + expect(userRepository.isVanished as any).to.have.been.calledOnceWithExactly(event.pubkey) expect(onMessageSpy).to.have.been.calledOnceWithExactly( [MessageType.OK, event.id, false, 'blocked: request to vanish active for pubkey'], ) @@ -259,7 +265,7 @@ describe('EventMessageHandler', () => { handler = new EventMessageHandler( {} as any, () => null, - { hasActiveRequestToVanish: async () => false } as any, + {} as any, userRepository, () => settings, () => ({ hit: async () => false }) @@ -732,10 +738,13 @@ describe('EventMessageHandler', () => { webSocket = { getClientAddress: getClientAddressStub, } as any + userRepository = { + isVanished: async () => false, + } as any handler = new EventMessageHandler( webSocket, () => null, - { hasActiveRequestToVanish: async () => false } as any, + {} as any, userRepository, () => settings, () => ({ hit: rateLimiterHitStub }) @@ -999,11 +1008,12 @@ describe('EventMessageHandler', () => { } as any userRepository = { findByPubkey: userRepositoryFindByPubkeyStub, + isVanished: async () => false, } as any handler = new EventMessageHandler( webSocket, () => null, - { hasActiveRequestToVanish: async () => false } as any, + {} as any, userRepository, () => settings, () => ({ hit: async () => false }) @@ -1084,27 +1094,27 @@ describe('EventMessageHandler', () => { }) it('fulfills with reason if user is not admitted', async () => { - userRepositoryFindByPubkeyStub.resolves({ isAdmitted: false }) + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: false, isVanished: false }) return expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: pubkey not admitted') }) it('fulfills with reason if user is not admitted', async () => { - userRepositoryFindByPubkeyStub.resolves({ isAdmitted: false }) + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: false, isVanished: false }) return expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: pubkey not admitted') }) it('fulfills with reason if user does not meet minimum balance', async () => { settings.limits.event.pubkey.minBalance = 1000n - userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, balance: 999n }) + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, isVanished: false, balance: 999n }) return expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: insufficient balance') }) it('fulfills with undefined if user is admitted', async () => { settings.limits.event.pubkey.minBalance = 0n - userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true }) + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, isVanished: false }) return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined }) diff --git a/test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts b/test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts index 2b6aefe2..845372cd 100644 --- a/test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts @@ -1,20 +1,24 @@ -import chai from 'chai' -import chaiAsPromised from 'chai-as-promised' import { Event } from '../../../../src/@types/event' import { EventKinds } from '../../../../src/constants/base' import { IWebSocketAdapter } from '../../../../src/@types/adapters' import { MessageType } from '../../../../src/@types/messages' -import Sinon from 'sinon' import { VanishEventStrategy } from '../../../../src/handlers/event-strategies/vanish-event-strategy' import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + chai.use(chaiAsPromised) +chai.use(sinonChai) const { expect } = chai describe('VanishEventStrategy', () => { let webSocket: IWebSocketAdapter let eventRepository: any + let userRepository: any let webSocketEmitStub: Sinon.SinonStub let strategy: VanishEventStrategy let sandbox: Sinon.SinonSandbox @@ -31,11 +35,14 @@ describe('VanishEventStrategy', () => { deleteByPubkeyExceptKinds: sandbox.stub().resolves(1), create: sandbox.stub().resolves(1), } + userRepository = { + setVanished: sandbox.stub().resolves(1), + } webSocketEmitStub = sandbox.stub() webSocket = { emit: webSocketEmitStub, } as any - strategy = new VanishEventStrategy(webSocket, eventRepository) + strategy = new VanishEventStrategy(webSocket, eventRepository, userRepository) }) afterEach(() => { @@ -50,6 +57,7 @@ describe('VanishEventStrategy', () => { [EventKinds.REQUEST_TO_VANISH], ) expect(eventRepository.create).to.have.been.calledOnceWithExactly(event) + expect(userRepository.setVanished).to.have.been.calledOnceWithExactly(event.pubkey, true) expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( WebSocketAdapterEvent.Message, [MessageType.OK, event.id, true, ''],