feat: support nip-26 delegated event signing

This commit is contained in:
Ricardo Arturo Cabral Mejia 2022-08-26 04:00:12 +00:00
parent b02c76c6d0
commit 262e00ad53
No known key found for this signature in database
GPG Key ID: 5931EBF43A650245
39 changed files with 1000 additions and 512 deletions

View File

@ -1,21 +1,36 @@
module.exports = {
parser: "@typescript-eslint/parser",
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: "module",
sourceType: 'module',
},
plugins: ["@typescript-eslint/eslint-plugin"],
extends: ["plugin:@typescript-eslint/recommended"],
plugins: ['@typescript-eslint/eslint-plugin'],
extends: ['plugin:@typescript-eslint/recommended'],
root: true,
env: {
node: true,
},
ignorePatterns: [".eslintrc.js", "dist", "tslint.json", "node_modules"],
ignorePatterns: ['dist', 'tslint.json', 'node_modules'],
rules: {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-console": "off",
semi: ["error", "never"],
quotes: ["error", "single", { avoidEscape: true }]
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': 'off',
semi: ['error', 'never'],
quotes: ['error', 'single', { avoidEscape: true }],
'sort-imports': ['error', {
ignoreCase: true,
allowSeparatedGroups: true,
}],
curly: [2, 'multi-line'],
'max-len': [
'error',
{
code: 120,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreRegExpLiterals: true,
},
],
'comma-dangle': ['error', 'always-multiline'],
},
};
}

View File

@ -1,6 +1,6 @@
module.exports = {
extension: ['ts'],
require: ['ts-node/register', 'source-map-support/register'],
require: ['ts-node/register'],
reporter: 'mochawesome',
slow: 75,
sorted: true,

View File

@ -20,7 +20,8 @@ NIPs with a relay-specific implementation are listed here.
- [x] NIP-15: End of Stored Events Notice
- [x] NIP-16: Event Treatment
- [ ] NIP-25: Reactions
- [x] NIP-27: Multicasting (Experimental)
- [x] NIP-26: Delegated Event Signing
- [ ] NIP-27: Restricted Events (Experimental)
## Requirements

View File

@ -0,0 +1,12 @@
exports.up = function (knex) {
// NIP-26: Delegated Event Signing
return knex.schema.alterTable('events', function (table) {
table.binary('event_delegator').nullable().index()
})
}
exports.down = function (knex) {
return knex.schema.alterTable('events', function (table) {
table.dropColumn('event_delegator')
})
}

View File

@ -1,5 +1,6 @@
import { EventEmitter } from 'node:stream'
import { WebSocket } from 'ws'
import { Event } from './event'
import { OutgoingMessage } from './messages'

View File

@ -1,4 +1,4 @@
import { EventKinds } from '../constants/base'
import { EventDelegatorMetadataKey, EventKinds } from '../constants/base'
import { EventId, Pubkey, Tag } from './base'
@ -10,6 +10,7 @@ export interface Event {
tags: Tag[]
sig: string
content: string
[EventDelegatorMetadataKey]?: Pubkey
}
export interface DBEvent {
@ -21,6 +22,7 @@ export interface DBEvent {
event_content: string
event_tags: Tag[]
event_signature: Buffer
event_delegator?: Buffer | null
first_seen: Date
}

View File

@ -1,6 +1,6 @@
import { Range } from './base'
import { SubscriptionFilter, SubscriptionId } from './subscription'
import { Event } from './event'
import { SubscriptionId, SubscriptionFilter } from './subscription'
import { Range } from './base'
export enum MessageType {
REQ = 'REQ',

View File

@ -1,6 +1,7 @@
import { PassThrough } from 'stream'
import { EventId, Pubkey } from './base'
import { DBEvent, Event } from './event'
import { EventId, Pubkey } from './base'
import { SubscriptionFilter } from './subscription'
export type ExposedPromiseKeys = 'then' | 'catch' | 'finally'

View File

@ -1,5 +1,5 @@
import { EventKinds } from '../constants/base'
import { EventId, Pubkey } from './base'
import { EventKinds } from '../constants/base'
export type SubscriptionId = string

View File

@ -1,9 +1,9 @@
import { IncomingMessage, Server, ServerResponse } from 'http'
import { Duplex, EventEmitter } from 'stream'
import { IncomingMessage, Server, ServerResponse } from 'http'
import packageJson from '../../package.json'
import { Settings } from '../settings'
import { IWebServerAdapter } from '../@types/adapters'
import { Settings } from '../utils/settings'
export class WebServerAdapter extends EventEmitter implements IWebServerAdapter {
@ -12,8 +12,8 @@ export class WebServerAdapter extends EventEmitter implements IWebServerAdapter
) {
super()
this.webServer.on('request', this.onWebServerRequest.bind(this))
this.webServer.on('clientError', this.onWebServerSocketError.bind(this))
this.webServer.on('close', this.onClose.bind(this))
.on('clientError', this.onWebServerSocketError.bind(this))
.on('close', this.onClose.bind(this))
}
public listen(port: number): void {
@ -32,7 +32,7 @@ export class WebServerAdapter extends EventEmitter implements IWebServerAdapter
description,
pubkey,
contact,
supported_nips: [1, 2, 4, 9, 11, 12, 15, 16],
supported_nips: [1, 2, 4, 9, 11, 12, 15, 16, 26],
software: packageJson.repository.url,
version: packageJson.version,
}

View File

@ -1,23 +1,27 @@
import { EventEmitter } from 'stream'
import { IncomingMessage as IncomingHttpMessage } from 'http'
import { WebSocket } from 'ws'
import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
import { Factory } from '../@types/base'
import { Event } from '../@types/event'
import { IMessageHandler, IAbortable } from '../@types/message-handlers'
import { IAbortable, IMessageHandler } from '../@types/message-handlers'
import { IncomingMessage, OutgoingMessage } from '../@types/messages'
import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
import { createOutgoingEventMessage } from '../messages'
import { messageSchema } from '../schemas/message-schema'
import { isEventMatchingFilter } from '../utils/event'
import { attemptValidation } from '../utils/validation'
import { createOutgoingEventMessage } from '../utils/messages'
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { isEventMatchingFilter } from '../utils/event'
import { messageSchema } from '../schemas/message-schema'
export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter {
private id: string
private clientAddress: string
private alive: boolean
private subscriptions: Map<SubscriptionId, Set<SubscriptionFilter>>
public constructor(
private readonly client: WebSocket,
private readonly request: IncomingHttpMessage,
private readonly webSocketServer: IWebSocketServerAdapter,
private readonly createMessageHandler: Factory<IMessageHandler, [IncomingMessage, IWebSocketAdapter]>,
) {
@ -25,11 +29,19 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
this.alive = true
this.subscriptions = new Map()
this.id = Buffer.from(request.headers['sec-websocket-key'], 'base64').toString('hex')
this.clientAddress = request.headers['x-forwarded-for'] as string
console.log('id', this.id, 'clientAddress', this.clientAddress)
console.log('listener count:', this.client.listenerCount('close'))
this.client
.on('message', this.onClientMessage.bind(this))
.on('close', this.onClientClose.bind(this))
.on('pong', this.onClientPong.bind(this))
console.log('+listener count:', this.client.listenerCount('close'))
this
.on('heartbeat', this.onHeartbeat.bind(this))
.on('subscribe', this.onSubscribed.bind(this))
@ -60,6 +72,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
}
public sendMessage(message: OutgoingMessage): void {
console.log('sending message', message)
this.client.send(JSON.stringify(message))
}
@ -86,12 +99,11 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
try {
const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf-8')))
console.debug('message received:', message[0])
const messageHandler = this.createMessageHandler([message, this]) as IMessageHandler & IAbortable
if (typeof messageHandler.abort === 'function') {
abort = messageHandler.abort.bind(messageHandler)
this.client.prependOnceListener('close', abort)
console.log('+listener count:', this.client.listenerCount('close'))
}
await messageHandler?.handleMessage(message)
@ -107,6 +119,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
if (abort) {
this.client.removeListener('close', abort)
}
console.log('-listener count:', this.client.listenerCount('close'))
}
}
@ -121,5 +134,6 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
this.removeAllListeners()
this.client.removeAllListeners()
console.log('-listener count:', this.client.listenerCount('close'))
}
}

View File

@ -1,11 +1,11 @@
import { Server } from 'http'
import { IncomingMessage, Server } from 'http'
import WebSocket, { OPEN, WebSocketServer } from 'ws'
import { Event } from '../@types/event'
import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
import { WebServerAdapter } from './web-server-adapter'
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { propEq } from 'ramda'
import { WebServerAdapter } from './web-server-adapter'
const WSS_CLIENT_HEALTH_PROBE_INTERVAL = 30000
@ -18,7 +18,10 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
public constructor(
webServer: Server,
private readonly webSocketServer: WebSocketServer,
private readonly createWebSocketAdapter: Factory<IWebSocketAdapter, [WebSocket, IWebSocketServerAdapter]>
private readonly createWebSocketAdapter: Factory<
IWebSocketAdapter,
[WebSocket, IncomingMessage, IWebSocketServerAdapter]
>
) {
super(webServer)
@ -49,10 +52,10 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
})
}
private onWebSocketServerConnection(client: WebSocket) {
private onWebSocketServerConnection(client: WebSocket, req: IncomingMessage) {
console.debug(`new client - ${this.getConnectedClients()} connected / ${this.webSocketServer.clients.size} total`)
this.webSocketsAdapters.set(client, this.createWebSocketAdapter([client, this]))
this.webSocketsAdapters.set(client, this.createWebSocketAdapter([client, req, this]))
}
private onWebSocketServerHeartbeat() {

View File

@ -11,5 +11,9 @@ export enum EventKinds {
export enum EventTags {
Event = 'e',
Pubkey = 'p',
Multicast = 'm',
// Multicast = 'm',
Delegation = 'delegation',
}
export const EventDelegatorMetadataKey = Symbol('Delegator')

View File

@ -1,28 +1,29 @@
import { isDeleteEvent, isEphemeralEvent, isNullEvent, isReplaceableEvent } from '../utils/event'
import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy'
import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy'
import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy'
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { IEventRepository } from '../@types/repositories'
import { IEventStrategy } from '../@types/message-handlers'
import { IWebSocketAdapter } from '../@types/adapters'
import { NullEventStrategy } from '../handlers/event-strategies/null-event-strategy'
import { ReplaceableEventStrategy } from '../handlers/event-strategies/replaceable-event-strategy'
import { Factory } from '../@types/base'
import { Event } from '../@types/event'
import { IEventStrategy } from '../@types/message-handlers'
import { IEventRepository } from '../@types/repositories'
import { isDeleteEvent, isEphemeralEvent, isNullEvent, isReplaceableEvent } from '../utils/event'
import { IWebSocketAdapter } from '../@types/adapters'
import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy'
export const eventStrategyFactory = (
eventRepository: IEventRepository,
): Factory<IEventStrategy<Event, Promise<boolean>>, [Event, IWebSocketAdapter]> => ([event, adapter]: [Event, IWebSocketAdapter]) => {
if (isReplaceableEvent(event)) {
return new ReplaceableEventStrategy(adapter, eventRepository)
} else if (isEphemeralEvent(event)) {
return new EphemeralEventStrategy(adapter)
} else if (isNullEvent(event)) {
return new NullEventStrategy()
} else if (isDeleteEvent(event)) {
return new DeleteEventStrategy(eventRepository)
}
): Factory<IEventStrategy<Event, Promise<boolean>>, [Event, IWebSocketAdapter]> =>
([event, adapter]: [Event, IWebSocketAdapter]) => {
if (isReplaceableEvent(event)) {
return new ReplaceableEventStrategy(adapter, eventRepository)
} else if (isEphemeralEvent(event)) {
return new EphemeralEventStrategy(adapter)
} else if (isNullEvent(event)) {
return new NullEventStrategy()
} else if (isDeleteEvent(event)) {
return new DeleteEventStrategy(eventRepository)
}
return new DefaultEventStrategy(adapter, eventRepository)
}
return new DefaultEventStrategy(adapter, eventRepository)
}

View File

@ -1,10 +1,10 @@
import { EventMessageHandler } from '../handlers/event-message-handler'
import { SubscribeMessageHandler } from '../handlers/subscribe-message-handler'
import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handler'
import { IncomingMessage, MessageType } from '../@types/messages'
import { EventMessageHandler } from '../handlers/event-message-handler'
import { eventStrategyFactory } from './event-strategy-factory'
import { IEventRepository } from '../@types/repositories'
import { IWebSocketAdapter } from '../@types/adapters'
import { eventStrategyFactory } from './event-strategy-factory'
import { SubscribeMessageHandler } from '../handlers/subscribe-message-handler'
import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handler'
export const messageHandlerFactory = (

View File

@ -1,15 +1,18 @@
import { IncomingMessage } from 'http'
import { WebSocket } from 'ws'
import { IWebSocketServerAdapter } from '../@types/adapters'
import { IEventRepository } from '../@types/repositories'
import { WebSocketAdapter } from '../adapters/web-socket-adapter'
import { IWebSocketServerAdapter } from '../@types/adapters'
import { messageHandlerFactory } from './message-handler-factory'
import { WebSocketAdapter } from '../adapters/web-socket-adapter'
export const webSocketAdapterFactory = (
eventRepository: IEventRepository,
) => ([client, webSocketServerAdapter]: [WebSocket, IWebSocketServerAdapter,]) =>
) => ([client, request, webSocketServerAdapter]: [WebSocket, IncomingMessage, IWebSocketServerAdapter]) =>
new WebSocketAdapter(
client,
request,
webSocketServerAdapter,
messageHandlerFactory(eventRepository)
)

View File

@ -1,8 +1,9 @@
import { IMessageHandler, IEventStrategy } from '../@types/message-handlers'
import { IncomingEventMessage } from '../@types/messages'
import { EventDelegatorMetadataKey, EventTags } from '../constants/base'
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
import { isDelegatedEvent, isDelegatedEventValid, isEventIdValid, isEventSignatureValid } from '../utils/event'
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { isEventIdValid, isEventSignatureValid } from '../utils/event'
import { IncomingEventMessage } from '../@types/messages'
import { IWebSocketAdapter } from '../@types/adapters'
export class EventMessageHandler implements IMessageHandler {
@ -19,6 +20,16 @@ export class EventMessageHandler implements IMessageHandler {
return
}
if (isDelegatedEvent(event)) {
if (await isDelegatedEventValid(event)) {
const [, delegator] = event.tags.find((tag) => tag.length === 4 && tag[0] === EventTags.Delegation)
event[EventDelegatorMetadataKey] = delegator
} else {
console.warn(`Delegated event ${event.id} from ${event.pubkey} is not valid`)
return
}
}
const strategy = this.strategyFactory([event, this.webSocket])
if (typeof strategy?.execute !== 'function') {

View File

@ -1,7 +1,7 @@
import { IWebSocketAdapter } from '../../@types/adapters'
import { Event } from '../../@types/event'
import { IEventStrategy } from '../../@types/message-handlers'
import { IEventRepository } from '../../@types/repositories'
import { IEventStrategy } from '../../@types/message-handlers'
import { IWebSocketAdapter } from '../../@types/adapters'
export class DefaultEventStrategy implements IEventStrategy<Event, Promise<boolean>> {

View File

@ -1,7 +1,7 @@
import { Event } from '../../@types/event'
import { IEventStrategy } from '../../@types/message-handlers'
import { IEventRepository } from '../../@types/repositories'
import { EventTags } from '../../constants/base'
import { IEventRepository } from '../../@types/repositories'
import { IEventStrategy } from '../../@types/message-handlers'
export class DeleteEventStrategy implements IEventStrategy<Event, Promise<boolean>> {

View File

@ -1,6 +1,6 @@
import { IWebSocketAdapter } from '../../@types/adapters'
import { Event } from '../../@types/event'
import { IEventStrategy } from '../../@types/message-handlers'
import { IWebSocketAdapter } from '../../@types/adapters'
export class EphemeralEventStrategy implements IEventStrategy<Event, Promise<boolean>> {

View File

@ -1,6 +1,6 @@
import { Event } from '../../@types/event'
import { IEventStrategy } from '../../@types/message-handlers'
import { IEventRepository } from '../../@types/repositories'
import { IEventStrategy } from '../../@types/message-handlers'
import { IWebSocketAdapter } from '../../@types/adapters'

View File

@ -1,15 +1,15 @@
import { anyPass, map } from 'ramda'
import { pipeline } from 'stream/promises'
import { createOutgoingEventMessage, createEndOfStoredEventsNoticeMessage } from '../messages'
import { createEndOfStoredEventsNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
import { IAbortable, IMessageHandler } from '../@types/message-handlers'
import { SubscribeMessage } from '../@types/messages'
import { IWebSocketAdapter } from '../@types/adapters'
import { IEventRepository } from '../@types/repositories'
import { SubscriptionId, SubscriptionFilter } from '../@types/subscription'
import { isEventMatchingFilter, toNostrEvent } from '../utils/event'
import { streamEach, streamEnd, streamFilter, streamMap } from '../utils/stream'
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
import { Event } from '../@types/event'
import { anyPass, map } from 'ramda'
import { IEventRepository } from '../@types/repositories'
import { IWebSocketAdapter } from '../@types/adapters'
import { SubscribeMessage } from '../@types/messages'
export class SubscribeMessageHandler implements IMessageHandler, IAbortable {

View File

@ -1,10 +1,10 @@
import * as http from 'http'
import { WebSocketServer } from 'ws'
import { getDbClient } from './database/client'
import { EventRepository } from './repositories/event-repository'
import { WebSocketServerAdapter } from './adapters/web-socket-server-adapter'
import { getDbClient } from './database/client'
import { webSocketAdapterFactory } from './factories/websocket-adapter-factory'
import { WebSocketServerAdapter } from './adapters/web-socket-server-adapter'
const server = http.createServer()
const wss = new WebSocketServer({ server, maxPayload: 1024 * 1024 })
@ -23,8 +23,12 @@ adapter.listen(port)
process.on('SIGINT', async function () {
console.log('\rCaught interrupt signal')
wss.clients.forEach((client) => client.terminate())
await new Promise((resolve, reject) => wss.close((error?: Error) => void (error instanceof Error) ? reject(error) : resolve(undefined)))
await new Promise((resolve, reject) => server.close((error?: Error) => void (error instanceof Error) ? reject(error) : resolve(undefined)))
await new Promise((resolve, reject) =>
wss.close((error?: Error) => void (error instanceof Error) ? reject(error) : resolve(undefined))
)
await new Promise((resolve, reject) =>
server.close((error?: Error) => void (error instanceof Error) ? reject(error) : resolve(undefined))
)
dbClient.destroy()
process.exit()
})

View File

@ -1,12 +1,39 @@
import {
__,
always,
applySpec,
complement,
cond,
equals,
evolve,
filter,
forEach,
forEachObjIndexed,
groupBy,
identity,
ifElse,
invoker,
is,
isEmpty,
isNil,
modulo,
nth,
omit,
pipe,
prop,
propSatisfies,
T,
toPairs,
} from 'ramda'
import { Knex } from 'knex'
import { __, applySpec, equals, modulo, omit, pipe, prop, cond, always, groupBy, T, evolve, forEach, isEmpty, forEachObjIndexed, isNil, complement, toPairs, filter, nth, ifElse, invoker, identity } from 'ramda'
import { EventId } from '../@types/base'
import { DBEvent, Event } from '../@types/event'
import { IEventRepository, IQueryResult } from '../@types/repositories'
import { SubscriptionFilter } from '../@types/subscription'
import { isGenericTagQuery } from '../utils/filter'
import { toBuffer, toJSON } from '../utils/transform'
import { EventDelegatorMetadataKey } from '../constants/base'
import { EventId } from '../@types/base'
import { isGenericTagQuery } from '../utils/filter'
import { SubscriptionFilter } from '../@types/subscription'
const even = pipe(modulo(__, 2), equals(0))
@ -31,7 +58,7 @@ export class EventRepository implements IEventRepository {
const queries = filters.map((currentFilter) => {
const builder = this.dbClient<DBEvent>('events')
forEachObjIndexed((tableField: string, filterName: string) => {
forEachObjIndexed((tableFields: string[], filterName: string) => {
builder.andWhere((bd) => {
cond([
[isEmpty, () => void bd.whereRaw('1 = 0')],
@ -40,27 +67,38 @@ export class EventRepository implements IEventRepository {
pipe(
groupByLengthSpec,
evolve({
exact: (pubkeys: string[]) => void bd.whereIn(tableField, pubkeys.map(toBuffer)),
even: forEach((prefix: string) => void bd.orWhereRaw(
`substring("${tableField}" from 1 for ?) = ?`,
[prefix.length >> 1, toBuffer(prefix)]
)),
odd: forEach((prefix: string) => void bd.orWhereRaw(
`substring("${tableField}" from 1 for ?) BETWEEN ? AND ?`,
[
(prefix.length >> 1) + 1,
`\\x${prefix}0`,
`\\x${prefix}f`
],
)),
exact: (pubkeys: string[]) =>
tableFields.forEach((tableField) =>
void bd.orWhereIn(tableField, pubkeys.map(toBuffer))
),
even: forEach((prefix: string) =>
tableFields.forEach((tableField) =>
void bd.orWhereRaw(
`substring("${tableField}" from 1 for ?) = ?`,
[prefix.length >> 1, toBuffer(prefix)]
)
)
),
odd: forEach((prefix: string) =>
tableFields.forEach((tableField) =>
void bd.orWhereRaw(
`substring("${tableField}" from 1 for ?) BETWEEN ? AND ?`,
[
(prefix.length >> 1) + 1,
`\\x${prefix}0`,
`\\x${prefix}f`,
],
)
)
),
}),
),
],
])(currentFilter[filterName] as string[])
})
})({
authors: 'event_pubkey',
ids: 'event_id',
authors: ['event_pubkey', 'event_delegator'],
ids: ['event_id'],
})
if (Array.isArray(currentFilter.kinds)) {
@ -95,7 +133,7 @@ export class EventRepository implements IEventRepository {
forEach((criterion: string[]) => void orWhereRaw(
'"event_tags" @> ?',
[
JSON.stringify([[filterName[1], criterion]]) as any
JSON.stringify([[filterName[1], criterion]]) as any,
],
bd,
)),
@ -111,6 +149,7 @@ export class EventRepository implements IEventRepository {
if (subqueries.length) {
query.union(subqueries, true)
}
console.log(query.toString())
return query
}
@ -128,6 +167,11 @@ export class EventRepository implements IEventRepository {
event_tags: pipe(prop('tags'), toJSON),
event_content: prop('content'),
event_signature: pipe(prop('sig'), toBuffer),
event_delegator: ifElse(
propSatisfies(is(String), EventDelegatorMetadataKey),
pipe(prop(EventDelegatorMetadataKey as any), toBuffer),
always(null),
),
})(event)
return this.dbClient('events')
@ -148,6 +192,11 @@ export class EventRepository implements IEventRepository {
event_tags: pipe(prop('tags'), toJSON),
event_content: prop('content'),
event_signature: pipe(prop('sig'), toBuffer),
event_delegator: ifElse(
propSatisfies(is(String), EventDelegatorMetadataKey),
pipe(prop(EventDelegatorMetadataKey as any), toBuffer),
always(null),
),
})(event)
return this.dbClient('events')

View File

@ -1,4 +1,5 @@
import Schema from 'joi'
import {
idSchema,
kindSchema,

View File

@ -1,9 +1,9 @@
import Joi from 'joi'
import Schema from 'joi'
import { MessageType } from '../@types/messages'
import { subscriptionSchema } from './base-schema'
import { eventSchema } from './event-schema'
import { filterSchema } from './filter-schema'
import { MessageType } from '../@types/messages'
import { subscriptionSchema } from './base-schema'
export const eventMessageSchema = Schema.array().ordered(
Schema.string().valid('EVENT').required(),
@ -22,10 +22,19 @@ export const closeMessageSchema = Schema.array().ordered(
).label('CLOSE message')
export const messageSchema = Schema.alternatives()
.conditional(Joi.ref('.'), {
.conditional(Schema.ref('.'), {
switch: [
{ is: Joi.array().ordered(Joi.string().equal(MessageType.EVENT)).items(Joi.any()), then: eventMessageSchema },
{ is: Joi.array().ordered(Joi.string().equal(MessageType.REQ)).items(Joi.any()), then: reqMessageSchema },
{ is: Joi.array().ordered(Joi.string().equal(MessageType.CLOSE)).items(Joi.any()), then: closeMessageSchema },
{
is: Schema.array().ordered(Schema.string().equal(MessageType.EVENT)).items(Schema.any()),
then: eventMessageSchema,
},
{
is: Schema.array().ordered(Schema.string().equal(MessageType.REQ)).items(Schema.any()),
then: reqMessageSchema,
},
{
is: Schema.array().ordered(Schema.string().equal(MessageType.CLOSE)).items(Schema.any()),
then: closeMessageSchema,
},
],
})

View File

@ -1,11 +1,12 @@
import * as secp256k1 from '@noble/secp256k1'
import { applySpec, pipe, prop } from 'ramda'
import { applySpec, converge, curry, mergeLeft, nth, omit, pipe, prop, reduceBy } from 'ramda'
import { CanonicalEvent, Event } from '../@types/event'
import { SubscriptionFilter } from '../@types/subscription'
import { EventKinds, EventTags } from '../constants/base'
import { isGenericTagQuery } from './filter'
import { fromBuffer } from './transform'
import { isGenericTagQuery } from './filter'
import { Rune } from './runes'
import { SubscriptionFilter } from '../@types/subscription'
export const serializeEvent = (event: Partial<Event>): CanonicalEvent => [
0,
@ -41,13 +42,6 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Eve
return false
}
if (
Array.isArray(filter.authors) &&
!filter.authors.some(startsWith(event.pubkey))
) {
return false
}
if (typeof filter.since === 'number' && event.created_at < filter.since) {
return false
}
@ -56,18 +50,34 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Eve
return false
}
// NIP-27: Multicast
const targetMulticastGroups: string[] = event.tags.reduce(
(acc, tag) => (tag[0] === EventTags.Multicast)
? [...acc, tag[1]]
: acc,
[] as string[]
)
if (Array.isArray(filter.authors)) {
if (
!filter.authors.some(startsWith(event.pubkey))
) {
if (isDelegatedEvent(event)) {
const delegation = event.tags.find((tag) => tag[0] === EventTags.Delegation)
if (targetMulticastGroups.length && !Array.isArray(filter['#m'])) {
return false
if (!filter.authors.some(startsWith(delegation[1]))) {
return false
}
} else {
return false
}
}
}
// NIP-27: Multicast
// const targetMulticastGroups: string[] = event.tags.reduce(
// (acc, tag) => (tag[0] === EventTags.Multicast)
// ? [...acc, tag[1]]
// : acc,
// [] as string[]
// )
// if (targetMulticastGroups.length && !Array.isArray(filter['#m'])) {
// return false
// }
// NIP-01: Support #e and #p tags
// NIP-12: Support generic tag queries
@ -89,6 +99,61 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Eve
return true
}
export const isDelegatedEvent = (event: Event): boolean => {
return event.tags.some((tag) => tag.length === 4 && tag[0] === EventTags.Delegation)
}
export const isDelegatedEventValid = async (event: Event): Promise<boolean> => {
const delegation = event.tags.find((tag) => tag.length === 4 && tag[0] === EventTags.Delegation)
if (!delegation) {
return false
}
const serializedDelegationTag = `nostr:${delegation[0]}:${event.pubkey}:${delegation[2]}`
const token = await secp256k1.utils.sha256(Buffer.from(serializedDelegationTag))
// Token generation to be decided:
// const serializedDelegationTag = [
// delegation[0], // 'delegation'
// delegation[1], // <delegator>
// event.pubkey, // <delegatee>
// delegation[2], // <rules>
// ]
// const token = await secp256k1.utils.sha256(Buffer.from(JSON.stringify(serializedDelegationTag)))
// Validate delegation signature
const verification = await secp256k1.schnorr.verify(delegation[3], token, delegation[1])
if (!verification) {
return false
}
// Validate rune
const runifiedEvent = (converge(
curry(mergeLeft),
[
omit(['tags']),
pipe(
prop('tags') as any,
reduceBy(
(acc, tag) => ([...acc, tag[1]]),
[],
nth(0),
),
),
],
) as any)(event)
try {
const [result] = Rune.from(delegation[2]).test(runifiedEvent)
return result
} catch (error) {
console.error('Invalid rune')
return false
}
}
export const isEventIdValid = async (event: Event): Promise<boolean> => {
const id = await secp256k1.utils.sha256(Buffer.from(JSON.stringify(serializeEvent(event))))

View File

@ -1,11 +1,11 @@
import { Event } from './@types/event'
import { SubscriptionId } from './@types/subscription'
import {
EndOfStoredEventsNotice,
MessageType,
Notice,
OutgoingMessage,
} from './@types/messages'
} from '../@types/messages'
import { Event } from '../@types/event'
import { SubscriptionId } from '../@types/subscription'
export const createNotice = (notice: string): Notice => {
return [MessageType.NOTICE, notice]

242
src/utils/runes.ts Normal file
View File

@ -0,0 +1,242 @@
const punctuations = /[!"#\$%&'()*+-.\/:;<=>?@\[\\\]^`{|}~]/
const hasPunctuation = (input) => punctuations.test(input)
// Reference: https://github.com/rustyrussell/runes/blob/master/runes/runes.py
export class Alternative {
public constructor(
private readonly field: string,
private readonly cond: string,
private readonly value: string,
) {
if (Array.from(this.field).some(hasPunctuation)) {
throw Error('Field is not valid')
}
if (!new Set(['!', '=', '/', '^', '$', '~', '<', '>', '}', '{', '#']).has(this.cond)) {
throw new Error('Cond not valid')
}
}
public test(values: Record<string, any>): string | undefined {
if (this.cond === '#') {
return
}
const why = (cond: boolean, field: string, explanation: string): string | undefined =>
(cond) ? undefined : `${field}: ${explanation}`
if (!(this.field in values)) {
return why(this.cond === '!', this.field, 'is missing')
}
if (typeof values[this.field] === 'function') {
return values[this.field](this)
}
const val = String(values[this.field])
switch (this.cond) {
case '!':
return why(false, this.field, 'is present')
case '=':
return why(val === this.value, this.field, `!= ${this.value}`)
case '/':
return why(val !== this.value, this.field, `= ${this.value}`)
case '^':
return why(val.startsWith(this.value), this.field, `does not start with ${this.value}`)
case '$':
return why(val.endsWith(this.value), this.field, `does not end with ${this.value}`)
case '~':
return why(values[this.field].includes(this.value), this.field, `does not contain ${this.value}`)
case '<':
case '>':
const actualInt = Number.parseInt(val)
if (Number.isNaN(actualInt)) {
return why(false, this.field, 'not an integer field')
}
const restrictionVal = Number.parseInt(this.value)
if (Number.isNaN(restrictionVal)) {
return why(false, this.field, 'not a valid integer')
}
if (this.cond === '<') {
return why(actualInt < restrictionVal, this.field, `>= ${restrictionVal}`)
} else {
return why(actualInt > restrictionVal, this.field, `<= ${restrictionVal}`)
}
case '{':
return why(val < this.value, this.field, `is the same or ordered after ${this.value}`)
case '{':
return why(val > this.value, this.field, `is the same or ordered before ${this.value}`)
default:
throw new Error('Invalid condition')
}
}
public encode(): string {
return `${this.field}${this.cond}${this.value.replace(/[\\|&]/g, '\\$&')}`
}
public valueOf(): string {
return this.encode()
}
public toString() {
return this.encode()
}
public static decode(encodedStr: string): [Alternative, string] {
let cond = undefined
let endOff = 0
while (endOff < encodedStr.length) {
if (hasPunctuation(encodedStr[endOff])) {
cond = encodedStr[endOff]
break
}
endOff++
}
if (typeof cond === 'undefined') {
throw new Error(`${encodedStr} does not contain any operator`)
}
const field = encodedStr.slice(0, endOff++)
let value = ''
while (endOff < encodedStr.length) {
if (encodedStr[endOff] === '|') {
endOff++
break
}
if (encodedStr[endOff] === '&') {
break
}
if (encodedStr[endOff] === '\\') {
endOff++
}
value += encodedStr[endOff++]
}
return [new Alternative(field, cond, value), encodedStr.slice(endOff)]
}
public static from(encodedStr: string): Alternative {
const [field, cond, value] = encodedStr.replace(/\s+/g, '').split(new RegExp(`(${punctuations.source})`, 'g'))
return new Alternative(field, cond, value)
}
}
export class Restriction {
public constructor(
private readonly alternatives: Alternative[]
) {
if (!alternatives.length) {
throw new Error('Restriction must have some alternatives')
}
}
public test(values: Record<string, any>): string | undefined {
const reasons: string[] = []
for (const alternative of this.alternatives) {
const reason = alternative.test(values)
if (typeof reason === 'undefined') {
return
}
reasons.push(reason)
}
return reasons.join(' AND ')
}
public encode(): string {
return this.alternatives.map((alternative) => alternative.encode()).join('|')
}
public valueOf(): string {
return this.encode()
}
public toString() {
return this.encode()
}
public static decode(encodedStr: string): [Restriction, string] {
let encStr = encodedStr
let alternative: Alternative
const alternatives: Alternative[] = []
while (encStr.length) {
if (encStr.startsWith('&')) {
encStr = encStr.slice(1)
break
}
[alternative, encStr] = Alternative.decode(encStr)
alternatives.push(alternative)
}
return [new Restriction(alternatives), encStr]
}
public static from(encodedStr: string): Restriction {
const [restriction, remainder] = Restriction.decode(encodedStr.replace(/\s+/g, ''))
if (remainder.length) {
throw new Error(`Restriction had extra characters at end: ${remainder}`)
}
return restriction
}
}
export class Rune {
public constructor(
private readonly restrictions: Restriction[] = []
) { }
public test(values: Record<string, string | string[]>): [boolean, string] {
for (const restriction of this.restrictions) {
const reasons = restriction.test(values)
if (typeof reasons !== 'undefined') {
return [false, reasons]
}
}
return [true, '']
}
public encode() {
return this.restrictions.map((restriction) => restriction.encode()).join('&')
}
public valueOf() {
return this.encode()
}
public toString() {
return this.encode()
}
public static from(encodedStr: string): Rune {
const restrictions: Restriction[] = []
let restriction: Restriction
let encStr = encodedStr.replace(/\s+/g, '')
while (encStr.length) {
[restriction, encStr] = Restriction.decode(encStr)
restrictions.push(restriction)
}
return new Rune(restrictions)
}
}

View File

@ -1,10 +1,10 @@
import { readFileSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import { mergeDeepRight } from 'ramda'
import { readFileSync } from 'fs'
import packageJson from '../package.json'
import { ISettings } from './@types/settings'
import { ISettings } from '../@types/settings'
import packageJson from '../../package.json'
let _settings: ISettings

View File

@ -4,7 +4,7 @@ export const streamMap = (fn: (chunk) => any) => new Transform({
objectMode: true,
transform(chunk, _encoding, callback) {
callback(null, fn(chunk))
}
},
})
export const streamEach = (writeFn: (chunk: any) => void) => new PassThrough({

View File

@ -8,4 +8,5 @@ const getValidationConfig = () => ({
export const validateSchema = (schema: Joi.Schema) => (input: any) => schema.validate(input, getValidationConfig())
export const attemptValidation = (schema: Joi.Schema) => (input: any) => Joi.attempt(input, schema, getValidationConfig())
export const attemptValidation = (schema: Joi.Schema) =>
(input: any) => Joi.attempt(input, schema, getValidationConfig())

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
import * as chai from 'chai'
import knex, { Knex } from 'knex'
import * as sinon from 'sinon'
import knex, { Knex } from 'knex'
import sinonChai from 'sinon-chai'
import { Event } from '../../../src/@types/event'
import { IEventRepository } from '../../../src/@types/repositories'
import { SubscriptionFilter } from '../../../src/@types/subscription'
@ -21,7 +22,7 @@ describe('EventRepository', () => {
sandbox = sinon.createSandbox()
dbClient = knex({
client: 'pg'
client: 'pg',
})
repository = new EventRepository(dbClient)
@ -70,7 +71,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_pubkey" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\')) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_pubkey" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\') or "event_delegator" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\')) order by "event_created_at" asc')
})
it('selects events by two authors', () => {
@ -78,14 +79,14 @@ describe('EventRepository', () => {
{
authors: [
'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793',
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
]
}
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245',
],
},
]
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where ("event_pubkey" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\')) order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where ("event_pubkey" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\') or "event_delegator" in (X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\')) order by "event_created_at" asc')
})
it('selects events by one author prefix (even length)', () => {
@ -93,13 +94,13 @@ describe('EventRepository', () => {
{
authors: [
'22e804',
]
}
],
},
]
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 3) = X\'22e804\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 3) = X\'22e804\' or substring("event_delegator" from 1 for 3) = X\'22e804\') order by "event_created_at" asc')
})
it('selects events by one author prefix (odd length)', () => {
@ -107,13 +108,13 @@ describe('EventRepository', () => {
{
authors: [
'22e804f',
]
}
],
},
]
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 4) BETWEEN E\'\\\\x22e804f0\' AND E\'\\\\x22e804ff\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 4) BETWEEN E\'\\\\x22e804f0\' AND E\'\\\\x22e804ff\' or substring("event_delegator" from 1 for 4) BETWEEN E\'\\\\x22e804f0\' AND E\'\\\\x22e804ff\') order by "event_created_at" asc')
})
it('selects events by two author prefix (first even, second odd)', () => {
@ -122,13 +123,13 @@ describe('EventRepository', () => {
authors: [
'22e804',
'32e1827',
]
}
],
},
]
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 3) = X\'22e804\' or substring("event_pubkey" from 1 for 4) BETWEEN E\'\\\\x32e18270\' AND E\'\\\\x32e1827f\') order by "event_created_at" asc')
expect(query).to.equal('select * from "events" where (substring("event_pubkey" from 1 for 3) = X\'22e804\' or substring("event_delegator" from 1 for 3) = X\'22e804\' or substring("event_pubkey" from 1 for 4) BETWEEN E\'\\\\x32e18270\' AND E\'\\\\x32e1827f\' or substring("event_delegator" from 1 for 4) BETWEEN E\'\\\\x32e18270\' AND E\'\\\\x32e1827f\') order by "event_created_at" asc')
})
})
@ -154,9 +155,9 @@ describe('EventRepository', () => {
{
ids: [
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
]
}
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
],
},
]
const query = repository.findByFilters(filters).toString()
@ -169,8 +170,8 @@ describe('EventRepository', () => {
{
ids: [
'abcd',
]
}
],
},
]
const query = repository.findByFilters(filters).toString()
@ -183,8 +184,8 @@ describe('EventRepository', () => {
{
ids: [
'abc',
]
}
],
},
]
const query = repository.findByFilters(filters).toString()
@ -198,8 +199,8 @@ describe('EventRepository', () => {
ids: [
'abcdef',
'abc',
]
}
],
},
]
const query = repository.findByFilters(filters).toString()
@ -359,7 +360,7 @@ describe('EventRepository', () => {
const query = repository.findByFilters(filters).toString()
expect(query).to.equal('(select * from "events" where "event_kind" in (1)) union (select * from "events" where (substring("event_id" from 1 for 3) BETWEEN E\'\\\\xaaaaa0\' AND E\'\\\\xaaaaaf\') order by "event_created_at" asc) union (select * from "events" where (substring("event_pubkey" from 1 for 3) BETWEEN E\'\\\\xbbbbb0\' AND E\'\\\\xbbbbbf\') order by "event_created_at" asc) union (select * from "events" where "event_created_at" >= 1000 order by "event_created_at" asc) union (select * from "events" where "event_created_at" <= 1000 order by "event_created_at" asc) union (select * from "events" order by "event_created_at" DESC limit 1000) order by "event_created_at" asc')
expect(query).to.equal('(select * from "events" where "event_kind" in (1)) union (select * from "events" where (substring("event_id" from 1 for 3) BETWEEN E\'\\\\xaaaaa0\' AND E\'\\\\xaaaaaf\') order by "event_created_at" asc) union (select * from "events" where (substring("event_pubkey" from 1 for 3) BETWEEN E\'\\\\xbbbbb0\' AND E\'\\\\xbbbbbf\' or substring("event_delegator" from 1 for 3) BETWEEN E\'\\\\xbbbbb0\' AND E\'\\\\xbbbbbf\') order by "event_created_at" asc) union (select * from "events" where "event_created_at" >= 1000 order by "event_created_at" asc) union (select * from "events" where "event_created_at" <= 1000 order by "event_created_at" asc) union (select * from "events" order by "event_created_at" DESC limit 1000) order by "event_created_at" asc')
})
})
})
@ -430,7 +431,7 @@ describe('EventRepository', () => {
const query = (repository as any).insert(event).toString()
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags") values (\'I\'\'ve set up mirroring between relays: https://i.imgur.com/HxCDipB.png\', 1648351380, X\'6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407\', 1, X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'b37adfed0e6398546d623536f9ddc92b95b7dc71927e1123266332659253ecd0ffa91ddf2c0a82a8426c5b363139d28534d6cac893b8a810149557a3f6d36768\', \'[["p","8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9","wss://nostr-pub.wellorder.net"],["e","7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96","wss://nostr-relay.untethr.me"]]\') on conflict do nothing')
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags") values (\'I\'\'ve set up mirroring between relays: https://i.imgur.com/HxCDipB.png\', 1648351380, NULL, X\'6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407\', 1, X\'22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793\', X\'b37adfed0e6398546d623536f9ddc92b95b7dc71927e1123266332659253ecd0ffa91ddf2c0a82a8426c5b363139d28534d6cac893b8a810149557a3f6d36768\', \'[["p","8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9","wss://nostr-pub.wellorder.net"],["e","7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96","wss://nostr-relay.untethr.me"]]\') on conflict do nothing')
})
})
})

View File

@ -1,5 +1,6 @@
import { expect } from 'chai'
import { assocPath, omit, range } from 'ramda'
import { expect } from 'chai'
import { Event } from '../../../src/@types/event'
import { eventSchema } from '../../../src/schemas/event-schema'
import { validateSchema } from '../../../src/utils/validation'
@ -18,33 +19,33 @@ describe('NIP-01', () => {
'e',
'c58e83bb744e4c29642db7a5c3bd1519516ad5c51f6ba5f90c451d03c1961210',
'',
'root'
'root',
],
[
'e',
'd0d78967b734628cec7bdfa2321c71c1f1c48e211b4b54333c3b0e94e7e99166',
'',
'reply'
'reply',
],
[
'p',
'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29'
'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29',
],
[
'p',
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245',
],
[
'e',
'6fed2aae1e4f7d8b535774e4f7061c10e2ff20df1ef047da09462c7937925cd5'
'6fed2aae1e4f7d8b535774e4f7061c10e2ff20df1ef047da09462c7937925cd5',
],
[
'p',
'2ef93f01cd2493e04235a6b87b10d3c4a74e2a7eb7c3caf168268f6af73314b5'
]
'2ef93f01cd2493e04235a6b87b10d3c4a74e2a7eb7c3caf168268f6af73314b5',
],
],
'content': '',
'sig': '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96'
'sig': '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96',
}
})
@ -57,66 +58,66 @@ describe('NIP-01', () => {
const cases = {
id: [
{ message: 'must be a string', transform: assocPath(['id'], null), },
{ message: 'must only contain lowercase characters', transform: assocPath(['id'], 'F'.repeat(64)), },
{ message: 'must only contain hexadecimal characters', transform: assocPath(['id'], 'not hex'), },
{ message: 'length must be 64 characters long', transform: assocPath(['id'], 'f'.repeat(65)), },
{ message: 'length must be 64 characters long', transform: assocPath(['id'], 'f'.repeat(63)), },
{ message: 'is not allowed to be empty', transform: assocPath(['id'], ''), },
{ message: 'is required', transform: omit(['id']), },
{ message: 'must be a string', transform: assocPath(['id'], null) },
{ message: 'must only contain lowercase characters', transform: assocPath(['id'], 'F'.repeat(64)) },
{ message: 'must only contain hexadecimal characters', transform: assocPath(['id'], 'not hex') },
{ message: 'length must be 64 characters long', transform: assocPath(['id'], 'f'.repeat(65)) },
{ message: 'length must be 64 characters long', transform: assocPath(['id'], 'f'.repeat(63)) },
{ message: 'is not allowed to be empty', transform: assocPath(['id'], '') },
{ message: 'is required', transform: omit(['id']) },
],
pubkey: [
{ message: 'must be a string', transform: assocPath(['pubkey'], null), },
{ message: 'must only contain lowercase characters', transform: assocPath(['pubkey'], 'F'.repeat(64)), },
{ message: 'must only contain hexadecimal characters', transform: assocPath(['pubkey'], 'not hex'), },
{ message: 'length must be 64 characters long', transform: assocPath(['pubkey'], 'f'.repeat(65)), },
{ message: 'length must be 64 characters long', transform: assocPath(['pubkey'], 'f'.repeat(63)), },
{ message: 'is not allowed to be empty', transform: assocPath(['pubkey'], ''), },
{ message: 'is required', transform: omit(['pubkey']), },
{ message: 'must be a string', transform: assocPath(['pubkey'], null) },
{ message: 'must only contain lowercase characters', transform: assocPath(['pubkey'], 'F'.repeat(64)) },
{ message: 'must only contain hexadecimal characters', transform: assocPath(['pubkey'], 'not hex') },
{ message: 'length must be 64 characters long', transform: assocPath(['pubkey'], 'f'.repeat(65)) },
{ message: 'length must be 64 characters long', transform: assocPath(['pubkey'], 'f'.repeat(63)) },
{ message: 'is not allowed to be empty', transform: assocPath(['pubkey'], '') },
{ message: 'is required', transform: omit(['pubkey']) },
],
created_at: [
{ message: 'must be a number', transform: assocPath(['created_at'], null), },
{ message: 'must be greater than or equal to 0', transform: assocPath(['created_at'], -1), },
{ message: 'must be a multiple of 1', transform: assocPath(['created_at'], Math.PI), },
{ message: 'is required', transform: omit(['created_at']), },
{ message: 'must be a number', transform: assocPath(['created_at'], null) },
{ message: 'must be greater than or equal to 0', transform: assocPath(['created_at'], -1) },
{ message: 'must be a multiple of 1', transform: assocPath(['created_at'], Math.PI) },
{ message: 'is required', transform: omit(['created_at']) },
],
kind: [
{ message: 'must be a number', transform: assocPath(['kind'], null), },
{ message: 'must be greater than or equal to 0', transform: assocPath(['kind'], -1), },
{ message: 'must be a multiple of 1', transform: assocPath(['kind'], Math.PI), },
{ message: 'is required', transform: omit(['kind']), },
{ message: 'must be a number', transform: assocPath(['kind'], null) },
{ message: 'must be greater than or equal to 0', transform: assocPath(['kind'], -1) },
{ message: 'must be a multiple of 1', transform: assocPath(['kind'], Math.PI) },
{ message: 'is required', transform: omit(['kind']) },
],
content: [
{ message: 'must be a string', transform: assocPath(['content'], null), },
{ message: 'length must be less than or equal to 65536 characters long', transform: assocPath(['content'], ' '.repeat(64 * 1024 + 1)), },
{ message: 'is required', transform: omit(['content']), },
{ message: 'must be a string', transform: assocPath(['content'], null) },
{ message: 'length must be less than or equal to 65536 characters long', transform: assocPath(['content'], ' '.repeat(64 * 1024 + 1)) },
{ message: 'is required', transform: omit(['content']) },
],
sig: [
{ message: 'must be a string', transform: assocPath(['sig'], null), },
{ message: 'must only contain lowercase characters', transform: assocPath(['sig'], 'F'.repeat(128)), },
{ message: 'must only contain hexadecimal characters', transform: assocPath(['sig'], 'not hex'), },
{ message: 'length must be 128 characters long', transform: assocPath(['sig'], 'f'.repeat(129)), },
{ message: 'length must be 128 characters long', transform: assocPath(['sig'], 'f'.repeat(127)), },
{ message: 'is not allowed to be empty', transform: assocPath(['sig'], ''), },
{ message: 'is required', transform: omit(['sig']), },
{ message: 'must be a string', transform: assocPath(['sig'], null) },
{ message: 'must only contain lowercase characters', transform: assocPath(['sig'], 'F'.repeat(128)) },
{ message: 'must only contain hexadecimal characters', transform: assocPath(['sig'], 'not hex') },
{ message: 'length must be 128 characters long', transform: assocPath(['sig'], 'f'.repeat(129)) },
{ message: 'length must be 128 characters long', transform: assocPath(['sig'], 'f'.repeat(127)) },
{ message: 'is not allowed to be empty', transform: assocPath(['sig'], '') },
{ message: 'is required', transform: omit(['sig']) },
],
tags: [
{ message: 'must be an array', transform: assocPath(['tags'], null), },
{ message: 'is required', transform: omit(['tags']), },
{ message: 'must be an array', transform: assocPath(['tags'], null) },
{ message: 'is required', transform: omit(['tags']) },
{ message: 'must contain less than or equal to 500 items', transform: assocPath(['tags'], range(0, 501).map(() => (['x', 'x']))) },
],
tag: [
{ message: 'must be an array', transform: assocPath(['tags', 0], null), },
{ message: 'must be an array', transform: assocPath(['tags', 0], null) },
{ message: 'must contain less than or equal to 10 items', transform: assocPath(['tags', 0], range(0, 11).map(() => 'x')) },
],
identifier: [
{ message: 'must be a string', transform: assocPath(['tags', 0, 0], null), },
{ message: 'length must be less than or equal to 255 characters long', transform: assocPath(['tags', 0, 0], ' '.repeat(256)), },
{ message: 'is not allowed to be empty', transform: assocPath(['tags', 0, 0], ''), },
{ message: 'must be a string', transform: assocPath(['tags', 0, 0], null) },
{ message: 'length must be less than or equal to 255 characters long', transform: assocPath(['tags', 0, 0], ' '.repeat(256)) },
{ message: 'is not allowed to be empty', transform: assocPath(['tags', 0, 0], '') },
],
value: [
{ message: 'must be a string', transform: assocPath(['tags', 0, 1], null), },
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['tags', 0, 1], ' '.repeat(1024 + 1)), },
{ message: 'must be a string', transform: assocPath(['tags', 0, 1], null) },
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['tags', 0, 1], ' '.repeat(1024 + 1)) },
],
}

View File

@ -1,7 +1,8 @@
import { expect } from 'chai'
import { assocPath, range } from 'ramda'
import { SubscriptionFilter } from '../../../src/@types/subscription'
import { expect } from 'chai'
import { filterSchema } from '../../../src/schemas/filter-schema'
import { SubscriptionFilter } from '../../../src/@types/subscription'
import { validateSchema } from '../../../src/utils/validation'
describe('NIP-01', () => {
@ -30,71 +31,71 @@ describe('NIP-01', () => {
const cases = {
ids: [
{ message: 'must be an array', transform: assocPath(['ids'], null), },
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['ids'], range(0, 257).map(() => 'f')), },
{ message: 'must be an array', transform: assocPath(['ids'], null) },
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['ids'], range(0, 257).map(() => 'f')) },
],
prefixOrId: [
{ message: 'length must be less than or equal to 64 characters long', transform: assocPath(['ids', 0], 'f'.repeat(65)), },
{ message: 'must only contain hexadecimal characters', transform: assocPath(['ids', 0], 'not hex'), },
{ message: 'is not allowed to be empty', transform: assocPath(['ids', 0], ''), },
{ message: 'length must be less than or equal to 64 characters long', transform: assocPath(['ids', 0], 'f'.repeat(65)) },
{ message: 'must only contain hexadecimal characters', transform: assocPath(['ids', 0], 'not hex') },
{ message: 'is not allowed to be empty', transform: assocPath(['ids', 0], '') },
],
authors: [
{ message: 'must be an array', transform: assocPath(['authors'], null), },
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['authors'], range(0, 257).map(() => 'f')), },
{ message: 'must be an array', transform: assocPath(['authors'], null) },
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['authors'], range(0, 257).map(() => 'f')) },
],
prefixOrAuthor: [
{ message: 'length must be less than or equal to 64 characters long', transform: assocPath(['authors', 0], 'f'.repeat(65)), },
{ message: 'must only contain hexadecimal characters', transform: assocPath(['authors', 0], 'not hex'), },
{ message: 'is not allowed to be empty', transform: assocPath(['authors', 0], ''), },
{ message: 'length must be less than or equal to 64 characters long', transform: assocPath(['authors', 0], 'f'.repeat(65)) },
{ message: 'must only contain hexadecimal characters', transform: assocPath(['authors', 0], 'not hex') },
{ message: 'is not allowed to be empty', transform: assocPath(['authors', 0], '') },
],
kinds: [
{ message: 'must be an array', transform: assocPath(['kinds'], null), },
{ message: 'must contain less than or equal to 20 items', transform: assocPath(['kinds'], range(0, 21).map(() => 1)), },
{ message: 'must be an array', transform: assocPath(['kinds'], null) },
{ message: 'must contain less than or equal to 20 items', transform: assocPath(['kinds'], range(0, 21).map(() => 1)) },
],
kind: [
{ message: 'must be greater than or equal to 0', transform: assocPath(['kinds', 0], -1), },
{ message: 'must be a number', transform: assocPath(['kinds', 0], null), },
{ message: 'must be a multiple of 1', transform: assocPath(['kinds', 0], Math.PI), },
{ message: 'must be greater than or equal to 0', transform: assocPath(['kinds', 0], -1) },
{ message: 'must be a number', transform: assocPath(['kinds', 0], null) },
{ message: 'must be a multiple of 1', transform: assocPath(['kinds', 0], Math.PI) },
],
since: [
{ message: 'must be a number', transform: assocPath(['since'], null), },
{ message: 'must be greater than or equal to 0', transform: assocPath(['since'], -1), },
{ message: 'must be a multiple of 1', transform: assocPath(['since'], Math.PI), },
{ message: 'must be a number', transform: assocPath(['since'], null) },
{ message: 'must be greater than or equal to 0', transform: assocPath(['since'], -1) },
{ message: 'must be a multiple of 1', transform: assocPath(['since'], Math.PI) },
],
until: [
{ message: 'must be a number', transform: assocPath(['until'], null), },
{ message: 'must be greater than or equal to 0', transform: assocPath(['until'], -1), },
{ message: 'must be a multiple of 1', transform: assocPath(['until'], Math.PI), },
{ message: 'must be a number', transform: assocPath(['until'], null) },
{ message: 'must be greater than or equal to 0', transform: assocPath(['until'], -1) },
{ message: 'must be a multiple of 1', transform: assocPath(['until'], Math.PI) },
],
limit: [
{ message: 'must be a number', transform: assocPath(['limit'], null), },
{ message: 'must be greater than or equal to 1', transform: assocPath(['limit'], -1), },
{ message: 'must be a multiple of 1', transform: assocPath(['limit'], Math.PI), },
{ message: 'must be less than or equal to 10000', transform: assocPath(['limit'], 10001), },
{ message: 'must be a number', transform: assocPath(['limit'], null) },
{ message: 'must be greater than or equal to 1', transform: assocPath(['limit'], -1) },
{ message: 'must be a multiple of 1', transform: assocPath(['limit'], Math.PI) },
{ message: 'must be less than or equal to 10000', transform: assocPath(['limit'], 10001) },
],
'#e': [
{ message: 'must be an array', transform: assocPath(['#e'], null), },
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['#e'], range(0, 1024 + 1).map(() => 'f')), },
{ message: 'must be an array', transform: assocPath(['#e'], null) },
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['#e'], range(0, 1024 + 1).map(() => 'f')) },
],
'#e[0]': [
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['#e', 0], 'f'.repeat(1024 + 1)), },
{ message: 'is not allowed to be empty', transform: assocPath(['#e', 0], ''), },
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['#e', 0], 'f'.repeat(1024 + 1)) },
{ message: 'is not allowed to be empty', transform: assocPath(['#e', 0], '') },
],
'#p': [
{ message: 'must be an array', transform: assocPath(['#p'], null), },
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['#p'], range(0, 1024 + 1).map(() => 'f')), },
{ message: 'must be an array', transform: assocPath(['#p'], null) },
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['#p'], range(0, 1024 + 1).map(() => 'f')) },
],
'#p[0]': [
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['#p', 0], 'f'.repeat(1024 + 1)), },
{ message: 'is not allowed to be empty', transform: assocPath(['#p', 0], ''), },
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['#p', 0], 'f'.repeat(1024 + 1)) },
{ message: 'is not allowed to be empty', transform: assocPath(['#p', 0], '') },
],
'#r': [
{ message: 'must be an array', transform: assocPath(['#r'], null), },
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['#r'], range(0, 1024 + 1).map(() => 'f')), },
{ message: 'must be an array', transform: assocPath(['#r'], null) },
{ message: 'must contain less than or equal to 256 items', transform: assocPath(['#r'], range(0, 1024 + 1).map(() => 'f')) },
],
'#r[0]': [
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['#r', 0], 'f'.repeat(1024 + 1)), },
{ message: 'is not allowed to be empty', transform: assocPath(['#r', 0], ''), },
{ message: 'length must be less than or equal to 1024 characters long', transform: assocPath(['#r', 0], 'f'.repeat(1024 + 1)) },
{ message: 'is not allowed to be empty', transform: assocPath(['#r', 0], '') },
],
}

View File

@ -1,11 +1,11 @@
import { expect } from 'chai'
import { range } from 'ramda'
import { Event } from '../../../src/@types/event'
import { Event } from '../../../src/@types/event'
import { getEvents } from '../data/events'
import { IncomingMessage } from '../../../src/@types/messages'
import { messageSchema } from '../../../src/schemas/message-schema'
import { validateSchema } from '../../../src/utils/validation'
import { getEvents } from '../data/events'
describe('NIP-01', () => {
let message: IncomingMessage
@ -20,7 +20,7 @@ describe('NIP-01', () => {
events.forEach((event) => {
message = [
'EVENT',
event
event,
] as any
const result = validateSchema(messageSchema)(message)

View File

@ -1,6 +1,17 @@
import { expect } from 'chai'
import { Event, CanonicalEvent } from '../../../src/@types/event'
import { isEphemeralEvent, isEventIdValid, isEventMatchingFilter, isEventSignatureValid, isNullEvent, isReplaceableEvent, serializeEvent } from '../../../src/utils/event'
import { CanonicalEvent, Event } from '../../../src/@types/event'
import {
isDelegatedEvent,
isDelegatedEventValid,
isEphemeralEvent,
isEventIdValid,
isEventMatchingFilter,
isEventSignatureValid,
isNullEvent,
isReplaceableEvent,
serializeEvent,
} from '../../../src/utils/event'
import { EventKinds } from '../../../src/constants/base'
describe('NIP-01', () => {
@ -226,7 +237,7 @@ describe('NIP-01', () => {
'kind': 1,
'tags': [],
'content': 'learning terraform rn!',
'sig': 'ec8b2bc640c8c7e92fbc0e0a6f539da2635068a99809186f15106174d727456132977c78f3371d0ab01c108173df75750f33d8e04c4d7980bbb3fb70ba1e3848'
'sig': 'ec8b2bc640c8c7e92fbc0e0a6f539da2635068a99809186f15106174d727456132977c78f3371d0ab01c108173df75750f33d8e04c4d7980bbb3fb70ba1e3848',
}
})
@ -256,7 +267,7 @@ describe('NIP-01', () => {
'kind': 1,
'tags': [],
'content': 'learning terraform rn!',
'sig': 'ec8b2bc640c8c7e92fbc0e0a6f539da2635068a99809186f15106174d727456132977c78f3371d0ab01c108173df75750f33d8e04c4d7980bbb3fb70ba1e3848'
'sig': 'ec8b2bc640c8c7e92fbc0e0a6f539da2635068a99809186f15106174d727456132977c78f3371d0ab01c108173df75750f33d8e04c4d7980bbb3fb70ba1e3848',
}
})
@ -335,55 +346,89 @@ describe('isNullEvent', () => {
})
})
describe('NIP-27', () => {
describe('isEventMatchingFilter', () => {
describe('#m filter', () => {
let event: Event
beforeEach(() => {
event = {
tags: [
[
'm',
'group',
],
],
} as any
})
// describe('NIP-27', () => {
// describe('isEventMatchingFilter', () => {
// describe('#m filter', () => {
// let event: Event
// beforeEach(() => {
// event = {
// tags: [
// [
// 'm',
// 'group',
// ],
// ],
// } as any
// })
it('returns true given non-multicast event and there is no #m filter', () => {
event.tags = []
expect(isEventMatchingFilter({})(event)).to.be.true
})
// it('returns true given non-multicast event and there is no #m filter', () => {
// event.tags = []
// expect(isEventMatchingFilter({})(event)).to.be.true
// })
it('returns true given multicast event and contained in #m filter', () => {
expect(isEventMatchingFilter({ '#m': ['group'] })(event)).to.be.true
})
// it('returns true given multicast event and contained in #m filter', () => {
// expect(isEventMatchingFilter({ '#m': ['group'] })(event)).to.be.true
// })
it('returns true given multicast event and contained second in #m filter', () => {
expect(isEventMatchingFilter({ '#m': ['some group', 'group'] })(event)).to.be.true
})
// it('returns true given multicast event and contained second in #m filter', () => {
// expect(isEventMatchingFilter({ '#m': ['some group', 'group'] })(event)).to.be.true
// })
it('returns false given multicast event and not contained in #m filter', () => {
expect(isEventMatchingFilter({ '#m': ['other group'] })(event)).to.be.false
})
// it('returns false given multicast event and not contained in #m filter', () => {
// expect(isEventMatchingFilter({ '#m': ['other group'] })(event)).to.be.false
// })
it('returns false if given multicast event and there is no #m filter', () => {
expect(isEventMatchingFilter({})(event)).to.be.false
})
// it('returns false if given multicast event and there is no #m filter', () => {
// expect(isEventMatchingFilter({})(event)).to.be.false
// })
it('returns false if given multicast event and #m filter is empty', () => {
expect(isEventMatchingFilter({ '#m': [] })(event)).to.be.false
})
// it('returns false if given multicast event and #m filter is empty', () => {
// expect(isEventMatchingFilter({ '#m': [] })(event)).to.be.false
// })
it('returns false given non-multicast event and filter contains some group', () => {
event.tags = []
expect(isEventMatchingFilter({ '#m': ['group'] })(event)).to.be.false
})
// it('returns false given non-multicast event and filter contains some group', () => {
// event.tags = []
// expect(isEventMatchingFilter({ '#m': ['group'] })(event)).to.be.false
// })
it('returns false given non-multicast event and filter is empty', () => {
event.tags = []
expect(isEventMatchingFilter({ '#m': [] })(event)).to.be.false
})
// it('returns false given non-multicast event and filter is empty', () => {
// event.tags = []
// expect(isEventMatchingFilter({ '#m': [] })(event)).to.be.false
// })
// })
// })
// })
describe('NIP-26', () => {
let event: Event
beforeEach(() => {
event = {
'id': 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
'pubkey': '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
'created_at': 1660896109,
'kind': 1,
'tags': [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
],
],
'content': 'Hello world',
'sig': 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
}
})
describe('isDelegatedEvent', () => {
it('returns true if event contains delegation tag', () => {
expect(isDelegatedEvent(event)).to.be.true
})
})
describe('isDelegatedEventValid', () => {
it('resolves with true if delegated event is valid', async () => {
expect(await isDelegatedEventValid(event)).to.be.true
})
})
})

View File

@ -1,4 +1,5 @@
import { expect } from 'chai'
import { fromBuffer, toBuffer, toJSON } from '../../../src/utils/transform'