From 084def1501bbd08dc6276f8b7704f6d25d0fd869 Mon Sep 17 00:00:00 2001 From: CKodidela Date: Sat, 2 May 2026 14:15:51 +0000 Subject: [PATCH 1/5] refactor: normalize runCommandWithOutput result type to handle spawn errors --- src/cli/commands/info.ts | 11 +++----- src/cli/commands/update.ts | 4 +++ src/cli/utils/process.ts | 50 ++++++++++++++++++++++++++++-------- test/unit/cli/info.spec.ts | 25 +++++++++++++----- test/unit/cli/update.spec.ts | 2 ++ 5 files changed, 67 insertions(+), 25 deletions(-) diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts index 3c9450c2..9b162ff8 100644 --- a/src/cli/commands/info.ts +++ b/src/cli/commands/info.ts @@ -56,13 +56,8 @@ const getEventCount = async (): Promise => { } const getRelayUptimeSeconds = async (): Promise => { - let idResult: { code: number; stdout: string; stderr: string } - try { - idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 }) - } catch { - return null - } - if (idResult.code !== 0) { + const idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 }) + if (!idResult.ok || idResult.code !== 0) { return null } @@ -74,7 +69,7 @@ const getRelayUptimeSeconds = async (): Promise => { const startedAtResult = await runCommandWithOutput('docker', ['inspect', '--format', '{{.State.StartedAt}}', containerId], { timeoutMs: 1000, }) - if (startedAtResult.code !== 0) { + if (!startedAtResult.ok || startedAtResult.code !== 0) { return null } diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index 6fb3026d..0c6859da 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -18,6 +18,10 @@ export const runUpdate = async (passthrough: string[]): Promise => { } const stashResult = await runCommandWithOutput('git', ['stash', 'push', '-u', '-m', 'nostream-cli-update']) + if (!stashResult.ok) { + spinner.fail(stashResult.ok === false && stashResult.reason === 'not-found' ? 'Update failed: git is not installed' : 'Update failed while stashing local changes') + return 1 + } if (stashResult.code !== 0) { spinner.fail('Update failed while stashing local changes') return stashResult.code diff --git a/src/cli/utils/process.ts b/src/cli/utils/process.ts index a574de08..c0d49d0c 100644 --- a/src/cli/utils/process.ts +++ b/src/cli/utils/process.ts @@ -7,6 +7,10 @@ export type RunOptions = { timeoutMs?: number } +export type CommandResult = + | { ok: true; code: number; stdout: string; stderr: string } + | { ok: false; reason: 'not-found' | 'permission-denied' | 'spawn-error' | 'timeout' | 'signal'; stdout: string; stderr: string } + export const runCommand = (command: string, args: string[], options: RunOptions = {}): Promise => { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -38,10 +42,19 @@ export const runCommandWithOutput = ( command: string, args: string[], options: RunOptions = {}, -): Promise<{ code: number; stdout: string; stderr: string }> => { - return new Promise((resolve, reject) => { +): Promise => { + return new Promise((resolve) => { let stdout = '' let stderr = '' + let timedOut = false + let settled = false + + const settle = (result: CommandResult) => { + if (!settled) { + settled = true + resolve(result) + } + } const child = spawn(command, args, { cwd: options.cwd, @@ -53,6 +66,7 @@ export const runCommandWithOutput = ( const timer = typeof options.timeoutMs === 'number' ? setTimeout(() => { + timedOut = true child.kill('SIGTERM') }, options.timeoutMs) : undefined @@ -65,17 +79,31 @@ export const runCommandWithOutput = ( stderr += chunk.toString() }) - child.on('error', reject) - child.on('close', (code) => { - if (timer) { - clearTimeout(timer) + child.on('error', (err: NodeJS.ErrnoException) => { + if (timer) clearTimeout(timer) + if (err.code === 'ENOENT') { + settle({ ok: false, reason: 'not-found', stdout, stderr }) + } else if (err.code === 'EACCES') { + settle({ ok: false, reason: 'permission-denied', stdout, stderr }) + } else { + settle({ ok: false, reason: 'spawn-error', stdout, stderr }) + } + }) + + child.on('close', (code, signal) => { + if (timer) clearTimeout(timer) + + if (timedOut) { + settle({ ok: false, reason: 'timeout', stdout, stderr }) + return + } + + if (signal !== null && code === null) { + settle({ ok: false, reason: 'signal', stdout, stderr }) + return } - resolve({ - code: code ?? 1, - stdout, - stderr, - }) + settle({ ok: true, code: code ?? 1, stdout, stderr }) }) }) } diff --git a/test/unit/cli/info.spec.ts b/test/unit/cli/info.spec.ts index ffe0bd83..7bf9b892 100644 --- a/test/unit/cli/info.spec.ts +++ b/test/unit/cli/info.spec.ts @@ -31,14 +31,27 @@ describe('runInfo', () => { sinon.restore() }) + it('outputs valid JSON when docker is not installed (ENOENT)', async () => { + sinon.stub(fs, 'existsSync').returns(false) + sinon.stub(processUtils, 'runCommandWithOutput').resolves({ ok: false, reason: 'not-found', stdout: '', stderr: '' }) + + const code = await infoCommand.runInfo({ json: true }) + + expect(code).to.equal(0) + const parsed = JSON.parse(stdout) + expect(parsed).to.have.nested.property('runtime.uptimeSeconds', null) + expect(stderr).to.equal('') + }) + it('prints detected I2P hostnames as JSON', async () => { sinon.stub(fs, 'existsSync').callsFake((target) => String(target).endsWith('nostream.dat')) sinon .stub(processUtils, 'runCommandWithOutput') .onFirstCall() - .resolves({ code: 1, stdout: '', stderr: '' }) + .resolves({ ok: true, code: 1, stdout: '', stderr: '' }) .onSecondCall() .resolves({ + ok: true, code: 0, stdout: 'alphaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.b32.i2p\n', stderr: 'betabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.b32.i2p\n', @@ -58,7 +71,7 @@ describe('runInfo', () => { it('prints a JSON error when I2P keys are missing', async () => { sinon.stub(fs, 'existsSync').returns(false) - sinon.stub(processUtils, 'runCommandWithOutput').resolves({ code: 1, stdout: '', stderr: '' }) + sinon.stub(processUtils, 'runCommandWithOutput').resolves({ ok: true, code: 1, stdout: '', stderr: '' }) const code = await infoCommand.runInfo({ i2pHostname: true, json: true }) @@ -77,9 +90,9 @@ describe('runInfo', () => { sinon .stub(processUtils, 'runCommandWithOutput') .onFirstCall() - .resolves({ code: 1, stdout: '', stderr: '' }) + .resolves({ ok: true, code: 1, stdout: '', stderr: '' }) .onSecondCall() - .resolves({ code: 0, stdout: '', stderr: '' }) + .resolves({ ok: true, code: 0, stdout: '', stderr: '' }) const code = await infoCommand.runInfo({ i2pHostname: true, json: true }) @@ -101,9 +114,9 @@ describe('runInfo', () => { sinon .stub(processUtils, 'runCommandWithOutput') .onFirstCall() - .resolves({ code: 1, stdout: '', stderr: '' }) + .resolves({ ok: true, code: 1, stdout: '', stderr: '' }) .onSecondCall() - .resolves({ code: 0, stdout: '', stderr: '' }) + .resolves({ ok: true, code: 0, stdout: '', stderr: '' }) const code = await infoCommand.runInfo({ i2pHostname: true }) diff --git a/test/unit/cli/update.spec.ts b/test/unit/cli/update.spec.ts index 6c5d9308..7f216b71 100644 --- a/test/unit/cli/update.spec.ts +++ b/test/unit/cli/update.spec.ts @@ -15,6 +15,7 @@ describe('runUpdate', () => { sinon.stub(stopCommand, 'runStop').resolves(0) const runStartStub = sinon.stub(startCommand, 'runStart').resolves(0) sinon.stub(processUtils, 'runCommandWithOutput').resolves({ + ok: true, code: 0, stdout: 'Saved working directory and index state WIP on main: abc123', stderr: '', @@ -38,6 +39,7 @@ describe('runUpdate', () => { sinon.stub(stopCommand, 'runStop').resolves(0) const runStartStub = sinon.stub(startCommand, 'runStart').resolves(0) sinon.stub(processUtils, 'runCommandWithOutput').resolves({ + ok: true, code: 0, stdout: 'Saved working directory and index state WIP on main: abc123', stderr: '', From 3bfc101ba18b66113fcb95e1970282bbb4bcf47f Mon Sep 17 00:00:00 2001 From: CKodidela Date: Sat, 2 May 2026 14:18:47 +0000 Subject: [PATCH 2/5] fix: add braces to single-line if statements in process.ts --- src/cli/utils/process.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/utils/process.ts b/src/cli/utils/process.ts index c0d49d0c..51185144 100644 --- a/src/cli/utils/process.ts +++ b/src/cli/utils/process.ts @@ -80,7 +80,7 @@ export const runCommandWithOutput = ( }) child.on('error', (err: NodeJS.ErrnoException) => { - if (timer) clearTimeout(timer) + if (timer) { clearTimeout(timer) } if (err.code === 'ENOENT') { settle({ ok: false, reason: 'not-found', stdout, stderr }) } else if (err.code === 'EACCES') { @@ -91,7 +91,7 @@ export const runCommandWithOutput = ( }) child.on('close', (code, signal) => { - if (timer) clearTimeout(timer) + if (timer) { clearTimeout(timer) } if (timedOut) { settle({ ok: false, reason: 'timeout', stdout, stderr }) From 6e5ce6087d54de0756dc15bb6314040a8bdb275e Mon Sep 17 00:00:00 2001 From: CKodidela Date: Sat, 2 May 2026 14:23:15 +0000 Subject: [PATCH 3/5] chore: add changeset for runCommandWithOutput normalization --- .changeset/normalize-run-command-with-output.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/normalize-run-command-with-output.md diff --git a/.changeset/normalize-run-command-with-output.md b/.changeset/normalize-run-command-with-output.md new file mode 100644 index 00000000..d086daa9 --- /dev/null +++ b/.changeset/normalize-run-command-with-output.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Normalize runCommandWithOutput to return a CommandResult discriminated union instead of rejecting on spawn errors, fixing a crash in `info --json` when Docker is not installed. From bbf199a9ef213e0b5a7217d586a554b11fd4e816 Mon Sep 17 00:00:00 2001 From: CKodidela Date: Sat, 2 May 2026 14:41:04 +0000 Subject: [PATCH 4/5] test: add unit tests for runCommandWithOutput covering all result paths --- test/unit/cli/run-command.spec.ts | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/unit/cli/run-command.spec.ts diff --git a/test/unit/cli/run-command.spec.ts b/test/unit/cli/run-command.spec.ts new file mode 100644 index 00000000..98e6d190 --- /dev/null +++ b/test/unit/cli/run-command.spec.ts @@ -0,0 +1,44 @@ +const { expect } = require('chai') + +const { runCommandWithOutput } = require('../../../dist/src/cli/utils/process.js') + +describe('runCommandWithOutput', () => { + it('resolves ok:true with captured stdout, stderr and exit code 0', async () => { + const result = await runCommandWithOutput('sh', ['-c', 'echo out; echo err >&2']) + + expect(result).to.deep.equal({ ok: true, code: 0, stdout: 'out\n', stderr: 'err\n' }) + }) + + it('resolves ok:true with non-zero exit code', async () => { + const result = await runCommandWithOutput('sh', ['-c', 'exit 2']) + + expect(result.ok).to.equal(true) + expect(result.code).to.equal(2) + }) + + it('resolves ok:false reason:not-found when command does not exist (ENOENT)', async () => { + const result = await runCommandWithOutput('__nostream_nonexistent_cmd__', []) + + expect(result).to.deep.equal({ ok: false, reason: 'not-found', stdout: '', stderr: '' }) + }) + + it('resolves ok:false reason:timeout when the process exceeds timeoutMs', async () => { + const result = await runCommandWithOutput('sleep', ['10'], { timeoutMs: 100 }) + + expect(result).to.deep.equal({ ok: false, reason: 'timeout', stdout: '', stderr: '' }) + }) + + it('resolves ok:false reason:signal when the process is killed by a signal', async () => { + const result = await runCommandWithOutput('sh', ['-c', 'kill -9 $$']) + + expect(result.ok).to.equal(false) + expect(result.reason).to.equal('signal') + }) + + it('does not double-settle when ENOENT fires both error and close', async () => { + const result = await runCommandWithOutput('__nostream_nonexistent_cmd__', []) + + expect(result.ok).to.equal(false) + expect(result.reason).to.equal('not-found') + }) +}) From ef1e7f9176f5b56c960e7ac3bd345ce69a5e4525 Mon Sep 17 00:00:00 2001 From: CKodidela Date: Sun, 3 May 2026 17:17:35 +0000 Subject: [PATCH 5/5] feat: add Marmot Protocol relay support (MIPs 00-03) --- .changeset/marmot-protocol-support.md | 14 ++ package.json | 1 + resources/default-settings.yaml | 5 + src/constants/base.ts | 10 + src/factories/event-strategy-factory.ts | 4 + src/handlers/event-message-handler.ts | 5 +- .../event-strategies/group-event-strategy.ts | 56 +++++ .../request-handlers/root-request-handler.ts | 1 + src/utils/event.ts | 10 + .../factories/event-strategy-factory.spec.ts | 21 ++ .../handlers/event-message-handler.spec.ts | 13 + .../group-event-strategy.spec.ts | 232 ++++++++++++++++++ test/unit/utils/event.spec.ts | 41 ++++ 13 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 .changeset/marmot-protocol-support.md create mode 100644 src/handlers/event-strategies/group-event-strategy.ts create mode 100644 test/unit/handlers/event-strategies/group-event-strategy.spec.ts diff --git a/.changeset/marmot-protocol-support.md b/.changeset/marmot-protocol-support.md new file mode 100644 index 00000000..298b6de9 --- /dev/null +++ b/.changeset/marmot-protocol-support.md @@ -0,0 +1,14 @@ +--- +"nostream": minor +--- + +Add relay support for the Marmot Protocol (E2EE group messaging over Nostr). + +Supported MIPs: 00 (KeyPackages), 01 (Group Construction), 02 (Welcome Events), 03 (Group Messages). + +- kind 443 (legacy KeyPackage): stored as a regular event +- kind 10051 (KeyPackage relay list): stored as a replaceable event +- kind 30443 (KeyPackage): stored as a parameterized-replaceable event with `d`-tag deduplication +- kind 444 (Welcome rumor): blocked from direct publishing; must travel inside a kind 1059 gift wrap +- kind 445 (Group Event): dedicated strategy validates the required `h` tag (nostr_group_id) before storing; `#h` tag subscriptions work via the existing generic tag index +- NIP-11 relay info now advertises `supported_mips: [0, 1, 2, 3]` diff --git a/package.json b/package.json index dd74f3ab..47072846 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ 65 ], "supportedNipExtensions": [], + "supportedMips": [0, 1, 2, 3], "main": "src/index.ts", "bin": { "nostream": "./dist/src/cli/index.js" diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 6cdfebb7..e49ad366 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -181,6 +181,11 @@ limits: - 39999 period: 60000 rate: 24 + - description: 60 events/min for Marmot group events (kind 445) + kinds: + - 445 + period: 60000 + rate: 60 - description: 60 events/min for ephemeral events kinds: - - 20000 diff --git a/src/constants/base.ts b/src/constants/base.ts index 141f1175..aae5e2e7 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -20,6 +20,10 @@ export enum EventKinds { CHANNEL_MUTE_USER = 44, CHANNEL_RESERVED_FIRST = 45, CHANNEL_RESERVED_LAST = 49, + // Marmot Protocol: E2EE Group Messaging (MIPs) + MARMOT_KEY_PACKAGE_LEGACY = 443, // MIP-00: legacy KeyPackage (regular event, superseded by 30443) + MARMOT_WELCOME_RUMOR = 444, // MIP-02: Welcome rumor (must not be published directly; wraps inside gift wrap) + MARMOT_GROUP_EVENT = 445, // MIP-03: Group Event (proposals, commits, application messages) // NIP-17: Gift Wrap GIFT_WRAP = 1059, // NIP-03: OpenTimestamps attestation @@ -34,12 +38,16 @@ export enum EventKinds { REPLACEABLE_FIRST = 10000, // NIP-65: Relay List Metadata RELAY_LIST = 10002, + // Marmot Protocol MIP-00: KeyPackage Relay List + MARMOT_KEY_PACKAGE_RELAY_LIST = 10051, REPLACEABLE_LAST = 19999, // Ephemeral events EPHEMERAL_FIRST = 20000, EPHEMERAL_LAST = 29999, // Parameterized replaceable events PARAMETERIZED_REPLACEABLE_FIRST = 30000, + // Marmot Protocol MIP-00: KeyPackage (addressable, replaces legacy 443) + MARMOT_KEY_PACKAGE = 30443, PARAMETERIZED_REPLACEABLE_LAST = 39999, USER_APPLICATION_FIRST = 40000, } @@ -58,6 +66,8 @@ export enum EventTags { Kind = 'k', // NIP-12: geohash tag for location-based queries Geohash = 'g', + // Marmot Protocol MIP-03: group ID for filtering kind:445 Group Events + Group = 'h', } export const ALL_RELAYS = 'ALL_RELAYS' diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 41d5a95e..a8648457 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -3,6 +3,7 @@ import { isDeleteEvent, isEphemeralEvent, isGiftWrapEvent, + isMarmotGroupEvent, isOpenTimestampsEvent, isParameterizedReplaceableEvent, isReplaceableEvent, @@ -15,6 +16,7 @@ import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-e import { Event } from '../@types/event' import { Factory } from '../@types/base' import { GiftWrapEventStrategy } from '../handlers/event-strategies/gift-wrap-event-strategy' +import { GroupEventStrategy } from '../handlers/event-strategies/group-event-strategy' import { IEventStrategy } from '../@types/message-handlers' import { IWebSocketAdapter } from '../@types/adapters' import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy' @@ -32,6 +34,8 @@ export const eventStrategyFactory = return new VanishEventStrategy(adapter, eventRepository, userRepository) } else if (isGiftWrapEvent(event)) { return new GiftWrapEventStrategy(adapter, eventRepository) + } else if (isMarmotGroupEvent(event)) { + return new GroupEventStrategy(adapter, eventRepository) } else if (isOpenTimestampsEvent(event)) { return new TimestampEventStrategy(adapter, eventRepository) } else if (isRelayListEvent(event) || isReplaceableEvent(event)) { diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 235b1c13..7139cbad 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -23,6 +23,7 @@ import { isFileMessageEvent, isRequestToVanishEvent, isSealEvent, + isWelcomeRumorEvent, } from '../utils/event' import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { IEventStrategy, IMessageHandler } from '../@types/message-handlers' @@ -238,7 +239,9 @@ export class EventMessageHandler implements IMessageHandler { // NIP-17: kind 13 (Seal) and kind 14 (Direct Message) are inner events that // must never be published directly to a relay. They are encrypted inside a // kind 1059 Gift Wrap (NIP-59) before being sent here. - if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event)) { + // Marmot MIP-02: kind 444 (Welcome rumor) is similarly an inner event that + // must only be delivered inside a kind 1059 gift wrap. + if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event) || isWelcomeRumorEvent(event)) { return `blocked: kind ${event.kind} events must not be published directly; wrap them in a kind 1059 gift wrap` } } diff --git a/src/handlers/event-strategies/group-event-strategy.ts b/src/handlers/event-strategies/group-event-strategy.ts new file mode 100644 index 00000000..29e65b97 --- /dev/null +++ b/src/handlers/event-strategies/group-event-strategy.ts @@ -0,0 +1,56 @@ +import { createCommandResult } from '../../utils/messages' +import { createLogger } from '../../factories/logger-factory' +import { Event } from '../../@types/event' +import { EventTags } from '../../constants/base' +import { IEventRepository } from '../../@types/repositories' +import { IEventStrategy } from '../../@types/message-handlers' +import { IWebSocketAdapter } from '../../@types/adapters' +import { WebSocketAdapterEvent } from '../../constants/adapter' + +const logger = createLogger('group-event-strategy') + +export class GroupEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly eventRepository: IEventRepository, + ) {} + + public async execute(event: Event): Promise { + logger('received group event: %o', event) + + const reason = this.validateGroupEvent(event) + if (reason) { + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, `invalid: ${reason}`)) + return + } + + const count = await this.eventRepository.create(event) + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, count ? '' : 'duplicate:')) + + if (count) { + this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event) + } + } + + // MIP-03: kind:445 Group Events MUST carry exactly one `h` tag whose value is the + // 64-character lowercase hex-encoded nostr_group_id from the Marmot Group Data Extension. + // The relay enforces this so that #h tag subscriptions always work correctly. + private validateGroupEvent(event: Event): string | undefined { + const groupTags = event.tags.filter((tag) => tag.length >= 2 && tag[0] === EventTags.Group) + + if (groupTags.length === 0) { + return 'group event (kind 445) must have an h tag identifying the group' + } + + if (groupTags.length > 1) { + return 'group event (kind 445) must have exactly one h tag' + } + + const groupId = groupTags[0][1] + if (!/^[0-9a-f]{64}$/.test(groupId)) { + return 'group event (kind 445) h tag must contain a valid 64-character lowercase hex group id' + } + + return undefined + } +} diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 604ead63..d901b934 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -80,6 +80,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N contact, supported_nips: packageJson.supportedNips, supported_nip_extensions: packageJson.supportedNipExtensions, + supported_mips: packageJson.supportedMips, software: packageJson.repository.url, version: packageJson.version, ...(terms_of_service !== undefined ? { terms_of_service } : {}), diff --git a/src/utils/event.ts b/src/utils/event.ts index 914772b8..68f34b2e 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -279,3 +279,13 @@ export const isFileMessageEvent = (event: Event): boolean => { export const isOpenTimestampsEvent = (event: Event): boolean => { return event.kind === EventKinds.OPEN_TIMESTAMPS } + +// Marmot Protocol helpers + +export const isWelcomeRumorEvent = (event: Event): boolean => { + return event.kind === EventKinds.MARMOT_WELCOME_RUMOR +} + +export const isMarmotGroupEvent = (event: Event): boolean => { + return event.kind === EventKinds.MARMOT_GROUP_EVENT +} diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index c051ca7d..8e72e9ea 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -9,6 +9,7 @@ import { EventKinds } from '../../../src/constants/base' import { eventStrategyFactory } from '../../../src/factories/event-strategy-factory' import { Factory } from '../../../src/@types/base' import { GiftWrapEventStrategy } from '../../../src/handlers/event-strategies/gift-wrap-event-strategy' +import { GroupEventStrategy } from '../../../src/handlers/event-strategies/group-event-strategy' import { IEventStrategy } from '../../../src/@types/message-handlers' import { IWebSocketAdapter } from '../../../src/@types/adapters' import { ParameterizedReplaceableEventStrategy } from '../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy' @@ -72,6 +73,26 @@ describe('eventStrategyFactory', () => { expect(factory([event, adapter])).to.be.an.instanceOf(GiftWrapEventStrategy) }) + it('returns GroupEventStrategy given a Marmot group event (kind 445)', () => { + event.kind = EventKinds.MARMOT_GROUP_EVENT + expect(factory([event, adapter])).to.be.an.instanceOf(GroupEventStrategy) + }) + + it('returns ParameterizedReplaceableEventStrategy given a Marmot KeyPackage event (kind 30443)', () => { + event.kind = EventKinds.MARMOT_KEY_PACKAGE + expect(factory([event, adapter])).to.be.an.instanceOf(ParameterizedReplaceableEventStrategy) + }) + + it('returns ReplaceableEventStrategy given a Marmot KeyPackage relay list (kind 10051)', () => { + event.kind = EventKinds.MARMOT_KEY_PACKAGE_RELAY_LIST + expect(factory([event, adapter])).to.be.an.instanceOf(ReplaceableEventStrategy) + }) + + it('returns DefaultEventStrategy given a legacy Marmot KeyPackage (kind 443)', () => { + event.kind = EventKinds.MARMOT_KEY_PACKAGE_LEGACY + expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) + }) + it('returns TimestampEventStrategy given an opentimestamps (NIP-03) event', () => { event.kind = EventKinds.OPEN_TIMESTAMPS expect(factory([event, adapter])).to.be.an.instanceOf(TimestampEventStrategy) diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 531eee57..57ee66a9 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -721,6 +721,19 @@ describe('EventMessageHandler', () => { const reason = await (handler as any).isEventValid(giftWrap) expect(reason).to.be.undefined }) + + it('blocks kind 444 (Marmot Welcome rumor) with a clear rejection message', async () => { + const welcomeRumor = await makeValidEvent(EventKinds.MARMOT_WELCOME_RUMOR) + const reason = await (handler as any).isEventValid(welcomeRumor) + expect(reason).to.include('blocked') + expect(reason).to.include('444') + }) + + it('does not block a kind 445 (Marmot Group Event)', async () => { + const groupEvent = await makeValidEvent(EventKinds.MARMOT_GROUP_EVENT) + const reason = await (handler as any).isEventValid(groupEvent) + expect(reason).to.be.undefined + }) }) }) diff --git a/test/unit/handlers/event-strategies/group-event-strategy.spec.ts b/test/unit/handlers/event-strategies/group-event-strategy.spec.ts new file mode 100644 index 00000000..b89647ff --- /dev/null +++ b/test/unit/handlers/event-strategies/group-event-strategy.spec.ts @@ -0,0 +1,232 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +import { DatabaseClient } from '../../../../src/@types/base' +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { EventRepository } from '../../../../src/repositories/event-repository' +import { GroupEventStrategy } from '../../../../src/handlers/event-strategies/group-event-strategy' +import { IEventRepository } from '../../../../src/@types/repositories' +import { IEventStrategy } from '../../../../src/@types/message-handlers' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { MessageType } from '../../../../src/@types/messages' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +const { expect } = chai + +const VALID_GROUP_ID = 'a'.repeat(64) + +describe('GroupEventStrategy', () => { + let event: Event + let webSocket: IWebSocketAdapter + let eventRepository: IEventRepository + let webSocketEmitStub: Sinon.SinonStub + let eventRepositoryCreateStub: Sinon.SinonStub + let strategy: IEventStrategy> + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + eventRepositoryCreateStub = sandbox.stub(EventRepository.prototype, 'create') + + webSocketEmitStub = sandbox.stub() + webSocket = { emit: webSocketEmitStub } as any + + const masterClient: DatabaseClient = {} as any + const readReplicaClient: DatabaseClient = {} as any + eventRepository = new EventRepository(masterClient, readReplicaClient) + + event = { + id: 'group-event-id', + pubkey: 'b'.repeat(64), // ephemeral per MIP-03 + created_at: 1700000000, + kind: EventKinds.MARMOT_GROUP_EVENT, + tags: [['h', VALID_GROUP_ID]], + content: 'base64encodedencryptedcontent', + sig: 'c'.repeat(128), + } as any + + strategy = new GroupEventStrategy(webSocket, eventRepository) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('execute', () => { + describe('valid group event', () => { + it('creates the event in the repository', async () => { + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + }) + + it('sends OK and broadcasts when the event is new', async () => { + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledTwice + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + true, + '', + ]) + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Broadcast, event) + }) + + it('sends OK with duplicate marker and does not broadcast when event already exists', async () => { + eventRepositoryCreateStub.resolves(0) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + true, + 'duplicate:', + ]) + }) + + it('accepts an optional expiration tag alongside the h tag', async () => { + event.tags = [['h', VALID_GROUP_ID], ['expiration', '9999999999']] + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnce + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + true, + '', + ]) + }) + }) + + describe('invalid group event — h tag missing', () => { + it('rejects when the h tag is absent', async () => { + event.tags = [] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*h tag/), + ]) + }) + + it('rejects when the only tag is not an h tag', async () => { + event.tags = [['p', 'a'.repeat(64)]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*h tag/), + ]) + }) + }) + + describe('invalid group event — multiple h tags', () => { + it('rejects when there are two h tags', async () => { + event.tags = [ + ['h', VALID_GROUP_ID], + ['h', 'b'.repeat(64)], + ] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*exactly one h tag/), + ]) + }) + }) + + describe('invalid group event — h tag value format', () => { + it('rejects when the group id is too short', async () => { + event.tags = [['h', 'a'.repeat(63)]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*64-character/), + ]) + }) + + it('rejects when the group id is too long', async () => { + event.tags = [['h', 'a'.repeat(65)]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*64-character/), + ]) + }) + + it('rejects when the group id contains uppercase hex chars', async () => { + event.tags = [['h', 'A'.repeat(64)]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*64-character/), + ]) + }) + + it('rejects when the group id contains non-hex characters', async () => { + event.tags = [['h', 'g'.repeat(64)]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*64-character/), + ]) + }) + + it('accepts all valid lowercase hex characters (0-9 and a-f)', async () => { + event.tags = [['h', '0123456789abcdef'.repeat(4)]] + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnce + }) + }) + }) +}) diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index b1514b25..721d767b 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -11,10 +11,12 @@ import { isExpiredEvent, isFileMessageEvent, isGiftWrapEvent, + isMarmotGroupEvent, isParameterizedReplaceableEvent, isReplaceableEvent, isRequestToVanishEvent, isSealEvent, + isWelcomeRumorEvent, serializeEvent, } from '../../../src/utils/event' import { expect } from 'chai' @@ -415,6 +417,45 @@ describe('NIP-17', () => { }) }) +describe('Marmot Protocol', () => { + describe('isWelcomeRumorEvent', () => { + it('returns true for kind 444', () => { + expect(isWelcomeRumorEvent({ kind: EventKinds.MARMOT_WELCOME_RUMOR } as any)).to.be.true + }) + + it('returns false for kind 445 (group event)', () => { + expect(isWelcomeRumorEvent({ kind: EventKinds.MARMOT_GROUP_EVENT } as any)).to.be.false + }) + + it('returns false for kind 1059 (gift wrap)', () => { + expect(isWelcomeRumorEvent({ kind: EventKinds.GIFT_WRAP } as any)).to.be.false + }) + + it('returns false for any unrelated kind', () => { + expect(isWelcomeRumorEvent({ kind: EventKinds.TEXT_NOTE } as any)).to.be.false + }) + }) + + describe('isMarmotGroupEvent', () => { + it('returns true for kind 445', () => { + expect(isMarmotGroupEvent({ kind: EventKinds.MARMOT_GROUP_EVENT } as any)).to.be.true + }) + + it('returns false for kind 444 (welcome rumor)', () => { + expect(isMarmotGroupEvent({ kind: EventKinds.MARMOT_WELCOME_RUMOR } as any)).to.be.false + }) + + it('returns false for kind 443 (legacy key package)', () => { + expect(isMarmotGroupEvent({ kind: EventKinds.MARMOT_KEY_PACKAGE_LEGACY } as any)).to.be.false + }) + + it('returns false for any unrelated kind', () => { + expect(isMarmotGroupEvent({ kind: EventKinds.TEXT_NOTE } as any)).to.be.false + expect(isMarmotGroupEvent({ kind: EventKinds.GIFT_WRAP } as any)).to.be.false + }) + }) +}) + // describe('NIP-27', () => { // describe('isEventMatchingFilter', () => { // describe('#m filter', () => {