Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/marmot-protocol-support.md
Original file line number Diff line number Diff line change
@@ -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]`
5 changes: 5 additions & 0 deletions .changeset/normalize-run-command-with-output.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
65
],
"supportedNipExtensions": [],
"supportedMips": [0, 1, 2, 3],
"main": "src/index.ts",
"bin": {
"nostream": "./dist/src/cli/index.js"
Expand Down
5 changes: 5 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 3 additions & 8 deletions src/cli/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,8 @@ const getEventCount = async (): Promise<number | null> => {
}

const getRelayUptimeSeconds = async (): Promise<number | null> => {
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
}

Expand All @@ -74,7 +69,7 @@ const getRelayUptimeSeconds = async (): Promise<number | null> => {
const startedAtResult = await runCommandWithOutput('docker', ['inspect', '--format', '{{.State.StartedAt}}', containerId], {
timeoutMs: 1000,
})
if (startedAtResult.code !== 0) {
if (!startedAtResult.ok || startedAtResult.code !== 0) {
return null
}

Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export const runUpdate = async (passthrough: string[]): Promise<number> => {
}

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
Expand Down
50 changes: 39 additions & 11 deletions src/cli/utils/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> => {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
Expand Down Expand Up @@ -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<CommandResult> => {
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,
Expand All @@ -53,6 +66,7 @@ export const runCommandWithOutput = (
const timer =
typeof options.timeoutMs === 'number'
? setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
}, options.timeoutMs)
: undefined
Expand All @@ -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 })
})
})
}
10 changes: 10 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
Expand All @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isDeleteEvent,
isEphemeralEvent,
isGiftWrapEvent,
isMarmotGroupEvent,
isOpenTimestampsEvent,
isParameterizedReplaceableEvent,
isReplaceableEvent,
Expand All @@ -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'
Expand All @@ -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)) {
Expand Down
5 changes: 4 additions & 1 deletion src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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`
}
}
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/event-strategies/group-event-strategy.ts
Original file line number Diff line number Diff line change
@@ -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<Event, Promise<void>> {
public constructor(
private readonly webSocket: IWebSocketAdapter,
private readonly eventRepository: IEventRepository,
) {}

public async execute(event: Event): Promise<void> {
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
}
}
1 change: 1 addition & 0 deletions src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
10 changes: 10 additions & 0 deletions src/utils/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
25 changes: 19 additions & 6 deletions test/unit/cli/info.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 })

Expand All @@ -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 })

Expand All @@ -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 })

Expand Down
Loading
Loading