From 4d4c17e94d2cdd926eb331156fcd06219600bb69 Mon Sep 17 00:00:00 2001 From: codytseng Date: Thu, 3 Oct 2024 23:53:32 +0800 Subject: [PATCH 1/6] feat: nip-05 api --- .../1727942395255-create-nip-05-table.ts | 16 ++++ src/app.module.ts | 8 +- src/common/guards/admin-only.guard.spec.ts | 40 +++++++++ src/common/guards/admin-only.guard.ts | 22 +++++ src/common/guards/index.ts | 3 +- .../parse-nostr-authorization.guard.spec.ts | 83 +++++++++++++++++-- .../guards/parse-nostr-authorization.guard.ts | 16 +++- src/common/pipes/index.ts | 0 src/common/pipes/zod-validation.pipe.spec.ts | 24 ++++++ src/common/pipes/zod-validation.pipe.ts | 17 ++++ src/modules/nip-05/dtos/index.ts | 1 + .../nip-05/dtos/register-nip-05.dto.ts | 11 +++ src/modules/nip-05/nip-05.controller.spec.ts | 72 ++++++++++++++++ src/modules/nip-05/nip-05.controller.ts | 61 ++++++++++++-- src/modules/nip-05/nip-05.module.ts | 2 + src/modules/nip-05/nip-05.spec.ts | 49 ----------- src/modules/nip-05/schemas/index.ts | 1 + .../nip-05/schemas/register-nip-05.schema.ts | 6 ++ .../nostr/controllers/event.controller.ts | 3 - src/modules/repositories/constants.ts | 1 - .../repositories/event.repository.spec.ts | 30 +++---- src/modules/repositories/event.repository.ts | 18 ++-- src/modules/repositories/kysely-db.ts | 34 ++++++++ .../repositories/nip-05.repository.spec.ts | 44 ++++++++++ src/modules/repositories/nip-05.repository.ts | 31 +++++++ .../repositories/repositories.module.ts | 29 ++----- src/modules/repositories/types.ts | 7 ++ 27 files changed, 508 insertions(+), 121 deletions(-) create mode 100644 migrations/1727942395255-create-nip-05-table.ts create mode 100644 src/common/guards/admin-only.guard.spec.ts create mode 100644 src/common/guards/admin-only.guard.ts create mode 100644 src/common/pipes/index.ts create mode 100644 src/common/pipes/zod-validation.pipe.spec.ts create mode 100644 src/common/pipes/zod-validation.pipe.ts create mode 100644 src/modules/nip-05/dtos/index.ts create mode 100644 src/modules/nip-05/dtos/register-nip-05.dto.ts create mode 100644 src/modules/nip-05/nip-05.controller.spec.ts delete mode 100644 src/modules/nip-05/nip-05.spec.ts create mode 100644 src/modules/nip-05/schemas/index.ts create mode 100644 src/modules/nip-05/schemas/register-nip-05.schema.ts delete mode 100644 src/modules/repositories/constants.ts create mode 100644 src/modules/repositories/kysely-db.ts create mode 100644 src/modules/repositories/nip-05.repository.spec.ts create mode 100644 src/modules/repositories/nip-05.repository.ts diff --git a/migrations/1727942395255-create-nip-05-table.ts b/migrations/1727942395255-create-nip-05-table.ts new file mode 100644 index 00000000..4474d65e --- /dev/null +++ b/migrations/1727942395255-create-nip-05-table.ts @@ -0,0 +1,16 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('nip05') + .addColumn('name', 'varchar(20)', (col) => col.primaryKey()) + .addColumn('pubkey', 'char(64)', (col) => col.notNull()) + .addColumn('create_date', 'timestamp', (col) => + col.defaultTo(sql`now()`).notNull(), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('nip05').execute(); +} diff --git a/src/app.module.ts b/src/app.module.ts index cb1a2042..7193782b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { APP_FILTER } from '@nestjs/core'; +import { APP_FILTER, APP_GUARD } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { ThrottlerModule } from '@nestjs/throttler'; import { LoggerModule } from 'nestjs-pino'; import { GlobalExceptionFilter } from './common/filters'; +import { ParseNostrAuthorizationGuard } from './common/guards'; import { Config, config } from './config'; import { Nip05Module } from './modules/nip-05/nip-05.module'; import { NostrModule } from './modules/nostr/nostr.module'; @@ -33,6 +34,9 @@ import { loggerModuleFactory } from './utils'; Nip05Module, TaskModule, ], - providers: [{ provide: APP_FILTER, useClass: GlobalExceptionFilter }], + providers: [ + { provide: APP_FILTER, useClass: GlobalExceptionFilter }, + { provide: APP_GUARD, useClass: ParseNostrAuthorizationGuard }, + ], }) export class AppModule {} diff --git a/src/common/guards/admin-only.guard.spec.ts b/src/common/guards/admin-only.guard.spec.ts new file mode 100644 index 00000000..b0ba6d3b --- /dev/null +++ b/src/common/guards/admin-only.guard.spec.ts @@ -0,0 +1,40 @@ +import { createMock } from '@golevelup/ts-jest'; +import { AdminOnlyGuard } from './admin-only.guard'; +import { ConfigService } from '@nestjs/config'; +import { ExecutionContext } from '@nestjs/common'; + +describe('AdminOnlyGuard', () => { + let guard: AdminOnlyGuard; + + beforeEach(() => { + guard = new AdminOnlyGuard( + createMock({ + get: jest.fn().mockReturnValue({ pubkey: 'admin-pubkey' }), + }), + ); + }); + + it('should return false if adminPubkey is not set', () => { + (guard as any).adminPubkey = undefined; + const context = createMock(); + expect(guard.canActivate(context)).toBe(false); + }); + + it('should return false if pubkey is not equal to adminPubkey', () => { + const context = createMock({ + switchToHttp: () => ({ + getRequest: () => ({ pubkey: 'not-admin-pubkey' }), + }), + }); + expect(guard.canActivate(context)).toBe(false); + }); + + it('should return true if pubkey is equal to adminPubkey', () => { + const context = createMock({ + switchToHttp: () => ({ + getRequest: () => ({ pubkey: 'admin-pubkey' }), + }), + }); + expect(guard.canActivate(context)).toBe(true); + }); +}); diff --git a/src/common/guards/admin-only.guard.ts b/src/common/guards/admin-only.guard.ts new file mode 100644 index 00000000..4c1a913f --- /dev/null +++ b/src/common/guards/admin-only.guard.ts @@ -0,0 +1,22 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import { Config } from 'src/config'; + +@Injectable() +export class AdminOnlyGuard implements CanActivate { + private readonly adminPubkey: string | undefined; + + constructor(config: ConfigService) { + const relayInfo = config.get('relayInfo', { infer: true }); + this.adminPubkey = relayInfo.pubkey; + } + + canActivate(context: ExecutionContext): boolean { + if (!this.adminPubkey) { + return false; + } + const request = context.switchToHttp().getRequest(); + return request.pubkey === this.adminPubkey; + } +} diff --git a/src/common/guards/index.ts b/src/common/guards/index.ts index 92d70bc3..7bdab208 100644 --- a/src/common/guards/index.ts +++ b/src/common/guards/index.ts @@ -1,2 +1,3 @@ -export * from './ws-throttler.guard'; +export * from './admin-only.guard'; export * from './parse-nostr-authorization.guard'; +export * from './ws-throttler.guard'; diff --git a/src/common/guards/parse-nostr-authorization.guard.spec.ts b/src/common/guards/parse-nostr-authorization.guard.spec.ts index f33dd44d..26cf1930 100644 --- a/src/common/guards/parse-nostr-authorization.guard.spec.ts +++ b/src/common/guards/parse-nostr-authorization.guard.spec.ts @@ -48,7 +48,10 @@ describe('ParseNostrAuthorizationGuard', () => { it('should directly return true if event is invalid', async () => { const event = createEvent({ kind: 27235, - tags: [['u', 'http://localhost']], + tags: [ + ['u', 'http://localhost/test'], + ['method', 'GET'], + ], created_at: Math.floor(Date.now() / 1000), }); // modify event id to make it invalid @@ -68,7 +71,10 @@ describe('ParseNostrAuthorizationGuard', () => { it('should directly return true if token is expired', async () => { const event = createEvent({ kind: 27235, - tags: [['u', 'http://localhost']], + tags: [ + ['u', 'http://localhost/test'], + ['method', 'GET'], + ], created_at: Math.floor(Date.now() / 1000) - 61, // 1 minute ago }); const token = Buffer.from(JSON.stringify(event)).toString('base64'); @@ -84,7 +90,10 @@ describe('ParseNostrAuthorizationGuard', () => { it('should directly return true if event kind is not 27235', async () => { const event = createEvent({ kind: 1, - tags: [['u', 'http://localhost']], + tags: [ + ['u', 'http://localhost/test'], + ['method', 'GET'], + ], }); const token = Buffer.from(JSON.stringify(event)).toString('base64'); const authorization = 'Nostr ' + token; @@ -99,6 +108,7 @@ describe('ParseNostrAuthorizationGuard', () => { it('should directly return true if u tag is not set', async () => { const event = createEvent({ kind: 27235, + tags: [['method', 'GET']], }); const token = Buffer.from(JSON.stringify(event)).toString('base64'); const authorization = 'Nostr ' + token; @@ -113,7 +123,10 @@ describe('ParseNostrAuthorizationGuard', () => { it('should directly return true if u tag value is not the same as hostname', async () => { const event = createEvent({ kind: 1, - tags: [['u', 'http://test.com']], + tags: [ + ['u', 'http://test.com/test'], + ['method', 'GET'], + ], }); const token = Buffer.from(JSON.stringify(event)).toString('base64'); const authorization = 'Nostr ' + token; @@ -125,10 +138,64 @@ describe('ParseNostrAuthorizationGuard', () => { expect(request.pubkey).toBeUndefined(); }); + it('should directly return true if method tag value is not the same as request method', async () => { + const event = createEvent({ + kind: 27235, + tags: [ + ['u', 'http://localhost/test'], + ['method', 'POST'], + ], + }); + const token = Buffer.from(JSON.stringify(event)).toString('base64'); + const authorization = 'Nostr ' + token; + const { request, context } = createMockContext({ + authorization, + }); + + expect(await guard.canActivate(context)).toBe(true); + expect(request.pubkey).toBeUndefined(); + }); + + it('should directly return true if method tag is not set', async () => { + const event = createEvent({ + kind: 27235, + tags: [['u', 'http://localhost/test']], + }); + const token = Buffer.from(JSON.stringify(event)).toString('base64'); + const authorization = 'Nostr ' + token; + const { request, context } = createMockContext({ + authorization, + }); + + expect(await guard.canActivate(context)).toBe(true); + expect(request.pubkey).toBeUndefined(); + }); + + it('should directly return true if url pathname is not the same as request url', async () => { + const event = createEvent({ + kind: 27235, + tags: [ + ['u', 'http://localhost/test2'], + ['method', 'GET'], + ], + }); + const token = Buffer.from(JSON.stringify(event)).toString('base64'); + const authorization = 'Nostr ' + token; + const { request, context } = createMockContext({ + authorization, + }); + + expect(await guard.canActivate(context)).toBe(true); + expect(request.pubkey).toBeUndefined; + }); + it('should parse pubkey sucessfully', async () => { const event = createEvent({ kind: 27235, - tags: [['u', 'http://localhost']], + tags: [ + ['u', 'http://localhost/test'], + ['method', 'GET'], + ], }); const token = Buffer.from(JSON.stringify(event)).toString('base64'); const authorization = 'Nostr ' + token; @@ -142,9 +209,11 @@ describe('ParseNostrAuthorizationGuard', () => { }); function createMockContext(headers: Record = {}) { - const request: { headers: Record; pubkey?: string } = { + const request = { headers, - }; + url: '/test', + method: 'GET', + } as any; const context = createMock({ getClass: jest.fn().mockReturnValue({ name: 'Test' }), getHandler: jest.fn().mockReturnValue({ name: 'test' }), diff --git a/src/common/guards/parse-nostr-authorization.guard.ts b/src/common/guards/parse-nostr-authorization.guard.ts index 188fffc3..0a16eb08 100644 --- a/src/common/guards/parse-nostr-authorization.guard.ts +++ b/src/common/guards/parse-nostr-authorization.guard.ts @@ -48,7 +48,21 @@ export class ParseNostrAuthorizationGuard implements CanActivate { } const uTagValue = event.tags.find(([tagName]) => tagName === 'u')?.[1]; - if (!uTagValue || new URL(uTagValue).hostname !== this.hostname) { + if (!uTagValue) { + return true; + } + const url = new URL(uTagValue); + if (url.hostname !== this.hostname || url.pathname !== request.url) { + return true; + } + + const methodTagValue = event.tags.find( + ([tagName]) => tagName === 'method', + )?.[1]; + if ( + !methodTagValue || + methodTagValue.toUpperCase() !== request.method.toUpperCase() + ) { return true; } diff --git a/src/common/pipes/index.ts b/src/common/pipes/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/common/pipes/zod-validation.pipe.spec.ts b/src/common/pipes/zod-validation.pipe.spec.ts new file mode 100644 index 00000000..7ebbb159 --- /dev/null +++ b/src/common/pipes/zod-validation.pipe.spec.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { ZodValidationPipe } from './zod-validation.pipe'; +import { BadRequestException } from '@nestjs/common'; + +describe('ZodValidationPipe', () => { + const schema = z.object({ + test: z.string(), + }); + let pipe: ZodValidationPipe; + + beforeEach(() => { + pipe = new ZodValidationPipe(schema); + }); + + it('should return parsed value', () => { + const value = { test: 'test' }; + expect(pipe.transform(value)).toEqual(value); + }); + + it('should throw BadRequestException on error', () => { + const value = { test: 1 }; + expect(() => pipe.transform(value)).toThrow(BadRequestException); + }); +}); diff --git a/src/common/pipes/zod-validation.pipe.ts b/src/common/pipes/zod-validation.pipe.ts new file mode 100644 index 00000000..4509770a --- /dev/null +++ b/src/common/pipes/zod-validation.pipe.ts @@ -0,0 +1,17 @@ +import { BadRequestException, PipeTransform } from '@nestjs/common'; +import { ZodSchema } from 'zod'; +import { fromError } from 'zod-validation-error'; + +export class ZodValidationPipe implements PipeTransform { + constructor(private schema: ZodSchema) {} + + transform(value: unknown) { + try { + const parsedValue = this.schema.parse(value); + return parsedValue; + } catch (error) { + const validationError = fromError(error); + throw new BadRequestException(validationError.toString()); + } + } +} diff --git a/src/modules/nip-05/dtos/index.ts b/src/modules/nip-05/dtos/index.ts new file mode 100644 index 00000000..7d7ab448 --- /dev/null +++ b/src/modules/nip-05/dtos/index.ts @@ -0,0 +1 @@ +export * from './register-nip-05.dto'; diff --git a/src/modules/nip-05/dtos/register-nip-05.dto.ts b/src/modules/nip-05/dtos/register-nip-05.dto.ts new file mode 100644 index 00000000..d58fd74a --- /dev/null +++ b/src/modules/nip-05/dtos/register-nip-05.dto.ts @@ -0,0 +1,11 @@ +export class RegisterNip05Dto { + /** + * The name of the NIP-05. + */ + name: string; + + /** + * The public key of the NIP-05. + */ + pubkey: string; +} diff --git a/src/modules/nip-05/nip-05.controller.spec.ts b/src/modules/nip-05/nip-05.controller.spec.ts new file mode 100644 index 00000000..1dbef130 --- /dev/null +++ b/src/modules/nip-05/nip-05.controller.spec.ts @@ -0,0 +1,72 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Nip05Controller } from './nip-05.controller'; +import { ConfigService } from '@nestjs/config'; +import { Nip05Repository } from '../repositories/nip-05.repository'; + +describe('Nip05Controller', () => { + let controller: Nip05Controller; + + beforeEach(() => { + controller = new Nip05Controller( + createMock({ + get: jest.fn().mockReturnValue('admin-pubkey'), + }), + createMock(), + ); + }); + + describe('get', () => { + it('should return empty JSON object when no name is provided', async () => { + const result = await controller.get(); + expect(result).toEqual({}); + }); + + it('should return admin pubkey when name is "_"', async () => { + const result = await controller.get('_'); + expect(result).toEqual({ + names: { + _: 'admin-pubkey', + }, + }); + }); + + it('should return empty JSON object when name is unknown', async () => { + jest + .spyOn(controller['nip05Repository'], 'getPubkeyByName') + .mockResolvedValue(undefined); + const result = await controller.get('unknown'); + expect(result).toEqual({}); + }); + + it('should return pubkey when name is known', async () => { + jest + .spyOn(controller['nip05Repository'], 'getPubkeyByName') + .mockResolvedValue('pubkey'); + const result = await controller.get('known'); + expect(result).toEqual({ + names: { + known: 'pubkey', + }, + }); + }); + }); + + describe('register', () => { + it('should register a new NIP-05 identity', async () => { + jest.spyOn(controller['nip05Repository'], 'register').mockResolvedValue(); + const result = await controller.register({ + name: 'name', + pubkey: 'pubkey', + }); + expect(result).toBeUndefined(); + }); + }); + + describe('delete', () => { + it('should delete a NIP-05 identity by name', async () => { + jest.spyOn(controller['nip05Repository'], 'delete').mockResolvedValue(); + const result = await controller.delete('name'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/nip-05/nip-05.controller.ts b/src/modules/nip-05/nip-05.controller.ts index a2ece32f..0bfef0ac 100644 --- a/src/modules/nip-05/nip-05.controller.ts +++ b/src/modules/nip-05/nip-05.controller.ts @@ -1,27 +1,78 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ApiTags } from '@nestjs/swagger'; import { Config } from 'src/config'; +import { AdminOnlyGuard } from '../../common/guards'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { Nip05Repository } from '../repositories/nip-05.repository'; +import { RegisterNip05Dto } from './dtos'; +import { RegisterNip05Schema } from './schemas'; @Controller() @ApiTags('nip-05') export class Nip05Controller { private readonly adminPubkey?: string; - constructor(configService: ConfigService) { + constructor( + configService: ConfigService, + private readonly nip05Repository: Nip05Repository, + ) { this.adminPubkey = configService.get('relayInfo.pubkey', { infer: true, }); } @Get('.well-known/nostr.json') - async nip05(@Query('name') name?: string) { - return name === '_' && this.adminPubkey + async get(@Query('name') name?: string) { + if (!name) { + return {}; + } + + if (name === '_' && this.adminPubkey) { + return { + names: { + _: this.adminPubkey, + }, + }; + } + + const pubkey = await this.nip05Repository.getPubkeyByName(name); + return pubkey ? { names: { - _: this.adminPubkey, + [name]: pubkey, }, } : {}; } + + /** + * Register a new NIP-05 identity. + */ + @Post('api/v1/nip-05') + @UseGuards(AdminOnlyGuard) + async register( + @Body(new ZodValidationPipe(RegisterNip05Schema)) + { name, pubkey }: RegisterNip05Dto, + ) { + await this.nip05Repository.register(name, pubkey); + } + + /** + * Delete a NIP-05 identity by name. + */ + @Delete('api/v1/nip-05/:name') + @UseGuards(AdminOnlyGuard) + async delete(@Param('name') name: string) { + await this.nip05Repository.delete(name); + } } diff --git a/src/modules/nip-05/nip-05.module.ts b/src/modules/nip-05/nip-05.module.ts index 1f04ef3c..6b11db48 100644 --- a/src/modules/nip-05/nip-05.module.ts +++ b/src/modules/nip-05/nip-05.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { Nip05Controller } from './nip-05.controller'; +import { RepositoriesModule } from '../repositories/repositories.module'; @Module({ + imports: [RepositoriesModule], controllers: [Nip05Controller], }) export class Nip05Module {} diff --git a/src/modules/nip-05/nip-05.spec.ts b/src/modules/nip-05/nip-05.spec.ts deleted file mode 100644 index 12c9dc0c..00000000 --- a/src/modules/nip-05/nip-05.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { Test } from '@nestjs/testing'; -import * as request from 'supertest'; -import { Nip05Module } from './nip-05.module'; - -describe('NIP-05', () => { - const pubkey = - 'a09659cd9ee89cd3743bc29aa67edf1d7d12fb624699fcd3d6d33eef250b01e7'; - let app: INestApplication; - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - ConfigModule.forRoot({ - load: [() => ({ 'relayInfo.pubkey': pubkey })], - cache: true, - isGlobal: true, - }), - Nip05Module, - ], - }).compile(); - - app = moduleRef.createNestApplication(); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - it('should return admin pubkey', () => { - return request(app.getHttpServer()) - .get('/.well-known/nostr.json?name=_') - .expect(200) - .expect({ - names: { - _: pubkey, - }, - }); - }); - - it('should return empty JSON object when no pubkey is configured', () => { - return request(app.getHttpServer()) - .get('/.well-known/nostr.json?name=unknown') - .expect(200) - .expect({}); - }); -}); diff --git a/src/modules/nip-05/schemas/index.ts b/src/modules/nip-05/schemas/index.ts new file mode 100644 index 00000000..f5833789 --- /dev/null +++ b/src/modules/nip-05/schemas/index.ts @@ -0,0 +1 @@ +export * from './register-nip-05.schema'; diff --git a/src/modules/nip-05/schemas/register-nip-05.schema.ts b/src/modules/nip-05/schemas/register-nip-05.schema.ts new file mode 100644 index 00000000..f666b1e0 --- /dev/null +++ b/src/modules/nip-05/schemas/register-nip-05.schema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const RegisterNip05Schema = z.object({ + name: z.string().max(20), + pubkey: z.string().regex(/^[0-9a-f]{64}$/), +}); diff --git a/src/modules/nostr/controllers/event.controller.ts b/src/modules/nostr/controllers/event.controller.ts index 5e5b86f0..461a6d0d 100644 --- a/src/modules/nostr/controllers/event.controller.ts +++ b/src/modules/nostr/controllers/event.controller.ts @@ -8,11 +8,9 @@ import { Param, Post, Query, - UseGuards, } from '@nestjs/common'; import { ApiResponse, ApiTags } from '@nestjs/swagger'; import { Pubkey } from '../../../common/decorators'; -import { ParseNostrAuthorizationGuard } from '../../../common/guards'; import { FindEventsDto, HandleEventDto, RequestEventsDto } from '../dtos'; import { EventEntity, FilterEntity } from '../entities'; import { EventIdSchema } from '../schemas'; @@ -27,7 +25,6 @@ import { @Controller('api/v1/events') @ApiTags('events') -@UseGuards(ParseNostrAuthorizationGuard) export class EventController { constructor(private readonly nostrRelayService: NostrRelayService) {} diff --git a/src/modules/repositories/constants.ts b/src/modules/repositories/constants.ts deleted file mode 100644 index 88f88212..00000000 --- a/src/modules/repositories/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const KYSELY_DB = 'KYSELY_DB'; diff --git a/src/modules/repositories/event.repository.spec.ts b/src/modules/repositories/event.repository.spec.ts index cf0146da..58b19516 100644 --- a/src/modules/repositories/event.repository.spec.ts +++ b/src/modules/repositories/event.repository.spec.ts @@ -1,11 +1,13 @@ +import 'dotenv/config'; + import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from '@nestjs/config'; import { EventKind, getTimestampInSeconds } from '@nostr-relay/common'; -import 'dotenv/config'; -import { Kysely, PostgresDialect } from 'kysely'; -import * as pg from 'pg'; +import { Kysely } from 'kysely'; import { createEvent } from '../../../test-utils/event'; import { EventSearchRepository } from './event-search.repository'; import { EventRepository } from './event.repository'; +import { KyselyDb } from './kysely-db'; import { Database } from './types'; describe('EventRepository', () => { @@ -14,7 +16,14 @@ describe('EventRepository', () => { beforeEach(async () => { eventRepository = new EventRepository( - createDb(), + new KyselyDb( + createMock({ + get: jest.fn().mockReturnValue({ + url: process.env.TEST_DATABASE_URL, + maxConnections: 20, + }), + }), + ), createMock(), ); db = eventRepository['db']; @@ -485,16 +494,3 @@ describe('EventRepository', () => { }); }); }); - -function createDb() { - const int8TypeId = 20; - pg.types.setTypeParser(int8TypeId, (val) => parseInt(val, 10)); - - const dialect = new PostgresDialect({ - pool: new pg.Pool({ - connectionString: process.env.TEST_DATABASE_URL, - max: 50, - }), - }); - return new Kysely({ dialect }); -} diff --git a/src/modules/repositories/event.repository.ts b/src/modules/repositories/event.repository.ts index f87876e4..43c5565c 100644 --- a/src/modules/repositories/event.repository.ts +++ b/src/modules/repositories/event.repository.ts @@ -1,4 +1,4 @@ -import { BeforeApplicationShutdown, Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Event, EventUtils, @@ -10,20 +10,20 @@ import { Kysely, sql } from 'kysely'; import { isNil } from 'lodash'; import { TEventIdWithScore } from '../../types/event'; import { isGenericTagName, toGenericTag } from '../../utils'; -import { KYSELY_DB } from './constants'; import { EventSearchRepository } from './event-search.repository'; +import { KyselyDb } from './kysely-db'; import { Database, EventRow } from './types'; @Injectable() -export class EventRepository - extends IEventRepository - implements BeforeApplicationShutdown -{ +export class EventRepository extends IEventRepository { + private readonly db: Kysely; + constructor( - @Inject(KYSELY_DB) private readonly db: Kysely, + kyselyDb: KyselyDb, private readonly eventSearchRepository: EventSearchRepository, ) { super(); + this.db = kyselyDb.getDb(); } isSearchSupported(): boolean { @@ -332,8 +332,4 @@ export class EventRepository }); return [...genericTagSet]; } - - async beforeApplicationShutdown() { - await this.db.destroy(); - } } diff --git a/src/modules/repositories/kysely-db.ts b/src/modules/repositories/kysely-db.ts new file mode 100644 index 00000000..87bca7e7 --- /dev/null +++ b/src/modules/repositories/kysely-db.ts @@ -0,0 +1,34 @@ +import { BeforeApplicationShutdown, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Kysely, PostgresDialect } from 'kysely'; +import * as pg from 'pg'; +import { Config } from 'src/config'; +import { Database } from './types'; + +@Injectable() +export class KyselyDb implements BeforeApplicationShutdown { + private readonly db: Kysely; + + constructor(config: ConfigService) { + const databaseConfig = config.get('database', { infer: true }); + + const int8TypeId = 20; + pg.types.setTypeParser(int8TypeId, (val) => parseInt(val, 10)); + + const dialect = new PostgresDialect({ + pool: new pg.Pool({ + connectionString: databaseConfig.url, + max: databaseConfig.maxConnections, + }), + }); + this.db = new Kysely({ dialect }); + } + + getDb() { + return this.db; + } + + async beforeApplicationShutdown() { + await this.db.destroy(); + } +} diff --git a/src/modules/repositories/nip-05.repository.spec.ts b/src/modules/repositories/nip-05.repository.spec.ts new file mode 100644 index 00000000..a6cae248 --- /dev/null +++ b/src/modules/repositories/nip-05.repository.spec.ts @@ -0,0 +1,44 @@ +import 'dotenv/config'; + +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from '@nestjs/config'; +import { KyselyDb } from './kysely-db'; +import { Nip05Repository } from './nip-05.repository'; + +describe('EventRepository', () => { + let kyselyDb: KyselyDb; + let nip05Repository: Nip05Repository; + + beforeEach(async () => { + kyselyDb = new KyselyDb( + createMock({ + get: jest.fn().mockReturnValue({ + url: process.env.TEST_DATABASE_URL, + maxConnections: 20, + }), + }), + ); + nip05Repository = new Nip05Repository(kyselyDb); + }); + + afterEach(async () => { + const db = kyselyDb.getDb(); + await db.deleteFrom('nip05').execute(); + await db.destroy(); + }); + + it('should register, get and delete NIP-05 identity', async () => { + expect(await nip05Repository.getPubkeyByName('test')).toBeUndefined(); + + await nip05Repository.register( + 'test', + 'a09659cd9ee89cd3743bc29aa67edf1d7d12fb624699fcd3d6d33eef250b01e7', + ); + expect(await nip05Repository.getPubkeyByName('test')).toBe( + 'a09659cd9ee89cd3743bc29aa67edf1d7d12fb624699fcd3d6d33eef250b01e7', + ); + + await nip05Repository.delete('test'); + expect(await nip05Repository.getPubkeyByName('test')).toBeUndefined(); + }); +}); diff --git a/src/modules/repositories/nip-05.repository.ts b/src/modules/repositories/nip-05.repository.ts new file mode 100644 index 00000000..464c6861 --- /dev/null +++ b/src/modules/repositories/nip-05.repository.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { KyselyDb } from './kysely-db'; +import { Database } from './types'; + +@Injectable() +export class Nip05Repository { + private readonly db: Kysely; + + constructor(kyselyDb: KyselyDb) { + this.db = kyselyDb.getDb(); + } + + async getPubkeyByName(name: string): Promise { + const result = await this.db + .selectFrom('nip05') + .select('pubkey') + .where('name', '=', name) + .executeTakeFirst(); + + return result?.pubkey; + } + + async register(name: string, pubkey: string): Promise { + await this.db.insertInto('nip05').values({ name, pubkey }).execute(); + } + + async delete(name: string): Promise { + await this.db.deleteFrom('nip05').where('name', '=', name).execute(); + } +} diff --git a/src/modules/repositories/repositories.module.ts b/src/modules/repositories/repositories.module.ts index 4b1be432..7a2a0905 100644 --- a/src/modules/repositories/repositories.module.ts +++ b/src/modules/repositories/repositories.module.ts @@ -1,35 +1,16 @@ import { Module } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Kysely, PostgresDialect } from 'kysely'; -import * as pg from 'pg'; -import { Config } from 'src/config'; -import { KYSELY_DB } from './constants'; import { EventSearchRepository } from './event-search.repository'; import { EventRepository } from './event.repository'; +import { KyselyDb } from './kysely-db'; +import { Nip05Repository } from './nip-05.repository'; @Module({ providers: [ - { - provide: KYSELY_DB, - useFactory: (configService: ConfigService) => { - const databaseConfig = configService.get('database', { infer: true }); - - const int8TypeId = 20; - pg.types.setTypeParser(int8TypeId, (val) => parseInt(val, 10)); - - const dialect = new PostgresDialect({ - pool: new pg.Pool({ - connectionString: databaseConfig.url, - max: databaseConfig.maxConnections, - }), - }); - return new Kysely({ dialect }); - }, - inject: [ConfigService], - }, + KyselyDb, EventRepository, EventSearchRepository, + Nip05Repository, ], - exports: [EventRepository, EventSearchRepository], + exports: [EventRepository, EventSearchRepository, Nip05Repository], }) export class RepositoriesModule {} diff --git a/src/modules/repositories/types.ts b/src/modules/repositories/types.ts index 82fcf089..7c119fe7 100644 --- a/src/modules/repositories/types.ts +++ b/src/modules/repositories/types.ts @@ -3,6 +3,7 @@ import { ColumnType, Generated, JSONColumnType, Selectable } from 'kysely'; export interface Database { events: EventTable; generic_tags: GenericTagTable; + nip05: Nip05Table; } interface EventTable { @@ -29,3 +30,9 @@ interface GenericTagTable { event_id: string; created_at: number; } + +interface Nip05Table { + name: string; + pubkey: string; + create_date: ColumnType; +} From de7e88ca939ce42f0c7f3f92e5bf5cae9af5d5d6 Mon Sep 17 00:00:00 2001 From: codytseng Date: Fri, 4 Oct 2024 00:05:20 +0800 Subject: [PATCH 2/6] fix: lint --- src/common/guards/parse-nostr-authorization.guard.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/guards/parse-nostr-authorization.guard.spec.ts b/src/common/guards/parse-nostr-authorization.guard.spec.ts index 26cf1930..a60be71e 100644 --- a/src/common/guards/parse-nostr-authorization.guard.spec.ts +++ b/src/common/guards/parse-nostr-authorization.guard.spec.ts @@ -186,10 +186,10 @@ describe('ParseNostrAuthorizationGuard', () => { }); expect(await guard.canActivate(context)).toBe(true); - expect(request.pubkey).toBeUndefined; + expect(request.pubkey).toBeUndefined(); }); - it('should parse pubkey sucessfully', async () => { + it('should parse pubkey successfully', async () => { const event = createEvent({ kind: 27235, tags: [ From 35e0ff3300f1bc82c2c71640406ea773ac86b704 Mon Sep 17 00:00:00 2001 From: codytseng Date: Fri, 4 Oct 2024 00:06:46 +0800 Subject: [PATCH 3/6] feat: update validation for name field --- src/modules/nip-05/schemas/register-nip-05.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/nip-05/schemas/register-nip-05.schema.ts b/src/modules/nip-05/schemas/register-nip-05.schema.ts index f666b1e0..bf658886 100644 --- a/src/modules/nip-05/schemas/register-nip-05.schema.ts +++ b/src/modules/nip-05/schemas/register-nip-05.schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; export const RegisterNip05Schema = z.object({ - name: z.string().max(20), + name: z.string().regex(/^[a-zA-Z0-9_]{1,20}$/), pubkey: z.string().regex(/^[0-9a-f]{64}$/), }); From d7a245a271c5b406ebb41eb6c20d67b642dd28d6 Mon Sep 17 00:00:00 2001 From: codytseng Date: Fri, 4 Oct 2024 16:23:17 +0800 Subject: [PATCH 4/6] feat: more api --- .../filters/global-exception.filter.spec.ts | 16 +++- src/common/filters/global-exception.filter.ts | 15 +++- src/{modules/nostr => common}/vos/error.vo.ts | 0 src/common/vos/index.ts | 1 + src/modules/nip-05/dtos/index.ts | 1 + src/modules/nip-05/dtos/list-nip-05.dto.ts | 10 +++ src/modules/nip-05/entities/index.ts | 1 + src/modules/nip-05/entities/nip05.entity.ts | 24 ++++++ src/modules/nip-05/nip-05.controller.spec.ts | 62 +++++++++++++-- src/modules/nip-05/nip-05.controller.ts | 75 +++++++++++++++++-- src/modules/nip-05/schemas/index.ts | 1 + .../nip-05/schemas/list-nip-05.schema.spec.ts | 10 +++ .../nip-05/schemas/list-nip-05.schema.ts | 8 ++ src/modules/nip-05/vos/get-nip05.vo.ts | 8 ++ src/modules/nip-05/vos/index.ts | 2 + src/modules/nip-05/vos/list-nip05.vo.ts | 8 ++ .../controllers/event.controller.spec.ts | 2 +- .../nostr/controllers/event.controller.ts | 22 ++---- src/modules/nostr/vos/handle-event.vo.ts | 3 - src/modules/nostr/vos/index.ts | 2 - .../repositories/nip-05.repository.spec.ts | 15 ++++ src/modules/repositories/nip-05.repository.ts | 20 ++++- src/modules/repositories/types.ts | 1 + .../wot/vos/check-pubkey-trusted.vo.ts | 7 +- src/modules/wot/wot.controller.spec.ts | 5 +- src/modules/wot/wot.controller.ts | 3 +- src/modules/wot/wot.service.spec.ts | 10 +-- src/modules/wot/wot.service.ts | 5 +- 28 files changed, 275 insertions(+), 62 deletions(-) rename src/{modules/nostr => common}/vos/error.vo.ts (100%) create mode 100644 src/common/vos/index.ts create mode 100644 src/modules/nip-05/dtos/list-nip-05.dto.ts create mode 100644 src/modules/nip-05/entities/index.ts create mode 100644 src/modules/nip-05/entities/nip05.entity.ts create mode 100644 src/modules/nip-05/schemas/list-nip-05.schema.spec.ts create mode 100644 src/modules/nip-05/schemas/list-nip-05.schema.ts create mode 100644 src/modules/nip-05/vos/get-nip05.vo.ts create mode 100644 src/modules/nip-05/vos/index.ts create mode 100644 src/modules/nip-05/vos/list-nip05.vo.ts delete mode 100644 src/modules/nostr/vos/handle-event.vo.ts diff --git a/src/common/filters/global-exception.filter.spec.ts b/src/common/filters/global-exception.filter.spec.ts index 22273c12..6c2aefb3 100644 --- a/src/common/filters/global-exception.filter.spec.ts +++ b/src/common/filters/global-exception.filter.spec.ts @@ -1,5 +1,5 @@ import { createMock } from '@golevelup/ts-jest'; -import { ArgumentsHost, HttpException } from '@nestjs/common'; +import { ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; import { createOutgoingNoticeMessage } from '@nostr-relay/common'; import { PinoLogger } from 'nestjs-pino'; import { ClientException } from '../exceptions'; @@ -74,7 +74,11 @@ describe('GlobalExceptionFilter', () => { globalExceptionFilter.catch(error, host); expect(mockStatus).toHaveBeenCalledWith(500); - expect(mockSend).toHaveBeenCalledWith('Internal Server Error!'); + expect(mockSend).toHaveBeenCalledWith({ + message: 'Internal Server Error!', + error: 'Internal Error', + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + }); }); it('should return ISE when catch a 5xx http exception', () => { @@ -96,7 +100,11 @@ describe('GlobalExceptionFilter', () => { globalExceptionFilter.catch(error, host); expect(mockStatus).toHaveBeenCalledWith(501); - expect(mockSend).toHaveBeenCalledWith('Internal Server Error!'); + expect(mockSend).toHaveBeenCalledWith({ + message: 'Internal Server Error!', + error: 'Internal Error', + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + }); }); it('should return error msg when catch a 4xx http exception', () => { @@ -118,7 +126,7 @@ describe('GlobalExceptionFilter', () => { globalExceptionFilter.catch(error, host); expect(mockStatus).toHaveBeenCalledWith(404); - expect(mockSend).toHaveBeenCalledWith('test'); + expect(mockSend).toHaveBeenCalledWith(error.getResponse()); }); }); diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts index c33990c2..34d3896b 100644 --- a/src/common/filters/global-exception.filter.ts +++ b/src/common/filters/global-exception.filter.ts @@ -3,6 +3,7 @@ import { Catch, ExceptionFilter, HttpException, + HttpStatus, NotFoundException, } from '@nestjs/common'; import { createOutgoingNoticeMessage } from '@nostr-relay/common'; @@ -47,14 +48,22 @@ export class GlobalExceptionFilter implements ExceptionFilter { private handleHttpException(error: Error, response: Response) { if (!(error instanceof HttpException)) { - return response.status(500).send('Internal Server Error!'); + return response.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ + message: 'Internal Server Error!', + error: 'Internal Error', + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + }); } const status = error.getStatus(); if (status >= 500 && status < 600) { - return response.status(status).send('Internal Server Error!'); + return response.status(status).send({ + message: 'Internal Server Error!', + error: 'Internal Error', + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + }); } - return response.status(status).send(error.message); + return response.status(status).send(error.getResponse()); } } diff --git a/src/modules/nostr/vos/error.vo.ts b/src/common/vos/error.vo.ts similarity index 100% rename from src/modules/nostr/vos/error.vo.ts rename to src/common/vos/error.vo.ts diff --git a/src/common/vos/index.ts b/src/common/vos/index.ts new file mode 100644 index 00000000..4f8d37eb --- /dev/null +++ b/src/common/vos/index.ts @@ -0,0 +1 @@ +export * from './error.vo'; diff --git a/src/modules/nip-05/dtos/index.ts b/src/modules/nip-05/dtos/index.ts index 7d7ab448..513e8658 100644 --- a/src/modules/nip-05/dtos/index.ts +++ b/src/modules/nip-05/dtos/index.ts @@ -1 +1,2 @@ +export * from './list-nip-05.dto'; export * from './register-nip-05.dto'; diff --git a/src/modules/nip-05/dtos/list-nip-05.dto.ts b/src/modules/nip-05/dtos/list-nip-05.dto.ts new file mode 100644 index 00000000..518358d3 --- /dev/null +++ b/src/modules/nip-05/dtos/list-nip-05.dto.ts @@ -0,0 +1,10 @@ +export class ListNip05Dto { + /** + * Maximum number of NIP-05 identities to return. + */ + limit?: number; + /** + * Return NIP-05 identities after this cursor. + */ + after?: string; +} diff --git a/src/modules/nip-05/entities/index.ts b/src/modules/nip-05/entities/index.ts new file mode 100644 index 00000000..763691a4 --- /dev/null +++ b/src/modules/nip-05/entities/index.ts @@ -0,0 +1 @@ +export * from './nip05.entity'; diff --git a/src/modules/nip-05/entities/nip05.entity.ts b/src/modules/nip-05/entities/nip05.entity.ts new file mode 100644 index 00000000..aa70ec11 --- /dev/null +++ b/src/modules/nip-05/entities/nip05.entity.ts @@ -0,0 +1,24 @@ +import { Nip05Row } from '../../../modules/repositories/types'; + +export class Nip05Entity { + /** + * NIP-05 identity name. + */ + name: string; + + /** + * NIP-05 identity pubkey. + */ + pubkey: string; + + /** + * NIP-05 identity created at timestamp in milliseconds. + */ + created_at: number; + + constructor(raw: Nip05Row) { + this.name = raw.name; + this.pubkey = raw.pubkey; + this.created_at = raw.create_date.getTime(); + } +} diff --git a/src/modules/nip-05/nip-05.controller.spec.ts b/src/modules/nip-05/nip-05.controller.spec.ts index 1dbef130..b9f0ec8d 100644 --- a/src/modules/nip-05/nip-05.controller.spec.ts +++ b/src/modules/nip-05/nip-05.controller.spec.ts @@ -2,6 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { Nip05Controller } from './nip-05.controller'; import { ConfigService } from '@nestjs/config'; import { Nip05Repository } from '../repositories/nip-05.repository'; +import { Nip05Entity } from './entities'; describe('Nip05Controller', () => { let controller: Nip05Controller; @@ -15,14 +16,14 @@ describe('Nip05Controller', () => { ); }); - describe('get', () => { + describe('nip05', () => { it('should return empty JSON object when no name is provided', async () => { - const result = await controller.get(); + const result = await controller.nip05(); expect(result).toEqual({}); }); it('should return admin pubkey when name is "_"', async () => { - const result = await controller.get('_'); + const result = await controller.nip05('_'); expect(result).toEqual({ names: { _: 'admin-pubkey', @@ -34,7 +35,7 @@ describe('Nip05Controller', () => { jest .spyOn(controller['nip05Repository'], 'getPubkeyByName') .mockResolvedValue(undefined); - const result = await controller.get('unknown'); + const result = await controller.nip05('unknown'); expect(result).toEqual({}); }); @@ -42,7 +43,7 @@ describe('Nip05Controller', () => { jest .spyOn(controller['nip05Repository'], 'getPubkeyByName') .mockResolvedValue('pubkey'); - const result = await controller.get('known'); + const result = await controller.nip05('known'); expect(result).toEqual({ names: { known: 'pubkey', @@ -62,6 +63,57 @@ describe('Nip05Controller', () => { }); }); + describe('get', () => { + it('should return the specified NIP-05 identity', async () => { + const identity = { + name: 'name', + pubkey: 'pubkey', + create_date: new Date(), + }; + jest + .spyOn(controller['nip05Repository'], 'getByName') + .mockResolvedValue(identity); + + const result = await controller.get('name'); + expect(result).toEqual({ data: new Nip05Entity(identity) }); + }); + + it('should throw a NotFoundException when the identity is not found', async () => { + jest + .spyOn(controller['nip05Repository'], 'getByName') + .mockResolvedValue(undefined); + + await expect(controller.get('name')).rejects.toThrow( + 'NIP-05 identity not found', + ); + }); + }); + + describe('list', () => { + it('should list NIP-05 identities', async () => { + const identities = [ + { + name: 'name1', + pubkey: 'pubkey1', + create_date: new Date(), + }, + { + name: 'name2', + pubkey: 'pubkey2', + create_date: new Date(), + }, + ]; + jest + .spyOn(controller['nip05Repository'], 'list') + .mockResolvedValue(identities); + + const result = await controller.list({ limit: 10, after: 'name' }); + expect(result).toEqual({ + data: identities.map((row) => new Nip05Entity(row)), + }); + }); + }); + describe('delete', () => { it('should delete a NIP-05 identity by name', async () => { jest.spyOn(controller['nip05Repository'], 'delete').mockResolvedValue(); diff --git a/src/modules/nip-05/nip-05.controller.ts b/src/modules/nip-05/nip-05.controller.ts index 0bfef0ac..90044426 100644 --- a/src/modules/nip-05/nip-05.controller.ts +++ b/src/modules/nip-05/nip-05.controller.ts @@ -1,21 +1,27 @@ import { Body, + ConflictException, Controller, Delete, Get, + HttpStatus, + NotFoundException, Param, Post, Query, UseGuards, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiExcludeEndpoint, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Config } from 'src/config'; import { AdminOnlyGuard } from '../../common/guards'; import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { ErrorVo } from '../../common/vos'; import { Nip05Repository } from '../repositories/nip-05.repository'; -import { RegisterNip05Dto } from './dtos'; -import { RegisterNip05Schema } from './schemas'; +import { ListNip05Dto, RegisterNip05Dto } from './dtos'; +import { Nip05Entity } from './entities'; +import { ListNip05Schema, RegisterNip05Schema } from './schemas'; +import { GetNip05Vo, ListNip05Vo } from './vos'; @Controller() @ApiTags('nip-05') @@ -32,7 +38,8 @@ export class Nip05Controller { } @Get('.well-known/nostr.json') - async get(@Query('name') name?: string) { + @ApiExcludeEndpoint() + async nip05(@Query('name') name?: string) { if (!name) { return {}; } @@ -56,19 +63,73 @@ export class Nip05Controller { } /** - * Register a new NIP-05 identity. + * (ADMIN ONLY) Register a new NIP-05 identity. */ @Post('api/v1/nip-05') @UseGuards(AdminOnlyGuard) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: 'NIP-05 identity with this name already exists', + type: ErrorVo, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid NIP-05 identity', + type: ErrorVo, + }) async register( @Body(new ZodValidationPipe(RegisterNip05Schema)) { name, pubkey }: RegisterNip05Dto, ) { - await this.nip05Repository.register(name, pubkey); + try { + await this.nip05Repository.register(name, pubkey); + } catch (error) { + if (error.code === '23505') { + throw new ConflictException( + 'NIP-05 identity with this name already exists', + ); + } + } + } + + /** + * Get a NIP-05 identity by name. + */ + @Get('api/v1/nip-05/:name') + @ApiResponse({ type: GetNip05Vo }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'NIP-05 identity not found', + type: ErrorVo, + }) + async get(@Param('name') name: string): Promise { + const row = await this.nip05Repository.getByName(name); + if (!row) { + throw new NotFoundException('NIP-05 identity not found'); + } + return { data: new Nip05Entity(row) }; + } + + /** + * List NIP-05 identities. + */ + @Get('api/v1/nip-05') + @ApiResponse({ type: ListNip05Vo }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid query parameters', + type: ErrorVo, + }) + async list( + @Query(new ZodValidationPipe(ListNip05Schema)) + { limit, after }: ListNip05Dto, + ): Promise { + const rows = await this.nip05Repository.list(limit, after); + return { data: rows.map((row) => new Nip05Entity(row)) }; } /** - * Delete a NIP-05 identity by name. + * (ADMIN ONLY) Delete a NIP-05 identity by name. */ @Delete('api/v1/nip-05/:name') @UseGuards(AdminOnlyGuard) diff --git a/src/modules/nip-05/schemas/index.ts b/src/modules/nip-05/schemas/index.ts index f5833789..7a7acd4a 100644 --- a/src/modules/nip-05/schemas/index.ts +++ b/src/modules/nip-05/schemas/index.ts @@ -1 +1,2 @@ +export * from './list-nip-05.schema'; export * from './register-nip-05.schema'; diff --git a/src/modules/nip-05/schemas/list-nip-05.schema.spec.ts b/src/modules/nip-05/schemas/list-nip-05.schema.spec.ts new file mode 100644 index 00000000..11715cdb --- /dev/null +++ b/src/modules/nip-05/schemas/list-nip-05.schema.spec.ts @@ -0,0 +1,10 @@ +import { ListNip05Schema } from './list-nip-05.schema'; + +describe('ListNip05Schema', () => { + it('should return limit and after', () => { + expect(ListNip05Schema.parse({ limit: '10', after: 'name' })).toEqual({ + limit: 10, + after: 'name', + }); + }); +}); diff --git a/src/modules/nip-05/schemas/list-nip-05.schema.ts b/src/modules/nip-05/schemas/list-nip-05.schema.ts new file mode 100644 index 00000000..20bedff0 --- /dev/null +++ b/src/modules/nip-05/schemas/list-nip-05.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const ListNip05Schema = z.object({ + limit: z + .preprocess((val: string) => parseInt(val), z.number().int().positive()) + .optional(), + after: z.string().max(20).optional(), +}); diff --git a/src/modules/nip-05/vos/get-nip05.vo.ts b/src/modules/nip-05/vos/get-nip05.vo.ts new file mode 100644 index 00000000..b40ebb8c --- /dev/null +++ b/src/modules/nip-05/vos/get-nip05.vo.ts @@ -0,0 +1,8 @@ +import { Nip05Entity } from '../entities'; + +export class GetNip05Vo { + /** + * Nip05 identity. + */ + data: Nip05Entity; +} diff --git a/src/modules/nip-05/vos/index.ts b/src/modules/nip-05/vos/index.ts new file mode 100644 index 00000000..c1a8c53e --- /dev/null +++ b/src/modules/nip-05/vos/index.ts @@ -0,0 +1,2 @@ +export * from './get-nip05.vo'; +export * from './list-nip05.vo'; diff --git a/src/modules/nip-05/vos/list-nip05.vo.ts b/src/modules/nip-05/vos/list-nip05.vo.ts new file mode 100644 index 00000000..635d42ba --- /dev/null +++ b/src/modules/nip-05/vos/list-nip05.vo.ts @@ -0,0 +1,8 @@ +import { Nip05Entity } from '../entities'; + +export class ListNip05Vo { + /** + * List of NIP-05 identities. + */ + data: Nip05Entity[]; +} diff --git a/src/modules/nostr/controllers/event.controller.spec.ts b/src/modules/nostr/controllers/event.controller.spec.ts index fd21a166..079448cc 100644 --- a/src/modules/nostr/controllers/event.controller.spec.ts +++ b/src/modules/nostr/controllers/event.controller.spec.ts @@ -40,7 +40,7 @@ describe('EventController', () => { return request(app.getHttpServer()) .post('/api/v1/events') .send({ id: 'test' }) - .expect(201, { message: '' }); + .expect(201, { data: '' }); }); it('invalid event', () => { diff --git a/src/modules/nostr/controllers/event.controller.ts b/src/modules/nostr/controllers/event.controller.ts index 461a6d0d..9735d216 100644 --- a/src/modules/nostr/controllers/event.controller.ts +++ b/src/modules/nostr/controllers/event.controller.ts @@ -11,17 +11,12 @@ import { } from '@nestjs/common'; import { ApiResponse, ApiTags } from '@nestjs/swagger'; import { Pubkey } from '../../../common/decorators'; +import { ErrorVo } from '../../../common/vos'; import { FindEventsDto, HandleEventDto, RequestEventsDto } from '../dtos'; import { EventEntity, FilterEntity } from '../entities'; import { EventIdSchema } from '../schemas'; import { NostrRelayService } from '../services/nostr-relay.service'; -import { - ErrorVo, - FindEventByIdVo, - FindEventsVo, - HandleEventVo, - RequestEventsVo, -} from '../vos'; +import { FindEventByIdVo, FindEventsVo, RequestEventsVo } from '../vos'; @Controller('api/v1/events') @ApiTags('events') @@ -32,15 +27,12 @@ export class EventController { * Handle a new event. */ @Post() - @ApiResponse({ type: HandleEventVo }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid event', type: ErrorVo, }) - async handleEvent( - @Body() handleEventDto: HandleEventDto, - ): Promise { + async handleEvent(@Body() handleEventDto: HandleEventDto): Promise { let event: EventEntity; try { event = await this.nostrRelayService.validateEvent(handleEventDto); @@ -53,8 +45,6 @@ export class EventController { if (!success) { throw new BadRequestException(message); } - - return { message }; } /** @@ -62,7 +52,11 @@ export class EventController { */ @Get(':id') @ApiResponse({ type: FindEventByIdVo }) - @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Event not found' }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Event not found', + type: ErrorVo, + }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid event ID', diff --git a/src/modules/nostr/vos/handle-event.vo.ts b/src/modules/nostr/vos/handle-event.vo.ts deleted file mode 100644 index 7eeeb909..00000000 --- a/src/modules/nostr/vos/handle-event.vo.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class HandleEventVo { - message: string; -} diff --git a/src/modules/nostr/vos/index.ts b/src/modules/nostr/vos/index.ts index 1c2efda4..315c6460 100644 --- a/src/modules/nostr/vos/index.ts +++ b/src/modules/nostr/vos/index.ts @@ -1,5 +1,3 @@ -export * from './error.vo'; export * from './find-event-by-id.vo'; export * from './find-events.vo'; -export * from './handle-event.vo'; export * from './request-events.vo'; diff --git a/src/modules/repositories/nip-05.repository.spec.ts b/src/modules/repositories/nip-05.repository.spec.ts index a6cae248..bd363ddc 100644 --- a/src/modules/repositories/nip-05.repository.spec.ts +++ b/src/modules/repositories/nip-05.repository.spec.ts @@ -37,6 +37,21 @@ describe('EventRepository', () => { expect(await nip05Repository.getPubkeyByName('test')).toBe( 'a09659cd9ee89cd3743bc29aa67edf1d7d12fb624699fcd3d6d33eef250b01e7', ); + expect(await nip05Repository.getByName('test')).toEqual({ + name: 'test', + pubkey: + 'a09659cd9ee89cd3743bc29aa67edf1d7d12fb624699fcd3d6d33eef250b01e7', + create_date: expect.any(Date), + }); + expect(await nip05Repository.list()).toEqual([ + { + name: 'test', + pubkey: + 'a09659cd9ee89cd3743bc29aa67edf1d7d12fb624699fcd3d6d33eef250b01e7', + create_date: expect.any(Date), + }, + ]); + expect(await nip05Repository.list(10, 'test')).toEqual([]); await nip05Repository.delete('test'); expect(await nip05Repository.getPubkeyByName('test')).toBeUndefined(); diff --git a/src/modules/repositories/nip-05.repository.ts b/src/modules/repositories/nip-05.repository.ts index 464c6861..3925aa1f 100644 --- a/src/modules/repositories/nip-05.repository.ts +++ b/src/modules/repositories/nip-05.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Kysely } from 'kysely'; import { KyselyDb } from './kysely-db'; -import { Database } from './types'; +import { Database, Nip05Row } from './types'; @Injectable() export class Nip05Repository { @@ -21,6 +21,24 @@ export class Nip05Repository { return result?.pubkey; } + async getByName(name: string): Promise { + return this.db + .selectFrom('nip05') + .selectAll() + .where('name', '=', name) + .executeTakeFirst(); + } + + async list(limit = 10, after?: string): Promise { + let query = this.db.selectFrom('nip05').selectAll(); + + if (after) { + query = query.where('name', '>', after); + } + + return query.orderBy('name').limit(limit).execute(); + } + async register(name: string, pubkey: string): Promise { await this.db.insertInto('nip05').values({ name, pubkey }).execute(); } diff --git a/src/modules/repositories/types.ts b/src/modules/repositories/types.ts index 7c119fe7..20534b42 100644 --- a/src/modules/repositories/types.ts +++ b/src/modules/repositories/types.ts @@ -36,3 +36,4 @@ interface Nip05Table { pubkey: string; create_date: ColumnType; } +export type Nip05Row = Selectable; diff --git a/src/modules/wot/vos/check-pubkey-trusted.vo.ts b/src/modules/wot/vos/check-pubkey-trusted.vo.ts index 51803876..8edfe688 100644 --- a/src/modules/wot/vos/check-pubkey-trusted.vo.ts +++ b/src/modules/wot/vos/check-pubkey-trusted.vo.ts @@ -1,11 +1,6 @@ export class CheckPubkeyTrustedVo { - /** - * Whether the WoT is enabled. - */ - wotEnabled: boolean; - /** * Whether the pubkey is trusted. */ - trusted: boolean; + data: boolean; } diff --git a/src/modules/wot/wot.controller.spec.ts b/src/modules/wot/wot.controller.spec.ts index a964c9eb..96404648 100644 --- a/src/modules/wot/wot.controller.spec.ts +++ b/src/modules/wot/wot.controller.spec.ts @@ -17,15 +17,14 @@ describe('WotController', () => { it('should return trusted when pubkey is valid', async () => { jest .spyOn(wotController['wotService'], 'checkPubkeyIsTrusted') - .mockReturnValue({ wotEnabled: true, trusted: true }); + .mockReturnValue(true); expect( wotController.trusted( 'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl', ), ).toEqual({ - wotEnabled: true, - trusted: true, + data: true, }); }); }); diff --git a/src/modules/wot/wot.controller.ts b/src/modules/wot/wot.controller.ts index 1ae44b8d..d23020df 100644 --- a/src/modules/wot/wot.controller.ts +++ b/src/modules/wot/wot.controller.ts @@ -25,6 +25,7 @@ export class WotController { if (!/^[0-9a-f]{64}$/.test(pubkey)) { throw new BadRequestException('Invalid pubkey'); } - return this.wotService.checkPubkeyIsTrusted(pubkey); + const isTrusted = this.wotService.checkPubkeyIsTrusted(pubkey); + return { data: isTrusted }; } } diff --git a/src/modules/wot/wot.service.spec.ts b/src/modules/wot/wot.service.spec.ts index ba0024d9..f792c6a9 100644 --- a/src/modules/wot/wot.service.spec.ts +++ b/src/modules/wot/wot.service.spec.ts @@ -27,10 +27,7 @@ describe('WotService', () => { .spyOn(wotService['wotGuardPlugin'], 'checkPubkey') .mockReturnValue(true); - expect(wotService.checkPubkeyIsTrusted('test')).toEqual({ - wotEnabled: true, - trusted: true, - }); + expect(wotService.checkPubkeyIsTrusted('test')).toEqual(true); }); it('checkPubkeyIsTrusted (wot disabled)', () => { @@ -38,10 +35,7 @@ describe('WotService', () => { .spyOn(wotService['wotGuardPlugin'], 'getEnabled') .mockReturnValue(false); - expect(wotService.checkPubkeyIsTrusted('test')).toEqual({ - wotEnabled: false, - trusted: true, - }); + expect(wotService.checkPubkeyIsTrusted('test')).toEqual(true); }); it('refreshWot', async () => { diff --git a/src/modules/wot/wot.service.ts b/src/modules/wot/wot.service.ts index a15cb942..0515a872 100644 --- a/src/modules/wot/wot.service.ts +++ b/src/modules/wot/wot.service.ts @@ -37,10 +37,7 @@ export class WotService implements OnApplicationBootstrap { checkPubkeyIsTrusted(pubkey: string) { const wotEnabled = this.wotGuardPlugin.getEnabled(); - return { - wotEnabled, - trusted: wotEnabled ? this.wotGuardPlugin.checkPubkey(pubkey) : true, - }; + return wotEnabled ? this.wotGuardPlugin.checkPubkey(pubkey) : true; } async refreshWot() { From 3151715c6a0db254908d08d83694da64250bb171 Mon Sep 17 00:00:00 2001 From: codytseng Date: Fri, 4 Oct 2024 16:26:54 +0800 Subject: [PATCH 5/6] fix: test --- src/modules/nostr/controllers/event.controller.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/nostr/controllers/event.controller.spec.ts b/src/modules/nostr/controllers/event.controller.spec.ts index 079448cc..c6b2dbb8 100644 --- a/src/modules/nostr/controllers/event.controller.spec.ts +++ b/src/modules/nostr/controllers/event.controller.spec.ts @@ -40,7 +40,7 @@ describe('EventController', () => { return request(app.getHttpServer()) .post('/api/v1/events') .send({ id: 'test' }) - .expect(201, { data: '' }); + .expect(201); }); it('invalid event', () => { From 159f3827f66d2855c69ecc0321793ac28e0e1ff9 Mon Sep 17 00:00:00 2001 From: codytseng Date: Fri, 4 Oct 2024 22:17:21 +0800 Subject: [PATCH 6/6] docs: update README --- README.md | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 44b391ea..3e688026 100644 --- a/README.md +++ b/README.md @@ -17,21 +17,22 @@ If you'd like to help me test the reliability of this relay implementation, you 🟢 Full implemented 🟡 Partially implemented 🔴 Not implemented -| Feature | Status | Note | -| ------------------------------------------------------------------------------------------------------- | :----: | ---------------------------------------- | -| [NIP-01: Basic protocol flow description](https://github.com/nostr-protocol/nips/blob/master/01.md) | 🟢 | | -| [NIP-02: Contact List and Petnames](https://github.com/nostr-protocol/nips/blob/master/02.md) | 🟢 | | -| [NIP-04: Encrypted Direct Message](https://github.com/nostr-protocol/nips/blob/master/04.md) | 🟢 | | -| [NIP-09: Event Deletion](https://github.com/nostr-protocol/nips/blob/master/09.md) | 🔴 | No real deletion in a distributed system | -| [NIP-11: Relay Information Document](https://github.com/nostr-protocol/nips/blob/master/11.md) | 🟢 | | -| [NIP-13: Proof of Work](https://github.com/nostr-protocol/nips/blob/master/13.md) | 🟢 | | -| [NIP-22: Event created_at Limits](https://github.com/nostr-protocol/nips/blob/master/22.md) | 🟢 | | -| [NIP-26: Delegated Event Signing](https://github.com/nostr-protocol/nips/blob/master/26.md) | 🟢 | | -| [NIP-28: Public Chat](https://github.com/nostr-protocol/nips/blob/master/28.md) | 🟢 | | -| [NIP-40: Expiration Timestamp](https://github.com/nostr-protocol/nips/blob/master/40.md) | 🟢 | | -| [NIP-42: Authentication of clients to relays](https://github.com/nostr-protocol/nips/blob/master/42.md) | 🟢 | | -| [NIP-45: Counting results](https://github.com/nostr-protocol/nips/blob/master/45.md) | 🔴 | | -| [NIP-50: Keywords filter](https://github.com/nostr-protocol/nips/blob/master/50.md) | 🟢 | | +| Feature | Status | Note | +| ------------------------------------------------------------------------------------------------------------------------ | :----: | ---------------------------------------- | +| [NIP-01: Basic protocol flow description](https://github.com/nostr-protocol/nips/blob/master/01.md) | 🟢 | | +| [NIP-02: Follow List](https://github.com/nostr-protocol/nips/blob/master/02.md) | 🟢 | | +| [NIP-04: Encrypted Direct Message](https://github.com/nostr-protocol/nips/blob/master/04.md) | 🟢 | | +| [NIP-05: Mapping Nostr keys to DNS-based internet identifiers](https://github.com/nostr-protocol/nips/blob/master/05.md) | 🟢 | | +| [NIP-09: Event Deletion](https://github.com/nostr-protocol/nips/blob/master/09.md) | 🔴 | No real deletion in a distributed system | +| [NIP-11: Relay Information Document](https://github.com/nostr-protocol/nips/blob/master/11.md) | 🟢 | | +| [NIP-13: Proof of Work](https://github.com/nostr-protocol/nips/blob/master/13.md) | 🟢 | | +| [NIP-22: Event created_at Limits](https://github.com/nostr-protocol/nips/blob/master/22.md) | 🟢 | | +| [NIP-26: Delegated Event Signing](https://github.com/nostr-protocol/nips/blob/master/26.md) | 🟢 | | +| [NIP-28: Public Chat](https://github.com/nostr-protocol/nips/blob/master/28.md) | 🟢 | | +| [NIP-40: Expiration Timestamp](https://github.com/nostr-protocol/nips/blob/master/40.md) | 🟢 | | +| [NIP-42: Authentication of clients to relays](https://github.com/nostr-protocol/nips/blob/master/42.md) | 🟢 | | +| [NIP-45: Counting results](https://github.com/nostr-protocol/nips/blob/master/45.md) | 🔴 | | +| [NIP-50: Keywords filter](https://github.com/nostr-protocol/nips/blob/master/50.md) | 🟢 | | ## Extra Features @@ -45,7 +46,7 @@ If you want to enable the WoT feature, you need to set the following environment ### RESTful API -You can see the API documentation at `/api` endpoint. +You can see the API documentation at `/api` endpoint. [Example](https://nostr-relay.app/api) ### TOP verb