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/package-lock.json b/package-lock.json index 6b83a96a..a0f84bd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -147,6 +147,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -998,6 +999,7 @@ "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@cucumber/messages": ">=17.1.1" } @@ -1696,6 +1698,7 @@ "integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^3.0.0", "@octokit/graphql": "^5.0.0", @@ -1972,6 +1975,7 @@ "integrity": "sha512-YfcB2QrX+Wx1o6LD1G2Y2fhDhOix/bAY/oAnMpHoNLsKkWIRbt1oKLkIFvxBMzLwAEPqnYWguJrYC+J6i4ywbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bole": "^5.0.0", "ndjson": "^2.0.0" @@ -2171,6 +2175,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.4.2.tgz", "integrity": "sha512-oUdEjE0I7JS5AyaAjkD3aOXn9NhO7XKyPyXEyrgFDu++VrVBHUPnV6dgEya9TcMuj5nIJRuCzCm8ZP+c9zCHPw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.1", "generic-pool": "3.9.0", @@ -3097,6 +3102,7 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3276,6 +3282,7 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -3668,6 +3675,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4318,6 +4326,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4547,6 +4556,7 @@ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -5292,6 +5302,7 @@ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -6193,6 +6204,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8522,6 +8534,7 @@ "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9313,6 +9326,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -12010,6 +12024,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12983,6 +12998,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.9.0.tgz", "integrity": "sha512-ZJM+qkEbtOHRuXjmvBtOgNOXOtLSbxiMiUVMgE4rV6Zwocy03RicCVvDXgx8l4Biwo8/qORUnEqn2fdQzV7KCg==", "license": "MIT", + "peer": true, "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -14024,8 +14040,7 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/regexp-match-indices": { "version": "1.0.2", @@ -14433,6 +14448,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -14538,6 +14554,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -16736,6 +16753,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16905,6 +16923,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -17200,6 +17219,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18036,6 +18056,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 8395cc57..b3ef1dce 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -63,6 +63,8 @@ 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 admitUser(pubkey: Pubkey, admittedAt: Date, 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 45180fe4..6e9fc868 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -1,3 +1,4 @@ +import { IEventRepository, IUserRepository } from '../@types/repositories' import { isDeleteEvent, isEphemeralEvent, isGiftWrapEvent, isParameterizedReplaceableEvent, isReplaceableEvent, isRequestToVanishEvent } from '../utils/event' import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy' @@ -5,7 +6,6 @@ import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-e import { Event } from '../@types/event' import { Factory } from '../@types/base' import { GiftWrapEventStrategy } from '../handlers/event-strategies/gift-wrap-event-strategy' -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' @@ -14,10 +14,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 (isGiftWrapEvent(event)) { return new GiftWrapEventStrategy(adapter, eventRepository) } else if (isReplaceableEvent(event)) { diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index e1fb14b5..e26d0511 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -18,7 +18,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 bd758989..4f4cba9a 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -17,7 +17,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 nip05VerificationRepository = new Nip05VerificationRepository(dbClient) const settings = createSettings() diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index d968b993..73e7970d 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -252,8 +252,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 29da19cf..e040468b 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..a180cb7f 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,7 @@ 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 { GiftWrapEventStrategy } from '../../../src/handlers/event-strategies/gift-wrap-event-strategy' 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 +17,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', () => { @@ -58,6 +61,11 @@ describe('eventStrategyFactory', () => { expect(factory([event, adapter])).to.be.an.instanceOf(VanishEventStrategy) }) + it('returns GiftWrapEventStrategy given a gift wrap event', () => { + event.kind = EventKinds.GIFT_WRAP + expect(factory([event, adapter])).to.be.an.instanceOf(GiftWrapEventStrategy) + }) + it('returns ParameterizedReplaceableEventStrategy given a delete event', () => { event.kind = EventKinds.PARAMETERIZED_REPLACEABLE_FIRST expect(factory([event, adapter])).to.be.an.instanceOf(ParameterizedReplaceableEventStrategy) @@ -67,4 +75,4 @@ describe('eventStrategyFactory', () => { event.kind = EventKinds.TEXT_NOTE expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) }) -}) \ No newline at end of file +}) diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 22fead86..33a17b2c 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -52,6 +52,9 @@ describe('EventMessageHandler', () => { sig: 'f'.repeat(128), tags: [], } + userRepository = { + isVanished: async () => false, + } as any }) afterEach(() => { @@ -73,7 +76,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, @@ -129,11 +135,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'], ) @@ -263,7 +269,7 @@ describe('EventMessageHandler', () => { handler = new EventMessageHandler( {} as any, () => null, - { hasActiveRequestToVanish: async () => false } as any, + {} as any, userRepository, () => settings, () => ({ hit: async () => false }), @@ -787,10 +793,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 }), @@ -1055,11 +1064,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 }), @@ -1141,27 +1151,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, ''], diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index cb52f05c..2ccb0ee0 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -447,126 +447,6 @@ describe('EventRepository', () => { }) }) - describe('deleteByPubkeyExceptKinds', () => { - it('marks event as deleted by pubkey except excluded kinds', () => { - const query = repository.deleteByPubkeyExceptKinds('001122', [62]).toString() - - expect(query).to.equal('update "events" set "deleted_at" = now() where "event_pubkey" = X\'001122\' and "event_kind" not in (62) and "deleted_at" is null') - }) - }) - - describe('hasActiveRequestToVanish', () => { - it('checks for an existing active kind 62 event', async () => { - const firstStub = sandbox.stub().resolves({ event_id: Buffer.from('001122', 'hex') }) - const readReplicaStub = sandbox.stub().returns({ - select: sandbox.stub().returnsThis(), - where: sandbox.stub().returnsThis(), - whereNull: sandbox.stub().returnsThis(), - first: firstStub, - }) - repository = new EventRepository({} as any, readReplicaStub as any) - - const result = await repository.hasActiveRequestToVanish('001122') - - expect(result).to.be.true - expect(readReplicaStub).to.have.been.calledOnceWithExactly('events') - expect(firstStub).to.have.been.calledOnce - }) - - it('returns false when no kind 62 event exists', async () => { - const firstStub = sandbox.stub().resolves(undefined) - const readReplicaStub = sandbox.stub().returns({ - select: sandbox.stub().returnsThis(), - where: sandbox.stub().returnsThis(), - whereNull: sandbox.stub().returnsThis(), - first: firstStub, - }) - repository = new EventRepository({} as any, readReplicaStub as any) - - const result = await repository.hasActiveRequestToVanish('001122') - - expect(result).to.be.false - }) - }) - - describe('deleteExpiredAndRetained', () => { - let clock: sinon.SinonFakeTimers - beforeEach(() => { - clock = sinon.useFakeTimers(1000000000) // 1970-01-12T13:46:40.000Z - }) - - afterEach(() => { - clock.restore() - }) - - it('does not delete anything when retention is not set', async () => { - const result = await repository.deleteExpiredAndRetained() - - expect(result).to.deep.equal({ - deleted: 0, - expired: 0, - retained: 0, - }) - }) - - it('does not delete anything when retention.maxDays is zero or negative', async () => { - expect(await repository.deleteExpiredAndRetained({ maxDays: 0 })).to.deep.equal({ - deleted: 0, - expired: 0, - retained: 0, - }) - expect(await repository.deleteExpiredAndRetained({ maxDays: -1 })).to.deep.equal({ - deleted: 0, - expired: 0, - retained: 0, - }) - }) - - it('deletes expired, deleted and old events when retention.maxDays is set', () => { - const query = repository.deleteExpiredAndRetained({ - maxDays: 7, - }).toString() - - expect(query).to.equal('delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1000000 or "deleted_at" is not null or "event_created_at" < 395200) and not ("event_kind" = 62) limit 1000) returning "deleted_at", "expires_at", "event_created_at"') - }) - - it('excludes whitelisted kinds and pubkeys from purge', () => { - const query = repository.deleteExpiredAndRetained({ - maxDays: 7, - kindWhitelist: [62], - pubkeyWhitelist: ['001122'], - }).toString() - - expect(query).to.equal('delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1000000 or "deleted_at" is not null or "event_created_at" < 395200) and not ("event_kind" = 62) and "event_pubkey" not in (X\'001122\') limit 1000) returning "deleted_at", "expires_at", "event_created_at"') - }) - - it('always excludes kind 62 from purge, even when no kind whitelist is configured', () => { - const query = repository.deleteExpiredAndRetained({ - maxDays: 7, - }).toString() - - expect(query).to.equal('delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1000000 or "deleted_at" is not null or "event_created_at" < 395200) and not ("event_kind" = 62) limit 1000) returning "deleted_at", "expires_at", "event_created_at"') - }) - - it('excludes whitelisted kind ranges from purge', () => { - const query = repository.deleteExpiredAndRetained({ - maxDays: 7, - kindWhitelist: [[10000, 20000]], - }).toString() - - expect(query).to.equal('delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1000000 or "deleted_at" is not null or "event_created_at" < 395200) and not ("event_kind" between 10000 and 20000 or "event_kind" = 62) limit 1000) returning "deleted_at", "expires_at", "event_created_at"') - }) - - it('excludes a complex mix of kinds and ranges from purge', () => { - const query = repository.deleteExpiredAndRetained({ - maxDays: 7, - kindWhitelist: [0, 62, [30000, 40000]], - }).toString() - - expect(query).to.equal('delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1000000 or "deleted_at" is not null or "event_created_at" < 395200) and not ("event_kind" = 0 or "event_kind" = 62 or "event_kind" between 30000 and 40000) limit 1000) returning "deleted_at", "expires_at", "event_created_at"') - }) - }) - describe('upsert', () => { it('replaces event based on event_pubkey and event_kind', () => { const event: Event = { diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index a307bb93..19f2d893 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -609,4 +609,4 @@ describe('NIP-40', () => { expect(isExpiredEvent(event)).to.equal(true) }) }) -}) \ No newline at end of file +})