From 7b6cbf58697c4b12fdbf4f3ba4c11d794a8ebd30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 20:14:44 +0000 Subject: [PATCH] feat: add Nostr channel plugin and onboarding install defaults Co-authored-by: joelklabo --- CHANGELOG.md | 1 + docs/channels/index.md | 1 + docs/channels/nostr.md | 235 ++++++ docs/plugin.md | 1 + extensions/nostr/CHANGELOG.md | 26 + extensions/nostr/README.md | 136 ++++ extensions/nostr/clawdbot.plugin.json | 11 + extensions/nostr/index.ts | 69 ++ extensions/nostr/node_modules/.bin/clawdbot | 1 + extensions/nostr/node_modules/clawdbot | 1 + extensions/nostr/package.json | 29 + extensions/nostr/src/channel.test.ts | 141 ++++ extensions/nostr/src/channel.ts | 335 ++++++++ extensions/nostr/src/config-schema.ts | 87 ++ extensions/nostr/src/metrics.ts | 464 +++++++++++ extensions/nostr/src/nostr-bus.fuzz.test.ts | 544 +++++++++++++ .../nostr/src/nostr-bus.integration.test.ts | 452 +++++++++++ extensions/nostr/src/nostr-bus.test.ts | 199 +++++ extensions/nostr/src/nostr-bus.ts | 741 ++++++++++++++++++ .../nostr/src/nostr-profile-http.test.ts | 378 +++++++++ extensions/nostr/src/nostr-profile-http.ts | 500 ++++++++++++ .../nostr/src/nostr-profile-import.test.ts | 120 +++ extensions/nostr/src/nostr-profile-import.ts | 259 ++++++ .../nostr/src/nostr-profile.fuzz.test.ts | 479 +++++++++++ extensions/nostr/src/nostr-profile.test.ts | 410 ++++++++++ extensions/nostr/src/nostr-profile.ts | 242 ++++++ .../nostr/src/nostr-state-store.test.ts | 128 +++ extensions/nostr/src/nostr-state-store.ts | 226 ++++++ extensions/nostr/src/runtime.ts | 14 + extensions/nostr/src/seen-tracker.ts | 271 +++++++ extensions/nostr/src/types.test.ts | 161 ++++ extensions/nostr/src/types.ts | 99 +++ extensions/nostr/test/setup.ts | 5 + pnpm-lock.yaml | 90 +++ .../onboarding/plugin-install.test.ts | 46 ++ src/commands/onboarding/plugin-install.ts | 31 +- src/config/zod-schema.providers.ts | 2 +- ui/src/ui/app-channels.ts | 199 +++++ ui/src/ui/app-render.ts | 10 + ui/src/ui/app-view-state.ts | 10 + ui/src/ui/app.ts | 34 + ui/src/ui/types.ts | 35 +- .../ui/views/channels.nostr-profile-form.ts | 312 ++++++++ ui/src/ui/views/channels.nostr.ts | 217 +++++ ui/src/ui/views/channels.ts | 34 +- ui/src/ui/views/channels.types.ts | 12 + 46 files changed, 7789 insertions(+), 9 deletions(-) create mode 100644 docs/channels/nostr.md create mode 100644 extensions/nostr/CHANGELOG.md create mode 100644 extensions/nostr/README.md create mode 100644 extensions/nostr/clawdbot.plugin.json create mode 100644 extensions/nostr/index.ts create mode 120000 extensions/nostr/node_modules/.bin/clawdbot create mode 120000 extensions/nostr/node_modules/clawdbot create mode 100644 extensions/nostr/package.json create mode 100644 extensions/nostr/src/channel.test.ts create mode 100644 extensions/nostr/src/channel.ts create mode 100644 extensions/nostr/src/config-schema.ts create mode 100644 extensions/nostr/src/metrics.ts create mode 100644 extensions/nostr/src/nostr-bus.fuzz.test.ts create mode 100644 extensions/nostr/src/nostr-bus.integration.test.ts create mode 100644 extensions/nostr/src/nostr-bus.test.ts create mode 100644 extensions/nostr/src/nostr-bus.ts create mode 100644 extensions/nostr/src/nostr-profile-http.test.ts create mode 100644 extensions/nostr/src/nostr-profile-http.ts create mode 100644 extensions/nostr/src/nostr-profile-import.test.ts create mode 100644 extensions/nostr/src/nostr-profile-import.ts create mode 100644 extensions/nostr/src/nostr-profile.fuzz.test.ts create mode 100644 extensions/nostr/src/nostr-profile.test.ts create mode 100644 extensions/nostr/src/nostr-profile.ts create mode 100644 extensions/nostr/src/nostr-state-store.test.ts create mode 100644 extensions/nostr/src/nostr-state-store.ts create mode 100644 extensions/nostr/src/runtime.ts create mode 100644 extensions/nostr/src/seen-tracker.ts create mode 100644 extensions/nostr/src/types.test.ts create mode 100644 extensions/nostr/src/types.ts create mode 100644 extensions/nostr/test/setup.ts create mode 100644 ui/src/ui/views/channels.nostr-profile-form.ts create mode 100644 ui/src/ui/views/channels.nostr.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 320efbf94..6ebd02cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot - Repo: remove the Peekaboo git submodule now that the SPM release is used. - Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`. - Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`. +- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo. - Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow. - Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel. - Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow. diff --git a/docs/channels/index.md b/docs/channels/index.md index f2677cdfa..af1d5bfee 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -21,6 +21,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). +- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md new file mode 100644 index 000000000..d1dc284bb --- /dev/null +++ b/docs/channels/nostr.md @@ -0,0 +1,235 @@ +--- +summary: "Nostr DM channel via NIP-04 encrypted messages" +read_when: + - You want Clawdbot to receive DMs via Nostr + - You're setting up decentralized messaging +--- +# Nostr + +**Status:** Optional plugin (disabled by default). + +Nostr is a decentralized protocol for social networking. This channel enables Clawdbot to receive and respond to encrypted direct messages (DMs) via NIP-04. + +## Install (on demand) + +### Onboarding (recommended) + +- The onboarding wizard (`clawdbot onboard`) and `clawdbot channels add` list optional channel plugins. +- Selecting Nostr prompts you to install the plugin on demand. + +Install defaults: + +- **Dev channel + git checkout available:** uses the local plugin path. +- **Stable/Beta:** downloads from npm. + +You can always override the choice in the prompt. + +### Manual install + +```bash +clawdbot plugins install @clawdbot/nostr +``` + +Use a local checkout (dev workflows): + +```bash +clawdbot plugins install --link /extensions/nostr +``` + +Restart the Gateway after installing or enabling plugins. + +## Quick setup + +1) Generate a Nostr keypair (if needed): + +```bash +# Using nak +nak key generate +``` + +2) Add to config: + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}" + } + } +} +``` + +3) Export the key: + +```bash +export NOSTR_PRIVATE_KEY="nsec1..." +``` + +4) Restart the Gateway. + +## Configuration reference + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `privateKey` | string | required | Private key in `nsec` or hex format | +| `relays` | string[] | `['wss://relay.damus.io', 'wss://nos.lol']` | Relay URLs (WebSocket) | +| `dmPolicy` | string | `pairing` | DM access policy | +| `allowFrom` | string[] | `[]` | Allowed sender pubkeys | +| `enabled` | boolean | `true` | Enable/disable channel | +| `name` | string | - | Display name | +| `profile` | object | - | NIP-01 profile metadata | + +## Profile metadata + +Profile data is published as a NIP-01 `kind:0` event. You can manage it from the Control UI (Channels -> Nostr -> Profile) or set it directly in config. + +Example: + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "profile": { + "name": "clawdbot", + "displayName": "Clawdbot", + "about": "Personal assistant DM bot", + "picture": "https://example.com/avatar.png", + "banner": "https://example.com/banner.png", + "website": "https://example.com", + "nip05": "clawdbot@example.com", + "lud16": "clawdbot@example.com" + } + } + } +} +``` + +Notes: + +- Profile URLs must use `https://`. +- Importing from relays merges fields and preserves local overrides. + +## Access control + +### DM policies + +- **pairing** (default): unknown senders get a pairing code. +- **allowlist**: only pubkeys in `allowFrom` can DM. +- **open**: public inbound DMs (requires `allowFrom: ["*"]`). +- **disabled**: ignore inbound DMs. + +### Allowlist example + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "dmPolicy": "allowlist", + "allowFrom": ["npub1abc...", "npub1xyz..."] + } + } +} +``` + +## Key formats + +Accepted formats: + +- **Private key:** `nsec...` or 64-char hex +- **Pubkeys (`allowFrom`):** `npub...` or hex + +## Relays + +Defaults: `relay.damus.io` and `nos.lol`. + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "relays": [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://nostr.wine" + ] + } + } +} +``` + +Tips: + +- Use 2-3 relays for redundancy. +- Avoid too many relays (latency, duplication). +- Paid relays can improve reliability. +- Local relays are fine for testing (`ws://localhost:7777`). + +## Protocol support + +| NIP | Status | Description | +| --- | --- | --- | +| NIP-01 | Supported | Basic event format + profile metadata | +| NIP-04 | Supported | Encrypted DMs (`kind:4`) | +| NIP-17 | Planned | Gift-wrapped DMs | +| NIP-44 | Planned | Versioned encryption | + +## Testing + +### Local relay + +```bash +# Start strfry +docker run -p 7777:7777 ghcr.io/hoytech/strfry +``` + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "relays": ["ws://localhost:7777"] + } + } +} +``` + +### Manual test + +1) Note the bot pubkey (npub) from logs. +2) Open a Nostr client (Damus, Amethyst, etc.). +3) DM the bot pubkey. +4) Verify the response. + +## Troubleshooting + +### Not receiving messages + +- Verify the private key is valid. +- Ensure relay URLs are reachable and use `wss://` (or `ws://` for local). +- Confirm `enabled` is not `false`. +- Check Gateway logs for relay connection errors. + +### Not sending responses + +- Check relay accepts writes. +- Verify outbound connectivity. +- Watch for relay rate limits. + +### Duplicate responses + +- Expected when using multiple relays. +- Messages are deduplicated by event ID; only the first delivery triggers a response. + +## Security + +- Never commit private keys. +- Use environment variables for keys. +- Consider `allowlist` for production bots. + +## Limitations (MVP) + +- Direct messages only (no group chats). +- No media attachments. +- NIP-04 only (NIP-17 gift-wrap planned). diff --git a/docs/plugin.md b/docs/plugin.md index 34535fa10..4ecfd5911 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -41,6 +41,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin. - [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call` - [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser` - [Matrix](/channels/matrix) — `@clawdbot/matrix` +- [Nostr](/channels/nostr) — `@clawdbot/nostr` - [Zalo](/channels/zalo) — `@clawdbot/zalo` - [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams` - Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default) diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md new file mode 100644 index 000000000..0290022d0 --- /dev/null +++ b/extensions/nostr/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## 2026.1.19-1 + +Initial release. + +### Features + +- NIP-04 encrypted DM support (kind:4 events) +- Key validation (hex and nsec formats) +- Multi-relay support with sequential fallback +- Event signature verification +- TTL-based deduplication (24h) +- Access control via dmPolicy (pairing, allowlist, open, disabled) +- Pubkey normalization (hex/npub) + +### Protocol Support + +- NIP-01: Basic event structure +- NIP-04: Encrypted direct messages + +### Planned for v2 + +- NIP-17: Gift-wrapped DMs +- NIP-44: Versioned encryption +- Media attachments diff --git a/extensions/nostr/README.md b/extensions/nostr/README.md new file mode 100644 index 000000000..f089f2223 --- /dev/null +++ b/extensions/nostr/README.md @@ -0,0 +1,136 @@ +# @clawdbot/nostr + +Nostr DM channel plugin for Clawdbot using NIP-04 encrypted direct messages. + +## Overview + +This extension adds Nostr as a messaging channel to Clawdbot. It enables your bot to: + +- Receive encrypted DMs from Nostr users +- Send encrypted responses back +- Work with any NIP-04 compatible Nostr client (Damus, Amethyst, etc.) + +## Installation + +```bash +clawdbot plugins install @clawdbot/nostr +``` + +## Quick Setup + +1. Generate a Nostr keypair (if you don't have one): + ```bash + # Using nak CLI + nak key generate + + # Or use any Nostr key generator + ``` + +2. Add to your config: + ```json + { + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "relays": ["wss://relay.damus.io", "wss://nos.lol"] + } + } + } + ``` + +3. Set the environment variable: + ```bash + export NOSTR_PRIVATE_KEY="nsec1..." # or hex format + ``` + +4. Restart the gateway + +## Configuration + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `privateKey` | string | required | Bot's private key (nsec or hex format) | +| `relays` | string[] | `["wss://relay.damus.io", "wss://nos.lol"]` | WebSocket relay URLs | +| `dmPolicy` | string | `"pairing"` | Access control: `pairing`, `allowlist`, `open`, `disabled` | +| `allowFrom` | string[] | `[]` | Allowed sender pubkeys (npub or hex) | +| `enabled` | boolean | `true` | Enable/disable the channel | +| `name` | string | - | Display name for the account | + +## Access Control + +### DM Policies + +- **pairing** (default): Unknown senders receive a pairing code to request access +- **allowlist**: Only pubkeys in `allowFrom` can message the bot +- **open**: Anyone can message the bot (use with caution) +- **disabled**: DMs are disabled + +### Example: Allowlist Mode + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "dmPolicy": "allowlist", + "allowFrom": [ + "npub1abc...", + "0123456789abcdef..." + ] + } + } +} +``` + +## Testing + +### Local Relay (Recommended) + +```bash +# Using strfry +docker run -p 7777:7777 ghcr.io/hoytech/strfry + +# Configure clawdbot to use local relay +"relays": ["ws://localhost:7777"] +``` + +### Manual Test + +1. Start the gateway with Nostr configured +2. Open Damus, Amethyst, or another Nostr client +3. Send a DM to your bot's npub +4. Verify the bot responds + +## Protocol Support + +| NIP | Status | Notes | +|-----|--------|-------| +| NIP-01 | Supported | Basic event structure | +| NIP-04 | Supported | Encrypted DMs (kind:4) | +| NIP-17 | Planned | Gift-wrapped DMs (v2) | + +## Security Notes + +- Private keys are never logged +- Event signatures are verified before processing +- Use environment variables for keys, never commit to config files +- Consider using `allowlist` mode in production + +## Troubleshooting + +### Bot not receiving messages + +1. Verify private key is correctly configured +2. Check relay connectivity +3. Ensure `enabled` is not set to `false` +4. Check the bot's public key matches what you're sending to + +### Messages not being delivered + +1. Check relay URLs are correct (must use `wss://`) +2. Verify relays are online and accepting connections +3. Check for rate limiting (reduce message frequency) + +## License + +MIT diff --git a/extensions/nostr/clawdbot.plugin.json b/extensions/nostr/clawdbot.plugin.json new file mode 100644 index 000000000..2545ad928 --- /dev/null +++ b/extensions/nostr/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "nostr", + "channels": [ + "nostr" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts new file mode 100644 index 000000000..059c0608a --- /dev/null +++ b/extensions/nostr/index.ts @@ -0,0 +1,69 @@ +import type { ClawdbotPluginApi, ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { nostrPlugin } from "./src/channel.js"; +import { setNostrRuntime, getNostrRuntime } from "./src/runtime.js"; +import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js"; +import { resolveNostrAccount } from "./src/types.js"; +import type { NostrProfile } from "./src/config-schema.js"; + +const plugin = { + id: "nostr", + name: "Nostr", + description: "Nostr DM channel plugin via NIP-04", + configSchema: emptyPluginConfigSchema(), + register(api: ClawdbotPluginApi) { + setNostrRuntime(api.runtime); + api.registerChannel({ plugin: nostrPlugin }); + + // Register HTTP handler for profile management + const httpHandler = createNostrProfileHttpHandler({ + getConfigProfile: (accountId: string) => { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig() as ClawdbotConfig; + const account = resolveNostrAccount({ cfg, accountId }); + return account.profile; + }, + updateConfigProfile: async (accountId: string, profile: NostrProfile) => { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig() as ClawdbotConfig; + + // Build the config patch for channels.nostr.profile + const channels = (cfg.channels ?? {}) as Record; + const nostrConfig = (channels.nostr ?? {}) as Record; + + const updatedNostrConfig = { + ...nostrConfig, + profile, + }; + + const updatedChannels = { + ...channels, + nostr: updatedNostrConfig, + }; + + await runtime.config.writeConfigFile({ + ...cfg, + channels: updatedChannels, + }); + }, + getAccountInfo: (accountId: string) => { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig() as ClawdbotConfig; + const account = resolveNostrAccount({ cfg, accountId }); + if (!account.configured || !account.publicKey) { + return null; + } + return { + pubkey: account.publicKey, + relays: account.relays, + }; + }, + log: api.logger, + }); + + api.registerHttpHandler(httpHandler); + }, +}; + +export default plugin; diff --git a/extensions/nostr/node_modules/.bin/clawdbot b/extensions/nostr/node_modules/.bin/clawdbot new file mode 120000 index 000000000..93baf7762 --- /dev/null +++ b/extensions/nostr/node_modules/.bin/clawdbot @@ -0,0 +1 @@ +../clawdbot/dist/entry.js \ No newline at end of file diff --git a/extensions/nostr/node_modules/clawdbot b/extensions/nostr/node_modules/clawdbot new file mode 120000 index 000000000..a8a4f8c21 --- /dev/null +++ b/extensions/nostr/node_modules/clawdbot @@ -0,0 +1 @@ +../../.. \ No newline at end of file diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json new file mode 100644 index 000000000..d5f2c6b24 --- /dev/null +++ b/extensions/nostr/package.json @@ -0,0 +1,29 @@ +{ + "name": "@clawdbot/nostr", + "version": "2026.1.19-1", + "type": "module", + "description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs", + "clawdbot": { + "extensions": ["./index.ts"], + "channel": { + "id": "nostr", + "label": "Nostr", + "selectionLabel": "Nostr (NIP-04 DMs)", + "docsPath": "/channels/nostr", + "docsLabel": "nostr", + "blurb": "Decentralized protocol; encrypted DMs via NIP-04.", + "order": 55, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@clawdbot/nostr", + "localPath": "extensions/nostr", + "defaultChoice": "npm" + } + }, + "dependencies": { + "clawdbot": "workspace:*", + "nostr-tools": "^2.10.4", + "zod": "^4.3.5" + } +} diff --git a/extensions/nostr/src/channel.test.ts b/extensions/nostr/src/channel.test.ts new file mode 100644 index 000000000..4008d6304 --- /dev/null +++ b/extensions/nostr/src/channel.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { nostrPlugin } from "./channel.js"; + +describe("nostrPlugin", () => { + describe("meta", () => { + it("has correct id", () => { + expect(nostrPlugin.id).toBe("nostr"); + }); + + it("has required meta fields", () => { + expect(nostrPlugin.meta.label).toBe("Nostr"); + expect(nostrPlugin.meta.docsPath).toBe("/channels/nostr"); + expect(nostrPlugin.meta.blurb).toContain("NIP-04"); + }); + }); + + describe("capabilities", () => { + it("supports direct messages", () => { + expect(nostrPlugin.capabilities.chatTypes).toContain("direct"); + }); + + it("does not support groups (MVP)", () => { + expect(nostrPlugin.capabilities.chatTypes).not.toContain("group"); + }); + + it("does not support media (MVP)", () => { + expect(nostrPlugin.capabilities.media).toBe(false); + }); + }); + + describe("config adapter", () => { + it("has required config functions", () => { + expect(nostrPlugin.config.listAccountIds).toBeTypeOf("function"); + expect(nostrPlugin.config.resolveAccount).toBeTypeOf("function"); + expect(nostrPlugin.config.isConfigured).toBeTypeOf("function"); + }); + + it("listAccountIds returns empty array for unconfigured", () => { + const cfg = { channels: {} }; + const ids = nostrPlugin.config.listAccountIds(cfg); + expect(ids).toEqual([]); + }); + + it("listAccountIds returns default for configured", () => { + const cfg = { + channels: { + nostr: { + privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + }, + }; + const ids = nostrPlugin.config.listAccountIds(cfg); + expect(ids).toContain("default"); + }); + }); + + describe("messaging", () => { + it("has target resolver", () => { + expect(nostrPlugin.messaging?.targetResolver?.looksLikeId).toBeTypeOf("function"); + }); + + it("recognizes npub as valid target", () => { + const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId; + if (!looksLikeId) return; + + expect(looksLikeId("npub1xyz123")).toBe(true); + }); + + it("recognizes hex pubkey as valid target", () => { + const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId; + if (!looksLikeId) return; + + const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(looksLikeId(hexPubkey)).toBe(true); + }); + + it("rejects invalid input", () => { + const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId; + if (!looksLikeId) return; + + expect(looksLikeId("not-a-pubkey")).toBe(false); + expect(looksLikeId("")).toBe(false); + }); + + it("normalizeTarget strips nostr: prefix", () => { + const normalize = nostrPlugin.messaging?.normalizeTarget; + if (!normalize) return; + + const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey); + }); + }); + + describe("outbound", () => { + it("has correct delivery mode", () => { + expect(nostrPlugin.outbound?.deliveryMode).toBe("direct"); + }); + + it("has reasonable text chunk limit", () => { + expect(nostrPlugin.outbound?.textChunkLimit).toBe(4000); + }); + }); + + describe("pairing", () => { + it("has id label for pairing", () => { + expect(nostrPlugin.pairing?.idLabel).toBe("nostrPubkey"); + }); + + it("normalizes nostr: prefix in allow entries", () => { + const normalize = nostrPlugin.pairing?.normalizeAllowEntry; + if (!normalize) return; + + const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey); + }); + }); + + describe("security", () => { + it("has resolveDmPolicy function", () => { + expect(nostrPlugin.security?.resolveDmPolicy).toBeTypeOf("function"); + }); + }); + + describe("gateway", () => { + it("has startAccount function", () => { + expect(nostrPlugin.gateway?.startAccount).toBeTypeOf("function"); + }); + }); + + describe("status", () => { + it("has default runtime", () => { + expect(nostrPlugin.status?.defaultRuntime).toBeDefined(); + expect(nostrPlugin.status?.defaultRuntime?.accountId).toBe("default"); + expect(nostrPlugin.status?.defaultRuntime?.running).toBe(false); + }); + + it("has buildAccountSnapshot function", () => { + expect(nostrPlugin.status?.buildAccountSnapshot).toBeTypeOf("function"); + }); + }); +}); diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts new file mode 100644 index 000000000..30f2f7dfc --- /dev/null +++ b/extensions/nostr/src/channel.ts @@ -0,0 +1,335 @@ +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + formatPairingApproveHint, + type ChannelPlugin, +} from "clawdbot/plugin-sdk"; + +import { NostrConfigSchema } from "./config-schema.js"; +import { getNostrRuntime } from "./runtime.js"; +import { + listNostrAccountIds, + resolveDefaultNostrAccountId, + resolveNostrAccount, + type ResolvedNostrAccount, +} from "./types.js"; +import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; +import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; +import type { NostrProfile } from "./config-schema.js"; +import type { ProfilePublishResult } from "./nostr-profile.js"; + +// Store active bus handles per account +const activeBuses = new Map(); + +// Store metrics snapshots per account (for status reporting) +const metricsSnapshots = new Map(); + +export const nostrPlugin: ChannelPlugin = { + id: "nostr", + meta: { + id: "nostr", + label: "Nostr", + selectionLabel: "Nostr", + docsPath: "/channels/nostr", + docsLabel: "nostr", + blurb: "Decentralized DMs via Nostr relays (NIP-04)", + order: 100, + }, + capabilities: { + chatTypes: ["direct"], // DMs only for MVP + media: false, // No media for MVP + }, + reload: { configPrefixes: ["channels.nostr"] }, + configSchema: buildChannelConfigSchema(NostrConfigSchema), + + config: { + listAccountIds: (cfg) => listNostrAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveNostrAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultNostrAccountId(cfg), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + publicKey: account.publicKey, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveNostrAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => + String(entry) + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => { + if (entry === "*") return "*"; + try { + return normalizePubkey(entry); + } catch { + return entry; // Keep as-is if normalization fails + } + }) + .filter(Boolean), + }, + + pairing: { + idLabel: "nostrPubkey", + normalizeAllowEntry: (entry) => { + try { + return normalizePubkey(entry.replace(/^nostr:/i, "")); + } catch { + return entry; + } + }, + notifyApproval: async ({ id }) => { + // Get the default account's bus and send approval message + const bus = activeBuses.get(DEFAULT_ACCOUNT_ID); + if (bus) { + await bus.sendDm(id, "Your pairing request has been approved!"); + } + }, + }, + + security: { + resolveDmPolicy: ({ account }) => { + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: "channels.nostr.dmPolicy", + allowFromPath: "channels.nostr.allowFrom", + approveHint: formatPairingApproveHint("nostr"), + normalizeEntry: (raw) => { + try { + return normalizePubkey(raw.replace(/^nostr:/i, "").trim()); + } catch { + return raw.trim(); + } + }, + }; + }, + }, + + messaging: { + normalizeTarget: (target) => { + // Strip nostr: prefix if present + const cleaned = target.replace(/^nostr:/i, "").trim(); + try { + return normalizePubkey(cleaned); + } catch { + return cleaned; + } + }, + targetResolver: { + looksLikeId: (input) => { + const trimmed = input.trim(); + return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed); + }, + hint: "", + }, + }, + + outbound: { + deliveryMode: "direct", + textChunkLimit: 4000, + sendText: async ({ to, text, accountId }) => { + const aid = accountId ?? DEFAULT_ACCOUNT_ID; + const bus = activeBuses.get(aid); + if (!bus) { + throw new Error(`Nostr bus not running for account ${aid}`); + } + const normalizedTo = normalizePubkey(to); + await bus.sendDm(normalizedTo, text); + return { channel: "nostr", to: normalizedTo }; + }, + }, + + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) return []; + return [ + { + channel: "nostr", + accountId: account.accountId, + kind: "runtime" as const, + message: `Channel error: ${lastError}`, + }, + ]; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + publicKey: snapshot.publicKey ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + }), + buildAccountSnapshot: ({ account, runtime }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + publicKey: account.publicKey, + profile: account.profile, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + }, + + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + publicKey: account.publicKey, + }); + ctx.log?.info(`[${account.accountId}] starting Nostr provider (pubkey: ${account.publicKey})`); + + if (!account.configured) { + throw new Error("Nostr private key not configured"); + } + + const runtime = getNostrRuntime(); + + // Track bus handle for metrics callback + let busHandle: NostrBusHandle | null = null; + + const bus = await startNostrBus({ + accountId: account.accountId, + privateKey: account.privateKey, + relays: account.relays, + onMessage: async (senderPubkey, text, reply) => { + ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`); + + // Forward to clawdbot's message pipeline + await runtime.channel.reply.handleInboundMessage({ + channel: "nostr", + accountId: account.accountId, + senderId: senderPubkey, + chatType: "direct", + chatId: senderPubkey, // For DMs, chatId is the sender's pubkey + text, + reply: async (responseText: string) => { + await reply(responseText); + }, + }); + }, + onError: (error, context) => { + ctx.log?.error(`[${account.accountId}] Nostr error (${context}): ${error.message}`); + }, + onConnect: (relay) => { + ctx.log?.debug(`[${account.accountId}] Connected to relay: ${relay}`); + }, + onDisconnect: (relay) => { + ctx.log?.debug(`[${account.accountId}] Disconnected from relay: ${relay}`); + }, + onEose: (relays) => { + ctx.log?.debug(`[${account.accountId}] EOSE received from relays: ${relays}`); + }, + onMetric: (event: MetricEvent) => { + // Log significant metrics at appropriate levels + if (event.name.startsWith("event.rejected.")) { + ctx.log?.debug(`[${account.accountId}] Metric: ${event.name}`, event.labels); + } else if (event.name === "relay.circuit_breaker.open") { + ctx.log?.warn(`[${account.accountId}] Circuit breaker opened for relay: ${event.labels?.relay}`); + } else if (event.name === "relay.circuit_breaker.close") { + ctx.log?.info(`[${account.accountId}] Circuit breaker closed for relay: ${event.labels?.relay}`); + } else if (event.name === "relay.error") { + ctx.log?.debug(`[${account.accountId}] Relay error: ${event.labels?.relay}`); + } + // Update cached metrics snapshot + if (busHandle) { + metricsSnapshots.set(account.accountId, busHandle.getMetrics()); + } + }, + }); + + busHandle = bus; + + // Store the bus handle + activeBuses.set(account.accountId, bus); + + ctx.log?.info(`[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`); + + // Return cleanup function + return { + stop: () => { + bus.close(); + activeBuses.delete(account.accountId); + metricsSnapshots.delete(account.accountId); + ctx.log?.info(`[${account.accountId}] Nostr provider stopped`); + }, + }; + }, + }, +}; + +/** + * Get metrics snapshot for a Nostr account. + * Returns undefined if account is not running. + */ +export function getNostrMetrics(accountId: string = DEFAULT_ACCOUNT_ID): MetricsSnapshot | undefined { + const bus = activeBuses.get(accountId); + if (bus) { + return bus.getMetrics(); + } + return metricsSnapshots.get(accountId); +} + +/** + * Get all active Nostr bus handles. + * Useful for debugging and status reporting. + */ +export function getActiveNostrBuses(): Map { + return new Map(activeBuses); +} + +/** + * Publish a profile (kind:0) for a Nostr account. + * @param accountId - Account ID (defaults to "default") + * @param profile - Profile data to publish + * @returns Publish results with successes and failures + * @throws Error if account is not running + */ +export async function publishNostrProfile( + accountId: string = DEFAULT_ACCOUNT_ID, + profile: NostrProfile +): Promise { + const bus = activeBuses.get(accountId); + if (!bus) { + throw new Error(`Nostr bus not running for account ${accountId}`); + } + return bus.publishProfile(profile); +} + +/** + * Get profile publish state for a Nostr account. + * @param accountId - Account ID (defaults to "default") + * @returns Profile publish state or null if account not running + */ +export async function getNostrProfileState( + accountId: string = DEFAULT_ACCOUNT_ID +): Promise<{ + lastPublishedAt: number | null; + lastPublishedEventId: string | null; + lastPublishResults: Record | null; +} | null> { + const bus = activeBuses.get(accountId); + if (!bus) { + return null; + } + return bus.getProfileState(); +} diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts new file mode 100644 index 000000000..bb01a068d --- /dev/null +++ b/extensions/nostr/src/config-schema.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import { buildChannelConfigSchema } from "clawdbot/plugin-sdk"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +/** + * Validates https:// URLs only (no javascript:, data:, file:, etc.) + */ +const safeUrlSchema = z + .string() + .url() + .refine( + (url) => { + try { + const parsed = new URL(url); + return parsed.protocol === "https:"; + } catch { + return false; + } + }, + { message: "URL must use https:// protocol" } + ); + +/** + * NIP-01 profile metadata schema + * https://github.com/nostr-protocol/nips/blob/master/01.md + */ +export const NostrProfileSchema = z.object({ + /** Username (NIP-01: name) - max 256 chars */ + name: z.string().max(256).optional(), + + /** Display name (NIP-01: display_name) - max 256 chars */ + displayName: z.string().max(256).optional(), + + /** Bio/description (NIP-01: about) - max 2000 chars */ + about: z.string().max(2000).optional(), + + /** Profile picture URL (must be https) */ + picture: safeUrlSchema.optional(), + + /** Banner image URL (must be https) */ + banner: safeUrlSchema.optional(), + + /** Website URL (must be https) */ + website: safeUrlSchema.optional(), + + /** NIP-05 identifier (e.g., "user@example.com") */ + nip05: z.string().optional(), + + /** Lightning address (LUD-16) */ + lud16: z.string().optional(), +}); + +export type NostrProfile = z.infer; + +/** + * Zod schema for channels.nostr.* configuration + */ +export const NostrConfigSchema = z.object({ + /** Account name (optional display name) */ + name: z.string().optional(), + + /** Whether this channel is enabled */ + enabled: z.boolean().optional(), + + /** Private key in hex or nsec bech32 format */ + privateKey: z.string().optional(), + + /** WebSocket relay URLs to connect to */ + relays: z.array(z.string()).optional(), + + /** DM access policy: pairing, allowlist, open, or disabled */ + dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + + /** Allowed sender pubkeys (npub or hex format) */ + allowFrom: z.array(allowFromEntry).optional(), + + /** Profile metadata (NIP-01 kind:0 content) */ + profile: NostrProfileSchema.optional(), +}); + +export type NostrConfig = z.infer; + +/** + * JSON Schema for Control UI (converted from Zod) + */ +export const nostrChannelConfigSchema = buildChannelConfigSchema(NostrConfigSchema); diff --git a/extensions/nostr/src/metrics.ts b/extensions/nostr/src/metrics.ts new file mode 100644 index 000000000..e9e0e7fb7 --- /dev/null +++ b/extensions/nostr/src/metrics.ts @@ -0,0 +1,464 @@ +/** + * Comprehensive metrics system for Nostr bus observability. + * Provides clear insight into what's happening with events, relays, and operations. + */ + +// ============================================================================ +// Metric Types +// ============================================================================ + +export type EventMetricName = + | "event.received" + | "event.processed" + | "event.duplicate" + | "event.rejected.invalid_shape" + | "event.rejected.wrong_kind" + | "event.rejected.stale" + | "event.rejected.future" + | "event.rejected.rate_limited" + | "event.rejected.invalid_signature" + | "event.rejected.oversized_ciphertext" + | "event.rejected.oversized_plaintext" + | "event.rejected.decrypt_failed" + | "event.rejected.self_message"; + +export type RelayMetricName = + | "relay.connect" + | "relay.disconnect" + | "relay.reconnect" + | "relay.error" + | "relay.message.event" + | "relay.message.eose" + | "relay.message.closed" + | "relay.message.notice" + | "relay.message.ok" + | "relay.message.auth" + | "relay.circuit_breaker.open" + | "relay.circuit_breaker.close" + | "relay.circuit_breaker.half_open"; + +export type RateLimitMetricName = "rate_limit.per_sender" | "rate_limit.global"; + +export type DecryptMetricName = "decrypt.success" | "decrypt.failure"; + +export type MemoryMetricName = + | "memory.seen_tracker_size" + | "memory.rate_limiter_entries"; + +export type MetricName = + | EventMetricName + | RelayMetricName + | RateLimitMetricName + | DecryptMetricName + | MemoryMetricName; + +// ============================================================================ +// Metric Event +// ============================================================================ + +export interface MetricEvent { + /** Metric name (e.g., "event.received", "relay.connect") */ + name: MetricName; + /** Metric value (usually 1 for counters, or a measured value) */ + value: number; + /** Unix timestamp in milliseconds */ + timestamp: number; + /** Optional labels for additional context */ + labels?: Record; +} + +export type OnMetricCallback = (event: MetricEvent) => void; + +// ============================================================================ +// Metrics Snapshot (for getMetrics()) +// ============================================================================ + +export interface MetricsSnapshot { + /** Total events received (before any filtering) */ + eventsReceived: number; + /** Events successfully processed */ + eventsProcessed: number; + /** Duplicate events skipped */ + eventsDuplicate: number; + /** Events rejected by reason */ + eventsRejected: { + invalidShape: number; + wrongKind: number; + stale: number; + future: number; + rateLimited: number; + invalidSignature: number; + oversizedCiphertext: number; + oversizedPlaintext: number; + decryptFailed: number; + selfMessage: number; + }; + + /** Relay stats by URL */ + relays: Record< + string, + { + connects: number; + disconnects: number; + reconnects: number; + errors: number; + messagesReceived: { + event: number; + eose: number; + closed: number; + notice: number; + ok: number; + auth: number; + }; + circuitBreakerState: "closed" | "open" | "half_open"; + circuitBreakerOpens: number; + circuitBreakerCloses: number; + } + >; + + /** Rate limiting stats */ + rateLimiting: { + perSenderHits: number; + globalHits: number; + }; + + /** Decrypt stats */ + decrypt: { + success: number; + failure: number; + }; + + /** Memory/capacity stats */ + memory: { + seenTrackerSize: number; + rateLimiterEntries: number; + }; + + /** Snapshot timestamp */ + snapshotAt: number; +} + +// ============================================================================ +// Metrics Collector +// ============================================================================ + +export interface NostrMetrics { + /** Emit a metric event */ + emit: ( + name: MetricName, + value?: number, + labels?: Record + ) => void; + + /** Get current metrics snapshot */ + getSnapshot: () => MetricsSnapshot; + + /** Reset all metrics to zero */ + reset: () => void; +} + +/** + * Create a metrics collector instance. + * Optionally pass an onMetric callback to receive real-time metric events. + */ +export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics { + // Counters + let eventsReceived = 0; + let eventsProcessed = 0; + let eventsDuplicate = 0; + const eventsRejected = { + invalidShape: 0, + wrongKind: 0, + stale: 0, + future: 0, + rateLimited: 0, + invalidSignature: 0, + oversizedCiphertext: 0, + oversizedPlaintext: 0, + decryptFailed: 0, + selfMessage: 0, + }; + + // Per-relay stats + const relays = new Map< + string, + { + connects: number; + disconnects: number; + reconnects: number; + errors: number; + messagesReceived: { + event: number; + eose: number; + closed: number; + notice: number; + ok: number; + auth: number; + }; + circuitBreakerState: "closed" | "open" | "half_open"; + circuitBreakerOpens: number; + circuitBreakerCloses: number; + } + >(); + + // Rate limiting stats + const rateLimiting = { + perSenderHits: 0, + globalHits: 0, + }; + + // Decrypt stats + const decrypt = { + success: 0, + failure: 0, + }; + + // Memory stats (updated via gauge-style metrics) + const memory = { + seenTrackerSize: 0, + rateLimiterEntries: 0, + }; + + function getOrCreateRelay(url: string) { + let relay = relays.get(url); + if (!relay) { + relay = { + connects: 0, + disconnects: 0, + reconnects: 0, + errors: 0, + messagesReceived: { + event: 0, + eose: 0, + closed: 0, + notice: 0, + ok: 0, + auth: 0, + }, + circuitBreakerState: "closed", + circuitBreakerOpens: 0, + circuitBreakerCloses: 0, + }; + relays.set(url, relay); + } + return relay; + } + + function emit( + name: MetricName, + value: number = 1, + labels?: Record + ): void { + // Fire callback if provided + if (onMetric) { + onMetric({ + name, + value, + timestamp: Date.now(), + labels, + }); + } + + // Update internal counters + const relayUrl = labels?.relay as string | undefined; + + switch (name) { + // Event metrics + case "event.received": + eventsReceived += value; + break; + case "event.processed": + eventsProcessed += value; + break; + case "event.duplicate": + eventsDuplicate += value; + break; + case "event.rejected.invalid_shape": + eventsRejected.invalidShape += value; + break; + case "event.rejected.wrong_kind": + eventsRejected.wrongKind += value; + break; + case "event.rejected.stale": + eventsRejected.stale += value; + break; + case "event.rejected.future": + eventsRejected.future += value; + break; + case "event.rejected.rate_limited": + eventsRejected.rateLimited += value; + break; + case "event.rejected.invalid_signature": + eventsRejected.invalidSignature += value; + break; + case "event.rejected.oversized_ciphertext": + eventsRejected.oversizedCiphertext += value; + break; + case "event.rejected.oversized_plaintext": + eventsRejected.oversizedPlaintext += value; + break; + case "event.rejected.decrypt_failed": + eventsRejected.decryptFailed += value; + break; + case "event.rejected.self_message": + eventsRejected.selfMessage += value; + break; + + // Relay metrics + case "relay.connect": + if (relayUrl) getOrCreateRelay(relayUrl).connects += value; + break; + case "relay.disconnect": + if (relayUrl) getOrCreateRelay(relayUrl).disconnects += value; + break; + case "relay.reconnect": + if (relayUrl) getOrCreateRelay(relayUrl).reconnects += value; + break; + case "relay.error": + if (relayUrl) getOrCreateRelay(relayUrl).errors += value; + break; + case "relay.message.event": + if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.event += value; + break; + case "relay.message.eose": + if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.eose += value; + break; + case "relay.message.closed": + if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.closed += value; + break; + case "relay.message.notice": + if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.notice += value; + break; + case "relay.message.ok": + if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.ok += value; + break; + case "relay.message.auth": + if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.auth += value; + break; + case "relay.circuit_breaker.open": + if (relayUrl) { + const r = getOrCreateRelay(relayUrl); + r.circuitBreakerState = "open"; + r.circuitBreakerOpens += value; + } + break; + case "relay.circuit_breaker.close": + if (relayUrl) { + const r = getOrCreateRelay(relayUrl); + r.circuitBreakerState = "closed"; + r.circuitBreakerCloses += value; + } + break; + case "relay.circuit_breaker.half_open": + if (relayUrl) { + getOrCreateRelay(relayUrl).circuitBreakerState = "half_open"; + } + break; + + // Rate limiting + case "rate_limit.per_sender": + rateLimiting.perSenderHits += value; + break; + case "rate_limit.global": + rateLimiting.globalHits += value; + break; + + // Decrypt + case "decrypt.success": + decrypt.success += value; + break; + case "decrypt.failure": + decrypt.failure += value; + break; + + // Memory (gauge-style - value replaces, not adds) + case "memory.seen_tracker_size": + memory.seenTrackerSize = value; + break; + case "memory.rate_limiter_entries": + memory.rateLimiterEntries = value; + break; + } + } + + function getSnapshot(): MetricsSnapshot { + // Convert relay map to object + const relaysObj: MetricsSnapshot["relays"] = {}; + for (const [url, stats] of relays) { + relaysObj[url] = { ...stats, messagesReceived: { ...stats.messagesReceived } }; + } + + return { + eventsReceived, + eventsProcessed, + eventsDuplicate, + eventsRejected: { ...eventsRejected }, + relays: relaysObj, + rateLimiting: { ...rateLimiting }, + decrypt: { ...decrypt }, + memory: { ...memory }, + snapshotAt: Date.now(), + }; + } + + function reset(): void { + eventsReceived = 0; + eventsProcessed = 0; + eventsDuplicate = 0; + Object.assign(eventsRejected, { + invalidShape: 0, + wrongKind: 0, + stale: 0, + future: 0, + rateLimited: 0, + invalidSignature: 0, + oversizedCiphertext: 0, + oversizedPlaintext: 0, + decryptFailed: 0, + selfMessage: 0, + }); + relays.clear(); + rateLimiting.perSenderHits = 0; + rateLimiting.globalHits = 0; + decrypt.success = 0; + decrypt.failure = 0; + memory.seenTrackerSize = 0; + memory.rateLimiterEntries = 0; + } + + return { emit, getSnapshot, reset }; +} + +/** + * Create a no-op metrics instance (for when metrics are disabled). + */ +export function createNoopMetrics(): NostrMetrics { + const emptySnapshot: MetricsSnapshot = { + eventsReceived: 0, + eventsProcessed: 0, + eventsDuplicate: 0, + eventsRejected: { + invalidShape: 0, + wrongKind: 0, + stale: 0, + future: 0, + rateLimited: 0, + invalidSignature: 0, + oversizedCiphertext: 0, + oversizedPlaintext: 0, + decryptFailed: 0, + selfMessage: 0, + }, + relays: {}, + rateLimiting: { perSenderHits: 0, globalHits: 0 }, + decrypt: { success: 0, failure: 0 }, + memory: { seenTrackerSize: 0, rateLimiterEntries: 0 }, + snapshotAt: 0, + }; + + return { + emit: () => {}, + getSnapshot: () => ({ ...emptySnapshot, snapshotAt: Date.now() }), + reset: () => {}, + }; +} diff --git a/extensions/nostr/src/nostr-bus.fuzz.test.ts b/extensions/nostr/src/nostr-bus.fuzz.test.ts new file mode 100644 index 000000000..e2fbc47f4 --- /dev/null +++ b/extensions/nostr/src/nostr-bus.fuzz.test.ts @@ -0,0 +1,544 @@ +import { describe, expect, it } from "vitest"; +import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-bus.js"; +import { createSeenTracker } from "./seen-tracker.js"; +import { createMetrics, type MetricName } from "./metrics.js"; + +// ============================================================================ +// Fuzz Tests for validatePrivateKey +// ============================================================================ + +describe("validatePrivateKey fuzz", () => { + describe("type confusion", () => { + it("rejects null input", () => { + expect(() => validatePrivateKey(null as unknown as string)).toThrow(); + }); + + it("rejects undefined input", () => { + expect(() => validatePrivateKey(undefined as unknown as string)).toThrow(); + }); + + it("rejects number input", () => { + expect(() => validatePrivateKey(123 as unknown as string)).toThrow(); + }); + + it("rejects boolean input", () => { + expect(() => validatePrivateKey(true as unknown as string)).toThrow(); + }); + + it("rejects object input", () => { + expect(() => validatePrivateKey({} as unknown as string)).toThrow(); + }); + + it("rejects array input", () => { + expect(() => validatePrivateKey([] as unknown as string)).toThrow(); + }); + + it("rejects function input", () => { + expect(() => validatePrivateKey((() => {}) as unknown as string)).toThrow(); + }); + }); + + describe("unicode attacks", () => { + it("rejects unicode lookalike characters", () => { + // Using zero-width characters + const withZeroWidth = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u200Bf"; + expect(() => validatePrivateKey(withZeroWidth)).toThrow(); + }); + + it("rejects RTL override", () => { + const withRtl = + "\u202E0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(() => validatePrivateKey(withRtl)).toThrow(); + }); + + it("rejects homoglyph 'a' (Cyrillic а)", () => { + // Using Cyrillic 'а' (U+0430) instead of Latin 'a' + const withCyrillicA = + "0123456789\u0430bcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(() => validatePrivateKey(withCyrillicA)).toThrow(); + }); + + it("rejects emoji", () => { + const withEmoji = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab😀"; + expect(() => validatePrivateKey(withEmoji)).toThrow(); + }); + + it("rejects combining characters", () => { + // 'a' followed by combining acute accent + const withCombining = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u0301"; + expect(() => validatePrivateKey(withCombining)).toThrow(); + }); + }); + + describe("injection attempts", () => { + it("rejects null byte injection", () => { + const withNullByte = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\x00f"; + expect(() => validatePrivateKey(withNullByte)).toThrow(); + }); + + it("rejects newline injection", () => { + const withNewline = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\nf"; + expect(() => validatePrivateKey(withNewline)).toThrow(); + }); + + it("rejects carriage return injection", () => { + const withCR = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\rf"; + expect(() => validatePrivateKey(withCR)).toThrow(); + }); + + it("rejects tab injection", () => { + const withTab = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\tf"; + expect(() => validatePrivateKey(withTab)).toThrow(); + }); + + it("rejects form feed injection", () => { + const withFormFeed = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\ff"; + expect(() => validatePrivateKey(withFormFeed)).toThrow(); + }); + }); + + describe("edge cases", () => { + it("rejects very long string", () => { + const veryLong = "a".repeat(10000); + expect(() => validatePrivateKey(veryLong)).toThrow(); + }); + + it("rejects string of spaces matching length", () => { + const spaces = " ".repeat(64); + expect(() => validatePrivateKey(spaces)).toThrow(); + }); + + it("rejects hex with spaces between characters", () => { + const withSpaces = + "01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef"; + expect(() => validatePrivateKey(withSpaces)).toThrow(); + }); + }); + + describe("nsec format edge cases", () => { + it("rejects nsec with invalid bech32 characters", () => { + // 'b', 'i', 'o' are not valid bech32 characters + const invalidBech32 = "nsec1qypqxpq9qtpqscx7peytbfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l"; + expect(() => validatePrivateKey(invalidBech32)).toThrow(); + }); + + it("rejects nsec with wrong prefix", () => { + expect(() => validatePrivateKey("nsec0aaaa")).toThrow(); + }); + + it("rejects partial nsec", () => { + expect(() => validatePrivateKey("nsec1")).toThrow(); + }); + }); +}); + +// ============================================================================ +// Fuzz Tests for isValidPubkey +// ============================================================================ + +describe("isValidPubkey fuzz", () => { + describe("type confusion", () => { + it("handles null gracefully", () => { + expect(isValidPubkey(null as unknown as string)).toBe(false); + }); + + it("handles undefined gracefully", () => { + expect(isValidPubkey(undefined as unknown as string)).toBe(false); + }); + + it("handles number gracefully", () => { + expect(isValidPubkey(123 as unknown as string)).toBe(false); + }); + + it("handles object gracefully", () => { + expect(isValidPubkey({} as unknown as string)).toBe(false); + }); + }); + + describe("malicious inputs", () => { + it("rejects __proto__ key", () => { + expect(isValidPubkey("__proto__")).toBe(false); + }); + + it("rejects constructor key", () => { + expect(isValidPubkey("constructor")).toBe(false); + }); + + it("rejects toString key", () => { + expect(isValidPubkey("toString")).toBe(false); + }); + }); +}); + +// ============================================================================ +// Fuzz Tests for normalizePubkey +// ============================================================================ + +describe("normalizePubkey fuzz", () => { + describe("prototype pollution attempts", () => { + it("throws for __proto__", () => { + expect(() => normalizePubkey("__proto__")).toThrow(); + }); + + it("throws for constructor", () => { + expect(() => normalizePubkey("constructor")).toThrow(); + }); + + it("throws for prototype", () => { + expect(() => normalizePubkey("prototype")).toThrow(); + }); + }); + + describe("case sensitivity", () => { + it("normalizes uppercase to lowercase", () => { + const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(normalizePubkey(upper)).toBe(lower); + }); + + it("normalizes mixed case to lowercase", () => { + const mixed = "0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf"; + const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(normalizePubkey(mixed)).toBe(lower); + }); + }); +}); + +// ============================================================================ +// Fuzz Tests for SeenTracker +// ============================================================================ + +describe("SeenTracker fuzz", () => { + describe("malformed IDs", () => { + it("handles empty string IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + expect(() => tracker.add("")).not.toThrow(); + expect(tracker.peek("")).toBe(true); + tracker.stop(); + }); + + it("handles very long IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + const longId = "a".repeat(100000); + expect(() => tracker.add(longId)).not.toThrow(); + expect(tracker.peek(longId)).toBe(true); + tracker.stop(); + }); + + it("handles unicode IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + const unicodeId = "事件ID_🎉_тест"; + expect(() => tracker.add(unicodeId)).not.toThrow(); + expect(tracker.peek(unicodeId)).toBe(true); + tracker.stop(); + }); + + it("handles IDs with null bytes", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + const idWithNull = "event\x00id"; + expect(() => tracker.add(idWithNull)).not.toThrow(); + expect(tracker.peek(idWithNull)).toBe(true); + tracker.stop(); + }); + + it("handles prototype property names as IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + + // These should not affect the tracker's internal operation + expect(() => tracker.add("__proto__")).not.toThrow(); + expect(() => tracker.add("constructor")).not.toThrow(); + expect(() => tracker.add("toString")).not.toThrow(); + expect(() => tracker.add("hasOwnProperty")).not.toThrow(); + + expect(tracker.peek("__proto__")).toBe(true); + expect(tracker.peek("constructor")).toBe(true); + expect(tracker.peek("toString")).toBe(true); + expect(tracker.peek("hasOwnProperty")).toBe(true); + + tracker.stop(); + }); + }); + + describe("rapid operations", () => { + it("handles rapid add/check cycles", () => { + const tracker = createSeenTracker({ maxEntries: 1000 }); + + for (let i = 0; i < 10000; i++) { + const id = `event-${i}`; + tracker.add(id); + // Recently added should be findable + if (i < 1000) { + tracker.peek(id); + } + } + + // Size should be capped at maxEntries + expect(tracker.size()).toBeLessThanOrEqual(1000); + tracker.stop(); + }); + + it("handles concurrent-style operations", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + + // Simulate interleaved operations + for (let i = 0; i < 100; i++) { + tracker.add(`add-${i}`); + tracker.peek(`peek-${i}`); + tracker.has(`has-${i}`); + if (i % 10 === 0) { + tracker.delete(`add-${i - 5}`); + } + } + + expect(() => tracker.size()).not.toThrow(); + tracker.stop(); + }); + }); + + describe("seed edge cases", () => { + it("handles empty seed array", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + expect(() => tracker.seed([])).not.toThrow(); + expect(tracker.size()).toBe(0); + tracker.stop(); + }); + + it("handles seed with duplicate IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + tracker.seed(["id1", "id1", "id1", "id2", "id2"]); + expect(tracker.size()).toBe(2); + tracker.stop(); + }); + + it("handles seed larger than maxEntries", () => { + const tracker = createSeenTracker({ maxEntries: 5 }); + const ids = Array.from({ length: 100 }, (_, i) => `id-${i}`); + tracker.seed(ids); + expect(tracker.size()).toBeLessThanOrEqual(5); + tracker.stop(); + }); + }); +}); + +// ============================================================================ +// Fuzz Tests for Metrics +// ============================================================================ + +describe("Metrics fuzz", () => { + describe("invalid metric names", () => { + it("handles unknown metric names gracefully", () => { + const metrics = createMetrics(); + + // Cast to bypass type checking - testing runtime behavior + expect(() => { + metrics.emit("invalid.metric.name" as MetricName); + }).not.toThrow(); + }); + }); + + describe("invalid label values", () => { + it("handles null relay label", () => { + const metrics = createMetrics(); + expect(() => { + metrics.emit("relay.connect", 1, { relay: null as unknown as string }); + }).not.toThrow(); + }); + + it("handles undefined relay label", () => { + const metrics = createMetrics(); + expect(() => { + metrics.emit("relay.connect", 1, { relay: undefined as unknown as string }); + }).not.toThrow(); + }); + + it("handles very long relay URL", () => { + const metrics = createMetrics(); + const longUrl = "wss://" + "a".repeat(10000) + ".com"; + expect(() => { + metrics.emit("relay.connect", 1, { relay: longUrl }); + }).not.toThrow(); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.relays[longUrl]).toBeDefined(); + }); + }); + + describe("extreme values", () => { + it("handles NaN value", () => { + const metrics = createMetrics(); + expect(() => metrics.emit("event.received", NaN)).not.toThrow(); + + const snapshot = metrics.getSnapshot(); + expect(isNaN(snapshot.eventsReceived)).toBe(true); + }); + + it("handles Infinity value", () => { + const metrics = createMetrics(); + expect(() => metrics.emit("event.received", Infinity)).not.toThrow(); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(Infinity); + }); + + it("handles negative value", () => { + const metrics = createMetrics(); + metrics.emit("event.received", -1); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(-1); + }); + + it("handles very large value", () => { + const metrics = createMetrics(); + metrics.emit("event.received", Number.MAX_SAFE_INTEGER); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(Number.MAX_SAFE_INTEGER); + }); + }); + + describe("rapid emissions", () => { + it("handles many rapid emissions", () => { + const events: unknown[] = []; + const metrics = createMetrics((e) => events.push(e)); + + for (let i = 0; i < 10000; i++) { + metrics.emit("event.received"); + } + + expect(events).toHaveLength(10000); + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(10000); + }); + }); + + describe("reset during operation", () => { + it("handles reset mid-operation safely", () => { + const metrics = createMetrics(); + + metrics.emit("event.received"); + metrics.emit("event.received"); + metrics.reset(); + metrics.emit("event.received"); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(1); + }); + }); +}); + +// ============================================================================ +// Event Shape Validation (simulating malformed events) +// ============================================================================ + +describe("Event shape validation", () => { + describe("malformed event structures", () => { + // These test what happens if malformed data somehow gets through + + it("identifies missing required fields", () => { + const malformedEvents = [ + {}, // empty + { id: "abc" }, // missing pubkey, created_at, etc. + { id: null, pubkey: null }, // null values + { id: 123, pubkey: 456 }, // wrong types + { tags: "not-an-array" }, // wrong type for tags + { tags: [[1, 2, 3]] }, // wrong type for tag elements + ]; + + for (const event of malformedEvents) { + // These should be caught by shape validation before processing + const hasId = typeof event?.id === "string"; + const hasPubkey = typeof (event as { pubkey?: unknown })?.pubkey === "string"; + const hasTags = Array.isArray((event as { tags?: unknown })?.tags); + + // At least one should be invalid + expect(hasId && hasPubkey && hasTags).toBe(false); + } + }); + }); + + describe("timestamp edge cases", () => { + const testTimestamps = [ + { value: NaN, desc: "NaN" }, + { value: Infinity, desc: "Infinity" }, + { value: -Infinity, desc: "-Infinity" }, + { value: -1, desc: "negative" }, + { value: 0, desc: "zero" }, + { value: 253402300800, desc: "year 10000" }, // Far future + { value: -62135596800, desc: "year 0001" }, // Far past + { value: 1.5, desc: "float" }, + ]; + + for (const { value, desc } of testTimestamps) { + it(`handles ${desc} timestamp`, () => { + const isValidTimestamp = + typeof value === "number" && + !isNaN(value) && + isFinite(value) && + value >= 0 && + Number.isInteger(value); + + // Timestamps should be validated as positive integers + if (["NaN", "Infinity", "-Infinity", "negative", "float"].includes(desc)) { + expect(isValidTimestamp).toBe(false); + } + }); + } + }); +}); + +// ============================================================================ +// JSON parsing edge cases (simulating relay responses) +// ============================================================================ + +describe("JSON parsing edge cases", () => { + const malformedJsonCases = [ + { input: "", desc: "empty string" }, + { input: "null", desc: "null literal" }, + { input: "undefined", desc: "undefined literal" }, + { input: "{", desc: "incomplete object" }, + { input: "[", desc: "incomplete array" }, + { input: '{"key": undefined}', desc: "undefined value" }, + { input: "{'key': 'value'}", desc: "single quotes" }, + { input: '{"key": NaN}', desc: "NaN value" }, + { input: '{"key": Infinity}', desc: "Infinity value" }, + { input: "\x00", desc: "null byte" }, + { input: "abc", desc: "plain string" }, + { input: "123", desc: "plain number" }, + ]; + + for (const { input, desc } of malformedJsonCases) { + it(`handles malformed JSON: ${desc}`, () => { + let parsed: unknown; + let parseError = false; + + try { + parsed = JSON.parse(input); + } catch { + parseError = true; + } + + // Either it throws or produces something that needs validation + if (!parseError) { + // If it parsed, we need to validate the structure + const isValidRelayMessage = + Array.isArray(parsed) && + parsed.length >= 2 && + typeof parsed[0] === "string"; + + // Most malformed cases won't produce valid relay messages + if (["null literal", "plain number", "plain string"].includes(desc)) { + expect(isValidRelayMessage).toBe(false); + } + } + }); + } +}); diff --git a/extensions/nostr/src/nostr-bus.integration.test.ts b/extensions/nostr/src/nostr-bus.integration.test.ts new file mode 100644 index 000000000..3e24bb831 --- /dev/null +++ b/extensions/nostr/src/nostr-bus.integration.test.ts @@ -0,0 +1,452 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { createSeenTracker } from "./seen-tracker.js"; +import { + createMetrics, + createNoopMetrics, + type MetricEvent, +} from "./metrics.js"; + +// ============================================================================ +// Seen Tracker Integration Tests +// ============================================================================ + +describe("SeenTracker", () => { + describe("basic operations", () => { + it("tracks seen IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); + + // First check returns false and adds + expect(tracker.has("id1")).toBe(false); + // Second check returns true (already seen) + expect(tracker.has("id1")).toBe(true); + + tracker.stop(); + }); + + it("peek does not add", () => { + const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); + + expect(tracker.peek("id1")).toBe(false); + expect(tracker.peek("id1")).toBe(false); // Still false + + tracker.add("id1"); + expect(tracker.peek("id1")).toBe(true); + + tracker.stop(); + }); + + it("delete removes entries", () => { + const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); + + tracker.add("id1"); + expect(tracker.peek("id1")).toBe(true); + + tracker.delete("id1"); + expect(tracker.peek("id1")).toBe(false); + + tracker.stop(); + }); + + it("clear removes all entries", () => { + const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); + + tracker.add("id1"); + tracker.add("id2"); + tracker.add("id3"); + expect(tracker.size()).toBe(3); + + tracker.clear(); + expect(tracker.size()).toBe(0); + expect(tracker.peek("id1")).toBe(false); + + tracker.stop(); + }); + + it("seed pre-populates entries", () => { + const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); + + tracker.seed(["id1", "id2", "id3"]); + expect(tracker.size()).toBe(3); + expect(tracker.peek("id1")).toBe(true); + expect(tracker.peek("id2")).toBe(true); + expect(tracker.peek("id3")).toBe(true); + + tracker.stop(); + }); + }); + + describe("LRU eviction", () => { + it("evicts least recently used when at capacity", () => { + const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 }); + + tracker.add("id1"); + tracker.add("id2"); + tracker.add("id3"); + expect(tracker.size()).toBe(3); + + // Adding fourth should evict oldest (id1) + tracker.add("id4"); + expect(tracker.size()).toBe(3); + expect(tracker.peek("id1")).toBe(false); // Evicted + expect(tracker.peek("id2")).toBe(true); + expect(tracker.peek("id3")).toBe(true); + expect(tracker.peek("id4")).toBe(true); + + tracker.stop(); + }); + + it("accessing an entry moves it to front (prevents eviction)", () => { + const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 }); + + tracker.add("id1"); + tracker.add("id2"); + tracker.add("id3"); + + // Access id1, moving it to front + tracker.has("id1"); + + // Add id4 - should evict id2 (now oldest) + tracker.add("id4"); + expect(tracker.peek("id1")).toBe(true); // Not evicted, was accessed + expect(tracker.peek("id2")).toBe(false); // Evicted + expect(tracker.peek("id3")).toBe(true); + expect(tracker.peek("id4")).toBe(true); + + tracker.stop(); + }); + + it("handles capacity of 1", () => { + const tracker = createSeenTracker({ maxEntries: 1, ttlMs: 60000 }); + + tracker.add("id1"); + expect(tracker.peek("id1")).toBe(true); + + tracker.add("id2"); + expect(tracker.peek("id1")).toBe(false); + expect(tracker.peek("id2")).toBe(true); + + tracker.stop(); + }); + + it("seed respects maxEntries", () => { + const tracker = createSeenTracker({ maxEntries: 2, ttlMs: 60000 }); + + tracker.seed(["id1", "id2", "id3", "id4"]); + expect(tracker.size()).toBe(2); + // Seed stops when maxEntries reached, processing from end to start + // So id4 and id3 get added first, then we're at capacity + expect(tracker.peek("id3")).toBe(true); + expect(tracker.peek("id4")).toBe(true); + + tracker.stop(); + }); + }); + + describe("TTL expiration", () => { + it("expires entries after TTL", async () => { + vi.useFakeTimers(); + + const tracker = createSeenTracker({ + maxEntries: 100, + ttlMs: 100, + pruneIntervalMs: 50, + }); + + tracker.add("id1"); + expect(tracker.peek("id1")).toBe(true); + + // Advance past TTL + vi.advanceTimersByTime(150); + + // Entry should be expired + expect(tracker.peek("id1")).toBe(false); + + tracker.stop(); + vi.useRealTimers(); + }); + + it("has() refreshes TTL", async () => { + vi.useFakeTimers(); + + const tracker = createSeenTracker({ + maxEntries: 100, + ttlMs: 100, + pruneIntervalMs: 50, + }); + + tracker.add("id1"); + + // Advance halfway + vi.advanceTimersByTime(50); + + // Access to refresh + expect(tracker.has("id1")).toBe(true); + + // Advance another 75ms (total 125ms from add, but only 75ms from last access) + vi.advanceTimersByTime(75); + + // Should still be valid (refreshed at 50ms) + expect(tracker.peek("id1")).toBe(true); + + tracker.stop(); + vi.useRealTimers(); + }); + }); +}); + +// ============================================================================ +// Metrics Integration Tests +// ============================================================================ + +describe("Metrics", () => { + describe("createMetrics", () => { + it("emits metric events to callback", () => { + const events: MetricEvent[] = []; + const metrics = createMetrics((event) => events.push(event)); + + metrics.emit("event.received"); + metrics.emit("event.processed"); + metrics.emit("event.duplicate"); + + expect(events).toHaveLength(3); + expect(events[0].name).toBe("event.received"); + expect(events[1].name).toBe("event.processed"); + expect(events[2].name).toBe("event.duplicate"); + }); + + it("includes labels in metric events", () => { + const events: MetricEvent[] = []; + const metrics = createMetrics((event) => events.push(event)); + + metrics.emit("relay.connect", 1, { relay: "wss://relay.example.com" }); + + expect(events[0].labels).toEqual({ relay: "wss://relay.example.com" }); + }); + + it("accumulates counters in snapshot", () => { + const metrics = createMetrics(); + + metrics.emit("event.received"); + metrics.emit("event.received"); + metrics.emit("event.processed"); + metrics.emit("event.duplicate"); + metrics.emit("event.duplicate"); + metrics.emit("event.duplicate"); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(2); + expect(snapshot.eventsProcessed).toBe(1); + expect(snapshot.eventsDuplicate).toBe(3); + }); + + it("tracks per-relay stats", () => { + const metrics = createMetrics(); + + metrics.emit("relay.connect", 1, { relay: "wss://relay1.com" }); + metrics.emit("relay.connect", 1, { relay: "wss://relay2.com" }); + metrics.emit("relay.error", 1, { relay: "wss://relay1.com" }); + metrics.emit("relay.error", 1, { relay: "wss://relay1.com" }); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.relays["wss://relay1.com"]).toBeDefined(); + expect(snapshot.relays["wss://relay1.com"].connects).toBe(1); + expect(snapshot.relays["wss://relay1.com"].errors).toBe(2); + expect(snapshot.relays["wss://relay2.com"].connects).toBe(1); + expect(snapshot.relays["wss://relay2.com"].errors).toBe(0); + }); + + it("tracks circuit breaker state changes", () => { + const metrics = createMetrics(); + + metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" }); + + let snapshot = metrics.getSnapshot(); + expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("open"); + expect(snapshot.relays["wss://relay.com"].circuitBreakerOpens).toBe(1); + + metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" }); + + snapshot = metrics.getSnapshot(); + expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("closed"); + expect(snapshot.relays["wss://relay.com"].circuitBreakerCloses).toBe(1); + }); + + it("tracks all rejection reasons", () => { + const metrics = createMetrics(); + + metrics.emit("event.rejected.invalid_shape"); + metrics.emit("event.rejected.wrong_kind"); + metrics.emit("event.rejected.stale"); + metrics.emit("event.rejected.future"); + metrics.emit("event.rejected.rate_limited"); + metrics.emit("event.rejected.invalid_signature"); + metrics.emit("event.rejected.oversized_ciphertext"); + metrics.emit("event.rejected.oversized_plaintext"); + metrics.emit("event.rejected.decrypt_failed"); + metrics.emit("event.rejected.self_message"); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsRejected.invalidShape).toBe(1); + expect(snapshot.eventsRejected.wrongKind).toBe(1); + expect(snapshot.eventsRejected.stale).toBe(1); + expect(snapshot.eventsRejected.future).toBe(1); + expect(snapshot.eventsRejected.rateLimited).toBe(1); + expect(snapshot.eventsRejected.invalidSignature).toBe(1); + expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1); + expect(snapshot.eventsRejected.oversizedPlaintext).toBe(1); + expect(snapshot.eventsRejected.decryptFailed).toBe(1); + expect(snapshot.eventsRejected.selfMessage).toBe(1); + }); + + it("tracks relay message types", () => { + const metrics = createMetrics(); + + metrics.emit("relay.message.event", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.message.eose", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.message.closed", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.message.notice", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.message.ok", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.message.auth", 1, { relay: "wss://relay.com" }); + + const snapshot = metrics.getSnapshot(); + const relay = snapshot.relays["wss://relay.com"]; + expect(relay.messagesReceived.event).toBe(1); + expect(relay.messagesReceived.eose).toBe(1); + expect(relay.messagesReceived.closed).toBe(1); + expect(relay.messagesReceived.notice).toBe(1); + expect(relay.messagesReceived.ok).toBe(1); + expect(relay.messagesReceived.auth).toBe(1); + }); + + it("tracks decrypt success/failure", () => { + const metrics = createMetrics(); + + metrics.emit("decrypt.success"); + metrics.emit("decrypt.success"); + metrics.emit("decrypt.failure"); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.decrypt.success).toBe(2); + expect(snapshot.decrypt.failure).toBe(1); + }); + + it("tracks memory gauges (replaces rather than accumulates)", () => { + const metrics = createMetrics(); + + metrics.emit("memory.seen_tracker_size", 100); + metrics.emit("memory.seen_tracker_size", 150); + metrics.emit("memory.seen_tracker_size", 125); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.memory.seenTrackerSize).toBe(125); // Last value, not sum + }); + + it("reset clears all counters", () => { + const metrics = createMetrics(); + + metrics.emit("event.received"); + metrics.emit("event.processed"); + metrics.emit("relay.connect", 1, { relay: "wss://relay.com" }); + + metrics.reset(); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(0); + expect(snapshot.eventsProcessed).toBe(0); + expect(Object.keys(snapshot.relays)).toHaveLength(0); + }); + }); + + describe("createNoopMetrics", () => { + it("does not throw on emit", () => { + const metrics = createNoopMetrics(); + + expect(() => { + metrics.emit("event.received"); + metrics.emit("relay.connect", 1, { relay: "wss://relay.com" }); + }).not.toThrow(); + }); + + it("returns empty snapshot", () => { + const metrics = createNoopMetrics(); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(0); + expect(snapshot.eventsProcessed).toBe(0); + }); + }); +}); + +// ============================================================================ +// Circuit Breaker Behavior Tests +// ============================================================================ + +describe("Circuit Breaker Behavior", () => { + // Test the circuit breaker logic through metrics emissions + it("emits circuit breaker metrics in correct sequence", () => { + const events: MetricEvent[] = []; + const metrics = createMetrics((event) => events.push(event)); + + // Simulate 5 failures -> open + for (let i = 0; i < 5; i++) { + metrics.emit("relay.error", 1, { relay: "wss://relay.com" }); + } + metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" }); + + // Simulate recovery + metrics.emit("relay.circuit_breaker.half_open", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" }); + + const cbEvents = events.filter((e) => e.name.startsWith("relay.circuit_breaker")); + expect(cbEvents).toHaveLength(3); + expect(cbEvents[0].name).toBe("relay.circuit_breaker.open"); + expect(cbEvents[1].name).toBe("relay.circuit_breaker.half_open"); + expect(cbEvents[2].name).toBe("relay.circuit_breaker.close"); + }); +}); + +// ============================================================================ +// Health Scoring Behavior Tests +// ============================================================================ + +describe("Health Scoring", () => { + it("metrics track relay errors for health scoring", () => { + const metrics = createMetrics(); + + // Simulate mixed success/failure pattern + metrics.emit("relay.connect", 1, { relay: "wss://good-relay.com" }); + metrics.emit("relay.connect", 1, { relay: "wss://bad-relay.com" }); + + metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" }); + metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" }); + metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" }); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.relays["wss://good-relay.com"].errors).toBe(0); + expect(snapshot.relays["wss://bad-relay.com"].errors).toBe(3); + }); +}); + +// ============================================================================ +// Reconnect Backoff Tests +// ============================================================================ + +describe("Reconnect Backoff", () => { + it("computes delays within expected bounds", () => { + // Compute expected delays (1s, 2s, 4s, 8s, 16s, 32s, 60s cap) + const BASE = 1000; + const MAX = 60000; + const JITTER = 0.3; + + for (let attempt = 0; attempt < 10; attempt++) { + const exponential = BASE * Math.pow(2, attempt); + const capped = Math.min(exponential, MAX); + const minDelay = capped * (1 - JITTER); + const maxDelay = capped * (1 + JITTER); + + // These are the expected bounds + expect(minDelay).toBeGreaterThanOrEqual(BASE * 0.7); + expect(maxDelay).toBeLessThanOrEqual(MAX * 1.3); + } + }); +}); diff --git a/extensions/nostr/src/nostr-bus.test.ts b/extensions/nostr/src/nostr-bus.test.ts new file mode 100644 index 000000000..1de9fc790 --- /dev/null +++ b/extensions/nostr/src/nostr-bus.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + validatePrivateKey, + getPublicKeyFromPrivate, + isValidPubkey, + normalizePubkey, + pubkeyToNpub, +} from "./nostr-bus.js"; + +// Test private key (DO NOT use in production - this is a known test key) +const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const TEST_NSEC = "nsec1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l"; + +describe("validatePrivateKey", () => { + describe("hex format", () => { + it("accepts valid 64-char hex key", () => { + const result = validatePrivateKey(TEST_HEX_KEY); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + + it("accepts lowercase hex", () => { + const result = validatePrivateKey(TEST_HEX_KEY.toLowerCase()); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it("accepts uppercase hex", () => { + const result = validatePrivateKey(TEST_HEX_KEY.toUpperCase()); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it("accepts mixed case hex", () => { + const mixed = "0123456789ABCdef0123456789abcDEF0123456789abcdef0123456789ABCDEF"; + const result = validatePrivateKey(mixed); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it("trims whitespace", () => { + const result = validatePrivateKey(` ${TEST_HEX_KEY} `); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it("trims newlines", () => { + const result = validatePrivateKey(`${TEST_HEX_KEY}\n`); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it("rejects 63-char hex (too short)", () => { + expect(() => validatePrivateKey(TEST_HEX_KEY.slice(0, 63))).toThrow( + "Private key must be 64 hex characters" + ); + }); + + it("rejects 65-char hex (too long)", () => { + expect(() => validatePrivateKey(TEST_HEX_KEY + "0")).toThrow( + "Private key must be 64 hex characters" + ); + }); + + it("rejects non-hex characters", () => { + const invalid = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg"; // 'g' at end + expect(() => validatePrivateKey(invalid)).toThrow("Private key must be 64 hex characters"); + }); + + it("rejects empty string", () => { + expect(() => validatePrivateKey("")).toThrow("Private key must be 64 hex characters"); + }); + + it("rejects whitespace-only string", () => { + expect(() => validatePrivateKey(" ")).toThrow("Private key must be 64 hex characters"); + }); + + it("rejects key with 0x prefix", () => { + expect(() => validatePrivateKey("0x" + TEST_HEX_KEY)).toThrow( + "Private key must be 64 hex characters" + ); + }); + }); + + describe("nsec format", () => { + it("rejects invalid nsec (wrong checksum)", () => { + const badNsec = "nsec1invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalid"; + expect(() => validatePrivateKey(badNsec)).toThrow(); + }); + + it("rejects npub (wrong type)", () => { + const npub = "npub1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8s5epk55"; + expect(() => validatePrivateKey(npub)).toThrow(); + }); + }); +}); + +describe("isValidPubkey", () => { + describe("hex format", () => { + it("accepts valid 64-char hex pubkey", () => { + const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(isValidPubkey(validHex)).toBe(true); + }); + + it("accepts uppercase hex", () => { + const validHex = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + expect(isValidPubkey(validHex)).toBe(true); + }); + + it("rejects 63-char hex", () => { + const shortHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde"; + expect(isValidPubkey(shortHex)).toBe(false); + }); + + it("rejects 65-char hex", () => { + const longHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0"; + expect(isValidPubkey(longHex)).toBe(false); + }); + + it("rejects non-hex characters", () => { + const invalid = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg"; + expect(isValidPubkey(invalid)).toBe(false); + }); + }); + + describe("npub format", () => { + it("rejects invalid npub", () => { + expect(isValidPubkey("npub1invalid")).toBe(false); + }); + + it("rejects nsec (wrong type)", () => { + expect(isValidPubkey(TEST_NSEC)).toBe(false); + }); + }); + + describe("edge cases", () => { + it("rejects empty string", () => { + expect(isValidPubkey("")).toBe(false); + }); + + it("handles whitespace-padded input", () => { + const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(isValidPubkey(` ${validHex} `)).toBe(true); + }); + }); +}); + +describe("normalizePubkey", () => { + describe("hex format", () => { + it("lowercases hex pubkey", () => { + const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + const result = normalizePubkey(upper); + expect(result).toBe(upper.toLowerCase()); + }); + + it("trims whitespace", () => { + const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(normalizePubkey(` ${hex} `)).toBe(hex); + }); + + it("rejects invalid hex", () => { + expect(() => normalizePubkey("invalid")).toThrow("Pubkey must be 64 hex characters"); + }); + }); +}); + +describe("getPublicKeyFromPrivate", () => { + it("derives public key from hex private key", () => { + const pubkey = getPublicKeyFromPrivate(TEST_HEX_KEY); + expect(pubkey).toMatch(/^[0-9a-f]{64}$/); + expect(pubkey.length).toBe(64); + }); + + it("derives consistent public key", () => { + const pubkey1 = getPublicKeyFromPrivate(TEST_HEX_KEY); + const pubkey2 = getPublicKeyFromPrivate(TEST_HEX_KEY); + expect(pubkey1).toBe(pubkey2); + }); + + it("throws for invalid private key", () => { + expect(() => getPublicKeyFromPrivate("invalid")).toThrow(); + }); +}); + +describe("pubkeyToNpub", () => { + it("converts hex pubkey to npub format", () => { + const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const npub = pubkeyToNpub(hex); + expect(npub).toMatch(/^npub1[a-z0-9]+$/); + }); + + it("produces consistent output", () => { + const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const npub1 = pubkeyToNpub(hex); + const npub2 = pubkeyToNpub(hex); + expect(npub1).toBe(npub2); + }); + + it("normalizes uppercase hex first", () => { + const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const upper = lower.toUpperCase(); + expect(pubkeyToNpub(lower)).toBe(pubkeyToNpub(upper)); + }); +}); diff --git a/extensions/nostr/src/nostr-bus.ts b/extensions/nostr/src/nostr-bus.ts new file mode 100644 index 000000000..25ae6f082 --- /dev/null +++ b/extensions/nostr/src/nostr-bus.ts @@ -0,0 +1,741 @@ +import { + SimplePool, + finalizeEvent, + getPublicKey, + verifyEvent, + nip19, + type Event, +} from "nostr-tools"; +import { decrypt, encrypt } from "nostr-tools/nip04"; + +import { + readNostrBusState, + writeNostrBusState, + computeSinceTimestamp, + readNostrProfileState, + writeNostrProfileState, +} from "./nostr-state-store.js"; +import { + publishProfile as publishProfileFn, + type ProfilePublishResult, +} from "./nostr-profile.js"; +import type { NostrProfile } from "./config-schema.js"; +import { createSeenTracker, type SeenTracker } from "./seen-tracker.js"; +import { + createMetrics, + createNoopMetrics, + type NostrMetrics, + type MetricsSnapshot, + type MetricEvent, +} from "./metrics.js"; + +export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"]; + +// ============================================================================ +// Constants +// ============================================================================ + +const STARTUP_LOOKBACK_SEC = 120; // tolerate relay lag / clock skew +const MAX_PERSISTED_EVENT_IDS = 5000; +const STATE_PERSIST_DEBOUNCE_MS = 5000; // Debounce state writes + +// Reconnect configuration (exponential backoff with jitter) +const RECONNECT_BASE_MS = 1000; // 1 second base +const RECONNECT_MAX_MS = 60000; // 60 seconds max +const RECONNECT_JITTER = 0.3; // ±30% jitter + +// Circuit breaker configuration +const CIRCUIT_BREAKER_THRESHOLD = 5; // failures before opening +const CIRCUIT_BREAKER_RESET_MS = 30000; // 30 seconds before half-open + +// Health tracker configuration +const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats + +// ============================================================================ +// Types +// ============================================================================ + +export interface NostrBusOptions { + /** Private key in hex or nsec format */ + privateKey: string; + /** WebSocket relay URLs (defaults to damus + nos.lol) */ + relays?: string[]; + /** Account ID for state persistence (optional, defaults to pubkey prefix) */ + accountId?: string; + /** Called when a DM is received */ + onMessage: ( + pubkey: string, + text: string, + reply: (text: string) => Promise + ) => Promise; + /** Called on errors (optional) */ + onError?: (error: Error, context: string) => void; + /** Called on connection status changes (optional) */ + onConnect?: (relay: string) => void; + /** Called on disconnection (optional) */ + onDisconnect?: (relay: string) => void; + /** Called on EOSE (end of stored events) for initial sync (optional) */ + onEose?: (relay: string) => void; + /** Called on each metric event (optional) */ + onMetric?: (event: MetricEvent) => void; + /** Maximum entries in seen tracker (default: 100,000) */ + maxSeenEntries?: number; + /** Seen tracker TTL in ms (default: 1 hour) */ + seenTtlMs?: number; +} + +export interface NostrBusHandle { + /** Stop the bus and close connections */ + close: () => void; + /** Get the bot's public key */ + publicKey: string; + /** Send a DM to a pubkey */ + sendDm: (toPubkey: string, text: string) => Promise; + /** Get current metrics snapshot */ + getMetrics: () => MetricsSnapshot; + /** Publish a profile (kind:0) to all relays */ + publishProfile: (profile: NostrProfile) => Promise; + /** Get the last profile publish state */ + getProfileState: () => Promise<{ + lastPublishedAt: number | null; + lastPublishedEventId: string | null; + lastPublishResults: Record | null; + }>; +} + +// ============================================================================ +// Circuit Breaker +// ============================================================================ + +interface CircuitBreakerState { + state: "closed" | "open" | "half_open"; + failures: number; + lastFailure: number; + lastSuccess: number; +} + +interface CircuitBreaker { + /** Check if requests should be allowed */ + canAttempt: () => boolean; + /** Record a success */ + recordSuccess: () => void; + /** Record a failure */ + recordFailure: () => void; + /** Get current state */ + getState: () => CircuitBreakerState["state"]; +} + +function createCircuitBreaker( + relay: string, + metrics: NostrMetrics, + threshold: number = CIRCUIT_BREAKER_THRESHOLD, + resetMs: number = CIRCUIT_BREAKER_RESET_MS +): CircuitBreaker { + const state: CircuitBreakerState = { + state: "closed", + failures: 0, + lastFailure: 0, + lastSuccess: Date.now(), + }; + + return { + canAttempt(): boolean { + if (state.state === "closed") return true; + + if (state.state === "open") { + // Check if enough time has passed to try half-open + if (Date.now() - state.lastFailure >= resetMs) { + state.state = "half_open"; + metrics.emit("relay.circuit_breaker.half_open", 1, { relay }); + return true; + } + return false; + } + + // half_open: allow one attempt + return true; + }, + + recordSuccess(): void { + if (state.state === "half_open") { + state.state = "closed"; + state.failures = 0; + metrics.emit("relay.circuit_breaker.close", 1, { relay }); + } else if (state.state === "closed") { + state.failures = 0; + } + state.lastSuccess = Date.now(); + }, + + recordFailure(): void { + state.failures++; + state.lastFailure = Date.now(); + + if (state.state === "half_open") { + state.state = "open"; + metrics.emit("relay.circuit_breaker.open", 1, { relay }); + } else if (state.state === "closed" && state.failures >= threshold) { + state.state = "open"; + metrics.emit("relay.circuit_breaker.open", 1, { relay }); + } + }, + + getState(): CircuitBreakerState["state"] { + return state.state; + }, + }; +} + +// ============================================================================ +// Relay Health Tracker +// ============================================================================ + +interface RelayHealthStats { + successCount: number; + failureCount: number; + latencySum: number; + latencyCount: number; + lastSuccess: number; + lastFailure: number; +} + +interface RelayHealthTracker { + /** Record a successful operation */ + recordSuccess: (relay: string, latencyMs: number) => void; + /** Record a failed operation */ + recordFailure: (relay: string) => void; + /** Get health score (0-1, higher is better) */ + getScore: (relay: string) => number; + /** Get relays sorted by health (best first) */ + getSortedRelays: (relays: string[]) => string[]; +} + +function createRelayHealthTracker(): RelayHealthTracker { + const stats = new Map(); + + function getOrCreate(relay: string): RelayHealthStats { + let s = stats.get(relay); + if (!s) { + s = { + successCount: 0, + failureCount: 0, + latencySum: 0, + latencyCount: 0, + lastSuccess: 0, + lastFailure: 0, + }; + stats.set(relay, s); + } + return s; + } + + return { + recordSuccess(relay: string, latencyMs: number): void { + const s = getOrCreate(relay); + s.successCount++; + s.latencySum += latencyMs; + s.latencyCount++; + s.lastSuccess = Date.now(); + }, + + recordFailure(relay: string): void { + const s = getOrCreate(relay); + s.failureCount++; + s.lastFailure = Date.now(); + }, + + getScore(relay: string): number { + const s = stats.get(relay); + if (!s) return 0.5; // Unknown relay gets neutral score + + const total = s.successCount + s.failureCount; + if (total === 0) return 0.5; + + // Success rate (0-1) + const successRate = s.successCount / total; + + // Recency bonus (prefer recently successful relays) + const now = Date.now(); + const recencyBonus = + s.lastSuccess > s.lastFailure + ? Math.max(0, 1 - (now - s.lastSuccess) / HEALTH_WINDOW_MS) * 0.2 + : 0; + + // Latency penalty (lower is better) + const avgLatency = + s.latencyCount > 0 ? s.latencySum / s.latencyCount : 1000; + const latencyPenalty = Math.min(0.2, avgLatency / 10000); + + return Math.max(0, Math.min(1, successRate + recencyBonus - latencyPenalty)); + }, + + getSortedRelays(relays: string[]): string[] { + return [...relays].sort((a, b) => this.getScore(b) - this.getScore(a)); + }, + }; +} + +// ============================================================================ +// Reconnect with Exponential Backoff + Jitter +// ============================================================================ + +function computeReconnectDelay(attempt: number): number { + // Exponential backoff: base * 2^attempt + const exponential = RECONNECT_BASE_MS * Math.pow(2, attempt); + const capped = Math.min(exponential, RECONNECT_MAX_MS); + + // Add jitter: ±JITTER% + const jitter = capped * RECONNECT_JITTER * (Math.random() * 2 - 1); + return Math.max(RECONNECT_BASE_MS, capped + jitter); +} + +// ============================================================================ +// Key Validation +// ============================================================================ + +/** + * Validate and normalize a private key (accepts hex or nsec format) + */ +export function validatePrivateKey(key: string): Uint8Array { + const trimmed = key.trim(); + + // Handle nsec (bech32) format + if (trimmed.startsWith("nsec1")) { + const decoded = nip19.decode(trimmed); + if (decoded.type !== "nsec") { + throw new Error("Invalid nsec key: wrong type"); + } + return decoded.data; + } + + // Handle hex format + if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { + throw new Error( + "Private key must be 64 hex characters or nsec bech32 format" + ); + } + + // Convert hex string to Uint8Array + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + bytes[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Get public key from private key (hex or nsec format) + */ +export function getPublicKeyFromPrivate(privateKey: string): string { + const sk = validatePrivateKey(privateKey); + return getPublicKey(sk); +} + +// ============================================================================ +// Main Bus +// ============================================================================ + +/** + * Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs + */ +export async function startNostrBus( + options: NostrBusOptions +): Promise { + const { + privateKey, + relays = DEFAULT_RELAYS, + onMessage, + onError, + onEose, + onMetric, + maxSeenEntries = 100_000, + seenTtlMs = 60 * 60 * 1000, + } = options; + + const sk = validatePrivateKey(privateKey); + const pk = getPublicKey(sk); + const pool = new SimplePool(); + const accountId = options.accountId ?? pk.slice(0, 16); + const gatewayStartedAt = Math.floor(Date.now() / 1000); + + // Initialize metrics + const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics(); + + // Initialize seen tracker with LRU + const seen: SeenTracker = createSeenTracker({ + maxEntries: maxSeenEntries, + ttlMs: seenTtlMs, + }); + + // Initialize circuit breakers and health tracker + const circuitBreakers = new Map(); + const healthTracker = createRelayHealthTracker(); + + for (const relay of relays) { + circuitBreakers.set(relay, createCircuitBreaker(relay, metrics)); + } + + // Read persisted state and compute `since` timestamp (with small overlap) + const state = await readNostrBusState({ accountId }); + const baseSince = computeSinceTimestamp(state, gatewayStartedAt); + const since = Math.max(0, baseSince - STARTUP_LOOKBACK_SEC); + + // Seed in-memory dedupe with recent IDs from disk (prevents restart replay) + if (state?.recentEventIds?.length) { + seen.seed(state.recentEventIds); + } + + // Persist startup timestamp + await writeNostrBusState({ + accountId, + lastProcessedAt: state?.lastProcessedAt ?? gatewayStartedAt, + gatewayStartedAt, + recentEventIds: state?.recentEventIds ?? [], + }); + + // Debounced state persistence + let pendingWrite: ReturnType | undefined; + let lastProcessedAt = state?.lastProcessedAt ?? gatewayStartedAt; + let recentEventIds = (state?.recentEventIds ?? []).slice( + -MAX_PERSISTED_EVENT_IDS + ); + + function scheduleStatePersist(eventCreatedAt: number, eventId: string): void { + lastProcessedAt = Math.max(lastProcessedAt, eventCreatedAt); + recentEventIds.push(eventId); + if (recentEventIds.length > MAX_PERSISTED_EVENT_IDS) { + recentEventIds = recentEventIds.slice(-MAX_PERSISTED_EVENT_IDS); + } + + if (pendingWrite) clearTimeout(pendingWrite); + pendingWrite = setTimeout(() => { + writeNostrBusState({ + accountId, + lastProcessedAt, + gatewayStartedAt, + recentEventIds, + }).catch((err) => onError?.(err as Error, "persist state")); + }, STATE_PERSIST_DEBOUNCE_MS); + } + + const inflight = new Set(); + + // Event handler + async function handleEvent(event: Event): Promise { + try { + metrics.emit("event.received"); + + // Fast dedupe check (handles relay reconnections) + if (seen.peek(event.id) || inflight.has(event.id)) { + metrics.emit("event.duplicate"); + return; + } + inflight.add(event.id); + + // Self-message loop prevention: skip our own messages + if (event.pubkey === pk) { + metrics.emit("event.rejected.self_message"); + return; + } + + // Skip events older than our `since` (relay may ignore filter) + if (event.created_at < since) { + metrics.emit("event.rejected.stale"); + return; + } + + // Fast p-tag check BEFORE crypto (no allocation, cheaper) + let targetsUs = false; + for (const t of event.tags) { + if (t[0] === "p" && t[1] === pk) { + targetsUs = true; + break; + } + } + if (!targetsUs) { + metrics.emit("event.rejected.wrong_kind"); + return; + } + + // Verify signature (must pass before we trust the event) + if (!verifyEvent(event)) { + metrics.emit("event.rejected.invalid_signature"); + onError?.(new Error("Invalid signature"), `event ${event.id}`); + return; + } + + // Mark seen AFTER verify (don't cache invalid IDs) + seen.add(event.id); + metrics.emit("memory.seen_tracker_size", seen.size()); + + // Decrypt the message + let plaintext: string; + try { + plaintext = await decrypt(sk, event.pubkey, event.content); + metrics.emit("decrypt.success"); + } catch (err) { + metrics.emit("decrypt.failure"); + metrics.emit("event.rejected.decrypt_failed"); + onError?.(err as Error, `decrypt from ${event.pubkey}`); + return; + } + + // Create reply function (try relays by health score) + const replyTo = async (text: string): Promise => { + await sendEncryptedDm( + pool, + sk, + event.pubkey, + text, + relays, + metrics, + circuitBreakers, + healthTracker, + onError + ); + }; + + // Call the message handler + await onMessage(event.pubkey, plaintext, replyTo); + + // Mark as processed + metrics.emit("event.processed"); + + // Persist progress (debounced) + scheduleStatePersist(event.created_at, event.id); + } catch (err) { + onError?.(err as Error, `event ${event.id}`); + } finally { + inflight.delete(event.id); + } + } + + const sub = pool.subscribeMany( + relays, + [{ kinds: [4], "#p": [pk], since }], + { + onevent: handleEvent, + oneose: () => { + // EOSE handler - called when all stored events have been received + for (const relay of relays) { + metrics.emit("relay.message.eose", 1, { relay }); + } + onEose?.(relays.join(", ")); + }, + onclose: (reason) => { + // Handle subscription close + for (const relay of relays) { + metrics.emit("relay.message.closed", 1, { relay }); + options.onDisconnect?.(relay); + } + onError?.( + new Error(`Subscription closed: ${reason}`), + "subscription" + ); + }, + } + ); + + // Public sendDm function + const sendDm = async (toPubkey: string, text: string): Promise => { + await sendEncryptedDm( + pool, + sk, + toPubkey, + text, + relays, + metrics, + circuitBreakers, + healthTracker, + onError + ); + }; + + // Profile publishing function + const publishProfile = async (profile: NostrProfile): Promise => { + // Read last published timestamp for monotonic ordering + const profileState = await readNostrProfileState({ accountId }); + const lastPublishedAt = profileState?.lastPublishedAt ?? undefined; + + // Publish the profile + const result = await publishProfileFn(pool, sk, relays, profile, lastPublishedAt); + + // Convert results to state format + const publishResults: Record = {}; + for (const relay of result.successes) { + publishResults[relay] = "ok"; + } + for (const { relay, error } of result.failures) { + publishResults[relay] = error === "timeout" ? "timeout" : "failed"; + } + + // Persist the publish state + await writeNostrProfileState({ + accountId, + lastPublishedAt: result.createdAt, + lastPublishedEventId: result.eventId, + lastPublishResults: publishResults, + }); + + return result; + }; + + // Get profile state function + const getProfileState = async () => { + const state = await readNostrProfileState({ accountId }); + return { + lastPublishedAt: state?.lastPublishedAt ?? null, + lastPublishedEventId: state?.lastPublishedEventId ?? null, + lastPublishResults: state?.lastPublishResults ?? null, + }; + }; + + return { + close: () => { + sub.close(); + seen.stop(); + // Flush pending state write synchronously on close + if (pendingWrite) { + clearTimeout(pendingWrite); + writeNostrBusState({ + accountId, + lastProcessedAt, + gatewayStartedAt, + recentEventIds, + }).catch((err) => onError?.(err as Error, "persist state on close")); + } + }, + publicKey: pk, + sendDm, + getMetrics: () => metrics.getSnapshot(), + publishProfile, + getProfileState, + }; +} + +// ============================================================================ +// Send DM with Circuit Breaker + Health Scoring +// ============================================================================ + +/** + * Send an encrypted DM to a pubkey + */ +async function sendEncryptedDm( + pool: SimplePool, + sk: Uint8Array, + toPubkey: string, + text: string, + relays: string[], + metrics: NostrMetrics, + circuitBreakers: Map, + healthTracker: RelayHealthTracker, + onError?: (error: Error, context: string) => void +): Promise { + const ciphertext = await encrypt(sk, toPubkey, text); + const reply = finalizeEvent( + { + kind: 4, + content: ciphertext, + tags: [["p", toPubkey]], + created_at: Math.floor(Date.now() / 1000), + }, + sk + ); + + // Sort relays by health score (best first) + const sortedRelays = healthTracker.getSortedRelays(relays); + + // Try relays in order of health, respecting circuit breakers + let lastError: Error | undefined; + for (const relay of sortedRelays) { + const cb = circuitBreakers.get(relay); + + // Skip if circuit breaker is open + if (cb && !cb.canAttempt()) { + continue; + } + + const startTime = Date.now(); + try { + await pool.publish([relay], reply); + const latency = Date.now() - startTime; + + // Record success + cb?.recordSuccess(); + healthTracker.recordSuccess(relay, latency); + + return; // Success - exit early + } catch (err) { + lastError = err as Error; + const latency = Date.now() - startTime; + + // Record failure + cb?.recordFailure(); + healthTracker.recordFailure(relay); + metrics.emit("relay.error", 1, { relay, latency }); + + onError?.(lastError, `publish to ${relay}`); + } + } + + throw new Error(`Failed to publish to any relay: ${lastError?.message}`); +} + +// ============================================================================ +// Pubkey Utilities +// ============================================================================ + +/** + * Check if a string looks like a valid Nostr pubkey (hex or npub) + */ +export function isValidPubkey(input: string): boolean { + if (typeof input !== "string") return false; + const trimmed = input.trim(); + + // npub format + if (trimmed.startsWith("npub1")) { + try { + const decoded = nip19.decode(trimmed); + return decoded.type === "npub"; + } catch { + return false; + } + } + + // Hex format + return /^[0-9a-fA-F]{64}$/.test(trimmed); +} + +/** + * Normalize a pubkey to hex format (accepts npub or hex) + */ +export function normalizePubkey(input: string): string { + const trimmed = input.trim(); + + // npub format - decode to hex + if (trimmed.startsWith("npub1")) { + const decoded = nip19.decode(trimmed); + if (decoded.type !== "npub") { + throw new Error("Invalid npub key"); + } + // Convert Uint8Array to hex string + return Array.from(decoded.data) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + + // Already hex - validate and return lowercase + if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { + throw new Error("Pubkey must be 64 hex characters or npub format"); + } + return trimmed.toLowerCase(); +} + +/** + * Convert a hex pubkey to npub format + */ +export function pubkeyToNpub(hexPubkey: string): string { + const normalized = normalizePubkey(hexPubkey); + // npubEncode expects a hex string, not Uint8Array + return nip19.npubEncode(normalized); +} diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts new file mode 100644 index 000000000..deb193efd --- /dev/null +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -0,0 +1,378 @@ +/** + * Tests for Nostr Profile HTTP Handler + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { IncomingMessage, ServerResponse } from "node:http"; +import { Socket } from "node:net"; + +import { + createNostrProfileHttpHandler, + type NostrProfileHttpContext, +} from "./nostr-profile-http.js"; + +// Mock the channel exports +vi.mock("./channel.js", () => ({ + publishNostrProfile: vi.fn(), + getNostrProfileState: vi.fn(), +})); + +// Mock the import module +vi.mock("./nostr-profile-import.js", () => ({ + importProfileFromRelays: vi.fn(), + mergeProfiles: vi.fn((local, imported) => ({ ...imported, ...local })), +})); + +import { publishNostrProfile, getNostrProfileState } from "./channel.js"; +import { importProfileFromRelays } from "./nostr-profile-import.js"; + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function createMockRequest( + method: string, + url: string, + body?: unknown +): IncomingMessage { + const socket = new Socket(); + const req = new IncomingMessage(socket); + req.method = method; + req.url = url; + req.headers = { host: "localhost:3000" }; + + if (body) { + const bodyStr = JSON.stringify(body); + process.nextTick(() => { + req.emit("data", Buffer.from(bodyStr)); + req.emit("end"); + }); + } else { + process.nextTick(() => { + req.emit("end"); + }); + } + + return req; +} + +function createMockResponse(): ServerResponse & { _getData: () => string; _getStatusCode: () => number } { + const socket = new Socket(); + const res = new ServerResponse({} as IncomingMessage); + + let data = ""; + let statusCode = 200; + + res.write = function (chunk: unknown) { + data += String(chunk); + return true; + }; + + res.end = function (chunk?: unknown) { + if (chunk) data += String(chunk); + return this; + }; + + Object.defineProperty(res, "statusCode", { + get: () => statusCode, + set: (code: number) => { + statusCode = code; + }, + }); + + (res as unknown as { _getData: () => string })._getData = () => data; + (res as unknown as { _getStatusCode: () => number })._getStatusCode = () => statusCode; + + return res as ServerResponse & { _getData: () => string; _getStatusCode: () => number }; +} + +function createMockContext(overrides?: Partial): NostrProfileHttpContext { + return { + getConfigProfile: vi.fn().mockReturnValue(undefined), + updateConfigProfile: vi.fn().mockResolvedValue(undefined), + getAccountInfo: vi.fn().mockReturnValue({ + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + relays: ["wss://relay.damus.io"], + }), + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + ...overrides, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe("nostr-profile-http", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("route matching", () => { + it("returns false for non-nostr paths", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("GET", "/api/channels/telegram/profile"); + const res = createMockResponse(); + + const result = await handler(req, res); + + expect(result).toBe(false); + }); + + it("returns false for paths without accountId", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("GET", "/api/channels/nostr/"); + const res = createMockResponse(); + + const result = await handler(req, res); + + expect(result).toBe(false); + }); + + it("handles /api/channels/nostr/:accountId/profile", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("GET", "/api/channels/nostr/default/profile"); + const res = createMockResponse(); + + vi.mocked(getNostrProfileState).mockResolvedValue(null); + + const result = await handler(req, res); + + expect(result).toBe(true); + }); + }); + + describe("GET /api/channels/nostr/:accountId/profile", () => { + it("returns profile and publish state", async () => { + const ctx = createMockContext({ + getConfigProfile: vi.fn().mockReturnValue({ + name: "testuser", + displayName: "Test User", + }), + }); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("GET", "/api/channels/nostr/default/profile"); + const res = createMockResponse(); + + vi.mocked(getNostrProfileState).mockResolvedValue({ + lastPublishedAt: 1234567890, + lastPublishedEventId: "abc123", + lastPublishResults: { "wss://relay.damus.io": "ok" }, + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(true); + expect(data.profile.name).toBe("testuser"); + expect(data.publishState.lastPublishedAt).toBe(1234567890); + }); + }); + + describe("PUT /api/channels/nostr/:accountId/profile", () => { + it("validates profile and publishes", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "satoshi", + displayName: "Satoshi Nakamoto", + about: "Creator of Bitcoin", + }); + const res = createMockResponse(); + + vi.mocked(publishNostrProfile).mockResolvedValue({ + eventId: "event123", + createdAt: 1234567890, + successes: ["wss://relay.damus.io"], + failures: [], + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(true); + expect(data.eventId).toBe("event123"); + expect(data.successes).toContain("wss://relay.damus.io"); + expect(data.persisted).toBe(true); + expect(ctx.updateConfigProfile).toHaveBeenCalled(); + }); + + it("rejects private IP in picture URL (SSRF protection)", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "hacker", + picture: "https://127.0.0.1/evil.jpg", + }); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + expect(data.error).toContain("private"); + }); + + it("rejects non-https URLs", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "test", + picture: "http://example.com/pic.jpg", + }); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + // The schema validation catches non-https URLs before SSRF check + expect(data.error).toBe("Validation failed"); + expect(data.details).toBeDefined(); + expect(data.details.some((d: string) => d.includes("https"))).toBe(true); + }); + + it("does not persist if all relays fail", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "test", + }); + const res = createMockResponse(); + + vi.mocked(publishNostrProfile).mockResolvedValue({ + eventId: "event123", + createdAt: 1234567890, + successes: [], + failures: [{ relay: "wss://relay.damus.io", error: "timeout" }], + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.persisted).toBe(false); + expect(ctx.updateConfigProfile).not.toHaveBeenCalled(); + }); + + it("enforces rate limiting", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + + vi.mocked(publishNostrProfile).mockResolvedValue({ + eventId: "event123", + createdAt: 1234567890, + successes: ["wss://relay.damus.io"], + failures: [], + }); + + // Make 6 requests (limit is 5/min) + for (let i = 0; i < 6; i++) { + const req = createMockRequest("PUT", "/api/channels/nostr/rate-test/profile", { + name: `user${i}`, + }); + const res = createMockResponse(); + await handler(req, res); + + if (i < 5) { + expect(res._getStatusCode()).toBe(200); + } else { + expect(res._getStatusCode()).toBe(429); + const data = JSON.parse(res._getData()); + expect(data.error).toContain("Rate limit"); + } + } + }); + }); + + describe("POST /api/channels/nostr/:accountId/profile/import", () => { + it("imports profile from relays", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {}); + const res = createMockResponse(); + + vi.mocked(importProfileFromRelays).mockResolvedValue({ + ok: true, + profile: { + name: "imported", + displayName: "Imported User", + }, + event: { + id: "evt123", + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + created_at: 1234567890, + }, + relaysQueried: ["wss://relay.damus.io"], + sourceRelay: "wss://relay.damus.io", + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(true); + expect(data.imported.name).toBe("imported"); + expect(data.saved).toBe(false); // autoMerge not requested + }); + + it("auto-merges when requested", async () => { + const ctx = createMockContext({ + getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }), + }); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", { + autoMerge: true, + }); + const res = createMockResponse(); + + vi.mocked(importProfileFromRelays).mockResolvedValue({ + ok: true, + profile: { + name: "imported", + displayName: "Imported User", + }, + event: { + id: "evt123", + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + created_at: 1234567890, + }, + relaysQueried: ["wss://relay.damus.io"], + sourceRelay: "wss://relay.damus.io", + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.saved).toBe(true); + expect(ctx.updateConfigProfile).toHaveBeenCalled(); + }); + + it("returns error when account not found", async () => { + const ctx = createMockContext({ + getAccountInfo: vi.fn().mockReturnValue(null), + }); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("POST", "/api/channels/nostr/unknown/profile/import", {}); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(404); + const data = JSON.parse(res._getData()); + expect(data.error).toContain("not found"); + }); + }); +}); diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts new file mode 100644 index 000000000..23caeb0bf --- /dev/null +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -0,0 +1,500 @@ +/** + * Nostr Profile HTTP Handler + * + * Handles HTTP requests for profile management: + * - PUT /api/channels/nostr/:accountId/profile - Update and publish profile + * - POST /api/channels/nostr/:accountId/profile/import - Import from relays + * - GET /api/channels/nostr/:accountId/profile - Get current profile state + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import { z } from "zod"; + +import { NostrProfileSchema, type NostrProfile } from "./config-schema.js"; +import { publishNostrProfile, getNostrProfileState } from "./channel.js"; +import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface NostrProfileHttpContext { + /** Get current profile from config */ + getConfigProfile: (accountId: string) => NostrProfile | undefined; + /** Update profile in config (after successful publish) */ + updateConfigProfile: (accountId: string, profile: NostrProfile) => Promise; + /** Get account's public key and relays */ + getAccountInfo: (accountId: string) => { pubkey: string; relays: string[] } | null; + /** Logger */ + log?: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + }; +} + +// ============================================================================ +// Rate Limiting +// ============================================================================ + +interface RateLimitEntry { + count: number; + windowStart: number; +} + +const rateLimitMap = new Map(); +const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute +const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute + +function checkRateLimit(accountId: string): boolean { + const now = Date.now(); + const entry = rateLimitMap.get(accountId); + + if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) { + rateLimitMap.set(accountId, { count: 1, windowStart: now }); + return true; + } + + if (entry.count >= RATE_LIMIT_MAX_REQUESTS) { + return false; + } + + entry.count++; + return true; +} + +// ============================================================================ +// Mutex for Concurrent Publish Prevention +// ============================================================================ + +const publishLocks = new Map>(); + +async function withPublishLock(accountId: string, fn: () => Promise): Promise { + // Atomic mutex using promise chaining - prevents TOCTOU race condition + const prev = publishLocks.get(accountId) ?? Promise.resolve(); + let resolve: () => void; + const next = new Promise((r) => { + resolve = r; + }); + // Atomically replace the lock before awaiting - any concurrent request + // will now wait on our `next` promise + publishLocks.set(accountId, next); + + // Wait for previous operation to complete + await prev.catch(() => {}); + + try { + return await fn(); + } finally { + resolve!(); + // Clean up if we're the last in chain + if (publishLocks.get(accountId) === next) { + publishLocks.delete(accountId); + } + } +} + +// ============================================================================ +// SSRF Protection +// ============================================================================ + +// Block common private/internal hostnames (quick string check) +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "localhost.localdomain", + "127.0.0.1", + "::1", + "[::1]", + "0.0.0.0", +]); + +// Check if an IP address (resolved) is in a private range +function isPrivateIp(ip: string): boolean { + // Handle IPv4 + const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (ipv4Match) { + const [, a, b, c] = ipv4Match.map(Number); + // 127.0.0.0/8 (loopback) + if (a === 127) return true; + // 10.0.0.0/8 (private) + if (a === 10) return true; + // 172.16.0.0/12 (private) + if (a === 172 && b >= 16 && b <= 31) return true; + // 192.168.0.0/16 (private) + if (a === 192 && b === 168) return true; + // 169.254.0.0/16 (link-local) + if (a === 169 && b === 254) return true; + // 0.0.0.0/8 + if (a === 0) return true; + return false; + } + + // Handle IPv6 + const ipLower = ip.toLowerCase().replace(/^\[|\]$/g, ""); + // ::1 (loopback) + if (ipLower === "::1") return true; + // fe80::/10 (link-local) + if (ipLower.startsWith("fe80:")) return true; + // fc00::/7 (unique local) + if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) return true; + // ::ffff:x.x.x.x (IPv4-mapped IPv6) - extract and check IPv4 + const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (v4Mapped) return isPrivateIp(v4Mapped[1]); + + return false; +} + +function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } { + try { + const url = new URL(urlStr); + + if (url.protocol !== "https:") { + return { ok: false, error: "URL must use https:// protocol" }; + } + + const hostname = url.hostname.toLowerCase(); + + // Quick hostname block check + if (BLOCKED_HOSTNAMES.has(hostname)) { + return { ok: false, error: "URL must not point to private/internal addresses" }; + } + + // Check if hostname is an IP address directly + if (isPrivateIp(hostname)) { + return { ok: false, error: "URL must not point to private/internal addresses" }; + } + + // Block suspicious TLDs that resolve to localhost + if (hostname.endsWith(".localhost") || hostname.endsWith(".local")) { + return { ok: false, error: "URL must not point to private/internal addresses" }; + } + + return { ok: true }; + } catch { + return { ok: false, error: "Invalid URL format" }; + } +} + +// Export for use in import validation +export { validateUrlSafety } + +// ============================================================================ +// Validation Schemas +// ============================================================================ + +// NIP-05 format: user@domain.com +const nip05FormatSchema = z + .string() + .regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid NIP-05 format (user@domain.com)") + .optional(); + +// LUD-16 Lightning address format: user@domain.com +const lud16FormatSchema = z + .string() + .regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid Lightning address format") + .optional(); + +// Extended profile schema with additional format validation +const ProfileUpdateSchema = NostrProfileSchema.extend({ + nip05: nip05FormatSchema, + lud16: lud16FormatSchema, +}); + +// ============================================================================ +// Request Helpers +// ============================================================================ + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +async function readJsonBody(req: IncomingMessage, maxBytes = 64 * 1024): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + + req.on("data", (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + reject(new Error("Request body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + try { + const body = Buffer.concat(chunks).toString("utf-8"); + resolve(body ? JSON.parse(body) : {}); + } catch { + reject(new Error("Invalid JSON")); + } + }); + + req.on("error", reject); + }); +} + +function parseAccountIdFromPath(pathname: string): string | null { + // Match: /api/channels/nostr/:accountId/profile + const match = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/); + return match?.[1] ?? null; +} + +// ============================================================================ +// HTTP Handler +// ============================================================================ + +export function createNostrProfileHttpHandler( + ctx: NostrProfileHttpContext +): (req: IncomingMessage, res: ServerResponse) => Promise { + return async (req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + + // Only handle /api/channels/nostr/:accountId/profile paths + if (!url.pathname.startsWith("/api/channels/nostr/")) { + return false; + } + + const accountId = parseAccountIdFromPath(url.pathname); + if (!accountId) { + return false; + } + + const isImport = url.pathname.endsWith("/profile/import"); + const isProfilePath = url.pathname.endsWith("/profile") || isImport; + + if (!isProfilePath) { + return false; + } + + // Handle different HTTP methods + try { + if (req.method === "GET" && !isImport) { + return await handleGetProfile(accountId, ctx, res); + } + + if (req.method === "PUT" && !isImport) { + return await handleUpdateProfile(accountId, ctx, req, res); + } + + if (req.method === "POST" && isImport) { + return await handleImportProfile(accountId, ctx, req, res); + } + + // Method not allowed + sendJson(res, 405, { ok: false, error: "Method not allowed" }); + return true; + } catch (err) { + ctx.log?.error(`Profile HTTP error: ${String(err)}`); + sendJson(res, 500, { ok: false, error: "Internal server error" }); + return true; + } + }; +} + +// ============================================================================ +// GET /api/channels/nostr/:accountId/profile +// ============================================================================ + +async function handleGetProfile( + accountId: string, + ctx: NostrProfileHttpContext, + res: ServerResponse +): Promise { + const configProfile = ctx.getConfigProfile(accountId); + const publishState = await getNostrProfileState(accountId); + + sendJson(res, 200, { + ok: true, + profile: configProfile ?? null, + publishState: publishState ?? null, + }); + return true; +} + +// ============================================================================ +// PUT /api/channels/nostr/:accountId/profile +// ============================================================================ + +async function handleUpdateProfile( + accountId: string, + ctx: NostrProfileHttpContext, + req: IncomingMessage, + res: ServerResponse +): Promise { + // Rate limiting + if (!checkRateLimit(accountId)) { + sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" }); + return true; + } + + // Parse body + let body: unknown; + try { + body = await readJsonBody(req); + } catch (err) { + sendJson(res, 400, { ok: false, error: String(err) }); + return true; + } + + // Validate profile + const parseResult = ProfileUpdateSchema.safeParse(body); + if (!parseResult.success) { + const errors = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`); + sendJson(res, 400, { ok: false, error: "Validation failed", details: errors }); + return true; + } + + const profile = parseResult.data; + + // SSRF check for picture URL + if (profile.picture) { + const pictureCheck = validateUrlSafety(profile.picture); + if (!pictureCheck.ok) { + sendJson(res, 400, { ok: false, error: `picture: ${pictureCheck.error}` }); + return true; + } + } + + // SSRF check for banner URL + if (profile.banner) { + const bannerCheck = validateUrlSafety(profile.banner); + if (!bannerCheck.ok) { + sendJson(res, 400, { ok: false, error: `banner: ${bannerCheck.error}` }); + return true; + } + } + + // SSRF check for website URL + if (profile.website) { + const websiteCheck = validateUrlSafety(profile.website); + if (!websiteCheck.ok) { + sendJson(res, 400, { ok: false, error: `website: ${websiteCheck.error}` }); + return true; + } + } + + // Merge with existing profile to preserve unknown fields + const existingProfile = ctx.getConfigProfile(accountId) ?? {}; + const mergedProfile: NostrProfile = { + ...existingProfile, + ...profile, + }; + + // Publish with mutex to prevent concurrent publishes + try { + const result = await withPublishLock(accountId, async () => { + return await publishNostrProfile(accountId, mergedProfile); + }); + + // Only persist if at least one relay succeeded + if (result.successes.length > 0) { + await ctx.updateConfigProfile(accountId, mergedProfile); + ctx.log?.info(`[${accountId}] Profile published to ${result.successes.length} relay(s)`); + } else { + ctx.log?.warn(`[${accountId}] Profile publish failed on all relays`); + } + + sendJson(res, 200, { + ok: true, + eventId: result.eventId, + createdAt: result.createdAt, + successes: result.successes, + failures: result.failures, + persisted: result.successes.length > 0, + }); + } catch (err) { + ctx.log?.error(`[${accountId}] Profile publish error: ${String(err)}`); + sendJson(res, 500, { ok: false, error: `Publish failed: ${String(err)}` }); + } + + return true; +} + +// ============================================================================ +// POST /api/channels/nostr/:accountId/profile/import +// ============================================================================ + +async function handleImportProfile( + accountId: string, + ctx: NostrProfileHttpContext, + req: IncomingMessage, + res: ServerResponse +): Promise { + // Get account info + const accountInfo = ctx.getAccountInfo(accountId); + if (!accountInfo) { + sendJson(res, 404, { ok: false, error: `Account not found: ${accountId}` }); + return true; + } + + const { pubkey, relays } = accountInfo; + + if (!pubkey) { + sendJson(res, 400, { ok: false, error: "Account has no public key configured" }); + return true; + } + + // Parse options from body + let autoMerge = false; + try { + const body = await readJsonBody(req); + if (typeof body === "object" && body !== null) { + autoMerge = (body as { autoMerge?: boolean }).autoMerge === true; + } + } catch { + // Ignore body parse errors - use defaults + } + + ctx.log?.info(`[${accountId}] Importing profile for ${pubkey.slice(0, 8)}...`); + + // Import from relays + const result = await importProfileFromRelays({ + pubkey, + relays, + timeoutMs: 10_000, // 10 seconds for import + }); + + if (!result.ok) { + sendJson(res, 200, { + ok: false, + error: result.error, + relaysQueried: result.relaysQueried, + }); + return true; + } + + // If autoMerge is requested, merge and save + if (autoMerge && result.profile) { + const localProfile = ctx.getConfigProfile(accountId); + const merged = mergeProfiles(localProfile, result.profile); + await ctx.updateConfigProfile(accountId, merged); + ctx.log?.info(`[${accountId}] Profile imported and merged`); + + sendJson(res, 200, { + ok: true, + imported: result.profile, + merged, + saved: true, + event: result.event, + sourceRelay: result.sourceRelay, + relaysQueried: result.relaysQueried, + }); + return true; + } + + // Otherwise, just return the imported profile for review + sendJson(res, 200, { + ok: true, + imported: result.profile, + saved: false, + event: result.event, + sourceRelay: result.sourceRelay, + relaysQueried: result.relaysQueried, + }); + return true; +} diff --git a/extensions/nostr/src/nostr-profile-import.test.ts b/extensions/nostr/src/nostr-profile-import.test.ts new file mode 100644 index 000000000..74b9deacf --- /dev/null +++ b/extensions/nostr/src/nostr-profile-import.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for Nostr Profile Import + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { mergeProfiles, type ProfileImportOptions } from "./nostr-profile-import.js"; +import type { NostrProfile } from "./config-schema.js"; + +// Note: importProfileFromRelays requires real network calls or complex mocking +// of nostr-tools SimplePool, so we focus on unit testing mergeProfiles + +describe("nostr-profile-import", () => { + describe("mergeProfiles", () => { + it("returns empty object when both are undefined", () => { + const result = mergeProfiles(undefined, undefined); + expect(result).toEqual({}); + }); + + it("returns imported when local is undefined", () => { + const imported: NostrProfile = { + name: "imported", + displayName: "Imported User", + about: "Bio from relay", + }; + const result = mergeProfiles(undefined, imported); + expect(result).toEqual(imported); + }); + + it("returns local when imported is undefined", () => { + const local: NostrProfile = { + name: "local", + displayName: "Local User", + }; + const result = mergeProfiles(local, undefined); + expect(result).toEqual(local); + }); + + it("prefers local values over imported", () => { + const local: NostrProfile = { + name: "localname", + about: "Local bio", + }; + const imported: NostrProfile = { + name: "importedname", + displayName: "Imported Display", + about: "Imported bio", + picture: "https://example.com/pic.jpg", + }; + + const result = mergeProfiles(local, imported); + + expect(result.name).toBe("localname"); // local wins + expect(result.displayName).toBe("Imported Display"); // imported fills gap + expect(result.about).toBe("Local bio"); // local wins + expect(result.picture).toBe("https://example.com/pic.jpg"); // imported fills gap + }); + + it("fills all missing fields from imported", () => { + const local: NostrProfile = { + name: "myname", + }; + const imported: NostrProfile = { + name: "theirname", + displayName: "Their Name", + about: "Their bio", + picture: "https://example.com/pic.jpg", + banner: "https://example.com/banner.jpg", + website: "https://example.com", + nip05: "user@example.com", + lud16: "user@getalby.com", + }; + + const result = mergeProfiles(local, imported); + + expect(result.name).toBe("myname"); + expect(result.displayName).toBe("Their Name"); + expect(result.about).toBe("Their bio"); + expect(result.picture).toBe("https://example.com/pic.jpg"); + expect(result.banner).toBe("https://example.com/banner.jpg"); + expect(result.website).toBe("https://example.com"); + expect(result.nip05).toBe("user@example.com"); + expect(result.lud16).toBe("user@getalby.com"); + }); + + it("handles empty strings as falsy (prefers imported)", () => { + const local: NostrProfile = { + name: "", + displayName: "", + }; + const imported: NostrProfile = { + name: "imported", + displayName: "Imported", + }; + + const result = mergeProfiles(local, imported); + + // Empty strings are still strings, so they "win" over imported + // This is JavaScript nullish coalescing behavior + expect(result.name).toBe(""); + expect(result.displayName).toBe(""); + }); + + it("handles null values in local (prefers imported)", () => { + const local: NostrProfile = { + name: undefined, + displayName: undefined, + }; + const imported: NostrProfile = { + name: "imported", + displayName: "Imported", + }; + + const result = mergeProfiles(local, imported); + + expect(result.name).toBe("imported"); + expect(result.displayName).toBe("Imported"); + }); + }); +}); diff --git a/extensions/nostr/src/nostr-profile-import.ts b/extensions/nostr/src/nostr-profile-import.ts new file mode 100644 index 000000000..95ab5242a --- /dev/null +++ b/extensions/nostr/src/nostr-profile-import.ts @@ -0,0 +1,259 @@ +/** + * Nostr Profile Import + * + * Fetches and verifies kind:0 profile events from relays. + * Used to import existing profiles before editing. + */ + +import { SimplePool, verifyEvent, type Event } from "nostr-tools"; + +import { contentToProfile, type ProfileContent } from "./nostr-profile.js"; +import type { NostrProfile } from "./config-schema.js"; +import { validateUrlSafety } from "./nostr-profile-http.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ProfileImportResult { + /** Whether the import was successful */ + ok: boolean; + /** The imported profile (if found and valid) */ + profile?: NostrProfile; + /** The raw event (for advanced users) */ + event?: { + id: string; + pubkey: string; + created_at: number; + }; + /** Error message if import failed */ + error?: string; + /** Which relays responded */ + relaysQueried: string[]; + /** Which relay provided the winning event */ + sourceRelay?: string; +} + +export interface ProfileImportOptions { + /** The public key to fetch profile for */ + pubkey: string; + /** Relay URLs to query */ + relays: string[]; + /** Timeout per relay in milliseconds (default: 5000) */ + timeoutMs?: number; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_TIMEOUT_MS = 5000; + +// ============================================================================ +// Profile Import +// ============================================================================ + +/** + * Sanitize URLs in an imported profile to prevent SSRF attacks. + * Removes any URLs that don't pass SSRF validation. + */ +function sanitizeProfileUrls(profile: NostrProfile): NostrProfile { + const result = { ...profile }; + const urlFields = ["picture", "banner", "website"] as const; + + for (const field of urlFields) { + const value = result[field]; + if (value && typeof value === "string") { + const validation = validateUrlSafety(value); + if (!validation.ok) { + // Remove unsafe URL + delete result[field]; + } + } + } + + return result; +} + +/** + * Fetch the latest kind:0 profile event for a pubkey from relays. + * + * - Queries all relays in parallel + * - Takes the event with the highest created_at + * - Verifies the event signature + * - Parses and returns the profile + */ +export async function importProfileFromRelays( + opts: ProfileImportOptions +): Promise { + const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts; + + if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) { + return { + ok: false, + error: "Invalid pubkey format (must be 64 hex characters)", + relaysQueried: [], + }; + } + + if (relays.length === 0) { + return { + ok: false, + error: "No relays configured", + relaysQueried: [], + }; + } + + const pool = new SimplePool(); + const relaysQueried: string[] = []; + + try { + // Query all relays for kind:0 events from this pubkey + const events: Array<{ event: Event; relay: string }> = []; + + // Create timeout promise + const timeoutPromise = new Promise((resolve) => { + setTimeout(resolve, timeoutMs); + }); + + // Create subscription promise + const subscriptionPromise = new Promise((resolve) => { + let completed = 0; + + for (const relay of relays) { + relaysQueried.push(relay); + + const sub = pool.subscribeMany( + [relay], + [ + { + kinds: [0], + authors: [pubkey], + limit: 1, + }, + ], + { + onevent(event) { + events.push({ event, relay }); + }, + oneose() { + completed++; + if (completed >= relays.length) { + resolve(); + } + }, + onclose() { + completed++; + if (completed >= relays.length) { + resolve(); + } + }, + } + ); + + // Clean up subscription after timeout + setTimeout(() => { + sub.close(); + }, timeoutMs); + } + }); + + // Wait for either all relays to respond or timeout + await Promise.race([subscriptionPromise, timeoutPromise]); + + // No events found + if (events.length === 0) { + return { + ok: false, + error: "No profile found on any relay", + relaysQueried, + }; + } + + // Find the event with the highest created_at (newest wins for replaceable events) + let bestEvent: { event: Event; relay: string } | null = null; + for (const item of events) { + if (!bestEvent || item.event.created_at > bestEvent.event.created_at) { + bestEvent = item; + } + } + + if (!bestEvent) { + return { + ok: false, + error: "No valid profile event found", + relaysQueried, + }; + } + + // Verify the event signature + const isValid = verifyEvent(bestEvent.event); + if (!isValid) { + return { + ok: false, + error: "Profile event has invalid signature", + relaysQueried, + sourceRelay: bestEvent.relay, + }; + } + + // Parse the profile content + let content: ProfileContent; + try { + content = JSON.parse(bestEvent.event.content) as ProfileContent; + } catch { + return { + ok: false, + error: "Profile event has invalid JSON content", + relaysQueried, + sourceRelay: bestEvent.relay, + }; + } + + // Convert to our profile format + const profile = contentToProfile(content); + + // Sanitize URLs from imported profile to prevent SSRF when auto-merging + const sanitizedProfile = sanitizeProfileUrls(profile); + + return { + ok: true, + profile: sanitizedProfile, + event: { + id: bestEvent.event.id, + pubkey: bestEvent.event.pubkey, + created_at: bestEvent.event.created_at, + }, + relaysQueried, + sourceRelay: bestEvent.relay, + }; + } finally { + pool.close(relays); + } +} + +/** + * Merge imported profile with local profile. + * + * Strategy: + * - For each field, prefer local if set, otherwise use imported + * - This preserves user customizations while filling in missing data + */ +export function mergeProfiles( + local: NostrProfile | undefined, + imported: NostrProfile | undefined +): NostrProfile { + if (!imported) return local ?? {}; + if (!local) return imported; + + return { + name: local.name ?? imported.name, + displayName: local.displayName ?? imported.displayName, + about: local.about ?? imported.about, + picture: local.picture ?? imported.picture, + banner: local.banner ?? imported.banner, + website: local.website ?? imported.website, + nip05: local.nip05 ?? imported.nip05, + lud16: local.lud16 ?? imported.lud16, + }; +} diff --git a/extensions/nostr/src/nostr-profile.fuzz.test.ts b/extensions/nostr/src/nostr-profile.fuzz.test.ts new file mode 100644 index 000000000..052999e53 --- /dev/null +++ b/extensions/nostr/src/nostr-profile.fuzz.test.ts @@ -0,0 +1,479 @@ +import { describe, expect, it } from "vitest"; +import { getPublicKey } from "nostr-tools"; +import { + createProfileEvent, + profileToContent, + validateProfile, + sanitizeProfileForDisplay, +} from "./nostr-profile.js"; +import type { NostrProfile } from "./config-schema.js"; + +// Test private key +const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const TEST_SK = new Uint8Array( + TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)) +); + +// ============================================================================ +// Unicode Attack Vectors +// ============================================================================ + +describe("profile unicode attacks", () => { + describe("zero-width characters", () => { + it("handles zero-width space in name", () => { + const profile: NostrProfile = { + name: "test\u200Buser", // Zero-width space + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + // The character should be preserved (not stripped) + expect(result.profile?.name).toBe("test\u200Buser"); + }); + + it("handles zero-width joiner in name", () => { + const profile: NostrProfile = { + name: "test\u200Duser", // Zero-width joiner + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles zero-width non-joiner in about", () => { + const profile: NostrProfile = { + about: "test\u200Cabout", // Zero-width non-joiner + }; + const content = profileToContent(profile); + expect(content.about).toBe("test\u200Cabout"); + }); + }); + + describe("RTL override attacks", () => { + it("handles RTL override in name", () => { + const profile: NostrProfile = { + name: "\u202Eevil\u202C", // Right-to-left override + pop direction + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + + // UI should escape or handle this + const sanitized = sanitizeProfileForDisplay(result.profile!); + expect(sanitized.name).toBeDefined(); + }); + + it("handles bidi embedding in about", () => { + const profile: NostrProfile = { + about: "Normal \u202Breversed\u202C text", // LTR embedding + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + }); + + describe("homoglyph attacks", () => { + it("handles Cyrillic homoglyphs", () => { + const profile: NostrProfile = { + // Cyrillic 'а' (U+0430) looks like Latin 'a' + name: "\u0430dmin", // Fake "admin" + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + // Profile is accepted but apps should be aware + }); + + it("handles Greek homoglyphs", () => { + const profile: NostrProfile = { + // Greek 'ο' (U+03BF) looks like Latin 'o' + name: "b\u03BFt", // Looks like "bot" + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + }); + + describe("combining characters", () => { + it("handles combining diacritics", () => { + const profile: NostrProfile = { + name: "cafe\u0301", // 'e' + combining acute = 'é' + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + expect(result.profile?.name).toBe("cafe\u0301"); + }); + + it("handles excessive combining characters (Zalgo text)", () => { + const zalgo = + "t̷̢̧̨̡̛̛̛͎̩̝̪̲̲̞̠̹̗̩͓̬̱̪̦͙̬̲̤͙̱̫̝̪̱̫̯̬̭̠̖̲̥̖̫̫̤͇̪̣̫̪̖̱̯̣͎̯̲̱̤̪̣̖̲̪̯͓̖̤̫̫̲̱̲̫̲̖̫̪̯̱̱̪̖̯e̶̡̧̨̧̛̛̛̖̪̯̱̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪s̶̨̧̛̛̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯t"; + const profile: NostrProfile = { + name: zalgo.slice(0, 256), // Truncate to fit limit + }; + const result = validateProfile(profile); + // Should be valid but may look weird + expect(result.valid).toBe(true); + }); + }); + + describe("CJK and other scripts", () => { + it("handles Chinese characters", () => { + const profile: NostrProfile = { + name: "中文用户", + about: "我是一个机器人", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles Japanese hiragana and katakana", () => { + const profile: NostrProfile = { + name: "ボット", + about: "これはテストです", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles Korean characters", () => { + const profile: NostrProfile = { + name: "한국어사용자", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles Arabic text", () => { + const profile: NostrProfile = { + name: "مستخدم", + about: "مرحبا بالعالم", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles Hebrew text", () => { + const profile: NostrProfile = { + name: "משתמש", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles Thai text", () => { + const profile: NostrProfile = { + name: "ผู้ใช้", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + }); + + describe("emoji edge cases", () => { + it("handles emoji sequences (ZWJ)", () => { + const profile: NostrProfile = { + name: "👨‍👩‍👧‍👦", // Family emoji using ZWJ + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles flag emojis", () => { + const profile: NostrProfile = { + name: "🇺🇸🇯🇵🇬🇧", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles skin tone modifiers", () => { + const profile: NostrProfile = { + name: "👋🏻👋🏽👋🏿", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + }); +}); + +// ============================================================================ +// XSS Attack Vectors +// ============================================================================ + +describe("profile XSS attacks", () => { + describe("script injection", () => { + it("escapes script tags", () => { + const profile: NostrProfile = { + name: '', + }; + const sanitized = sanitizeProfileForDisplay(profile); + expect(sanitized.name).not.toContain("/script>', + }; + const sanitized = sanitizeProfileForDisplay(profile); + expect(sanitized.about).not.toContain("", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(false); + }); + + it("rejects vbscript: URL", () => { + const profile = { + website: "vbscript:msgbox('xss')", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(false); + }); + + it("rejects file: URL", () => { + const profile = { + picture: "file:///etc/passwd", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(false); + }); + }); + + describe("HTML attribute injection", () => { + it("escapes double quotes in fields", () => { + const profile: NostrProfile = { + name: '" onclick="alert(1)" data-x="', + }; + const sanitized = sanitizeProfileForDisplay(profile); + expect(sanitized.name).toContain("""); + expect(sanitized.name).not.toContain('onclick="alert'); + }); + + it("escapes single quotes in fields", () => { + const profile: NostrProfile = { + name: "' onclick='alert(1)' data-x='", + }; + const sanitized = sanitizeProfileForDisplay(profile); + expect(sanitized.name).toContain("'"); + }); + }); + + describe("CSS injection", () => { + it("escapes style tags", () => { + const profile: NostrProfile = { + about: '', + }; + const sanitized = sanitizeProfileForDisplay(profile); + expect(sanitized.about).toContain("<style>"); + }); + }); +}); + +// ============================================================================ +// Length Boundary Tests +// ============================================================================ + +describe("profile length boundaries", () => { + describe("name field (max 256)", () => { + it("accepts exactly 256 characters", () => { + const result = validateProfile({ name: "a".repeat(256) }); + expect(result.valid).toBe(true); + }); + + it("rejects 257 characters", () => { + const result = validateProfile({ name: "a".repeat(257) }); + expect(result.valid).toBe(false); + }); + + it("accepts empty string", () => { + const result = validateProfile({ name: "" }); + expect(result.valid).toBe(true); + }); + }); + + describe("displayName field (max 256)", () => { + it("accepts exactly 256 characters", () => { + const result = validateProfile({ displayName: "b".repeat(256) }); + expect(result.valid).toBe(true); + }); + + it("rejects 257 characters", () => { + const result = validateProfile({ displayName: "b".repeat(257) }); + expect(result.valid).toBe(false); + }); + }); + + describe("about field (max 2000)", () => { + it("accepts exactly 2000 characters", () => { + const result = validateProfile({ about: "c".repeat(2000) }); + expect(result.valid).toBe(true); + }); + + it("rejects 2001 characters", () => { + const result = validateProfile({ about: "c".repeat(2001) }); + expect(result.valid).toBe(false); + }); + }); + + describe("URL fields", () => { + it("accepts long valid HTTPS URLs", () => { + const longPath = "a".repeat(1000); + const result = validateProfile({ + picture: `https://example.com/${longPath}.png`, + }); + expect(result.valid).toBe(true); + }); + + it("rejects invalid URL format", () => { + const result = validateProfile({ + picture: "not-a-url", + }); + expect(result.valid).toBe(false); + }); + + it("rejects URL without protocol", () => { + const result = validateProfile({ + picture: "example.com/pic.png", + }); + expect(result.valid).toBe(false); + }); + }); +}); + +// ============================================================================ +// Type Confusion Tests +// ============================================================================ + +describe("profile type confusion", () => { + it("rejects number as name", () => { + const result = validateProfile({ name: 123 as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("rejects array as about", () => { + const result = validateProfile({ about: ["hello"] as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("rejects object as picture", () => { + const result = validateProfile({ picture: { url: "https://example.com" } as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("rejects null as name", () => { + const result = validateProfile({ name: null as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("rejects boolean as about", () => { + const result = validateProfile({ about: true as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("rejects function as name", () => { + const result = validateProfile({ name: (() => "test") as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("handles prototype pollution attempt", () => { + const malicious = JSON.parse('{"__proto__": {"polluted": true}}') as unknown; + const result = validateProfile(malicious); + // Should not pollute Object.prototype + expect(({} as Record).polluted).toBeUndefined(); + }); +}); + +// ============================================================================ +// Event Creation Edge Cases +// ============================================================================ + +describe("event creation edge cases", () => { + it("handles profile with all fields at max length", () => { + const profile: NostrProfile = { + name: "a".repeat(256), + displayName: "b".repeat(256), + about: "c".repeat(2000), + nip05: "d".repeat(200) + "@example.com", + lud16: "e".repeat(200) + "@example.com", + }; + + const event = createProfileEvent(TEST_SK, profile); + expect(event.kind).toBe(0); + + // Content should be parseable JSON + expect(() => JSON.parse(event.content)).not.toThrow(); + }); + + it("handles rapid sequential events with monotonic timestamps", () => { + const profile: NostrProfile = { name: "rapid" }; + + // Create events in quick succession + let lastTimestamp = 0; + for (let i = 0; i < 100; i++) { + const event = createProfileEvent(TEST_SK, profile, lastTimestamp); + expect(event.created_at).toBeGreaterThan(lastTimestamp); + lastTimestamp = event.created_at; + } + }); + + it("handles JSON special characters in content", () => { + const profile: NostrProfile = { + name: 'test"user', + about: "line1\nline2\ttab\\backslash", + }; + + const event = createProfileEvent(TEST_SK, profile); + const parsed = JSON.parse(event.content) as { name: string; about: string }; + + expect(parsed.name).toBe('test"user'); + expect(parsed.about).toContain("\n"); + expect(parsed.about).toContain("\t"); + expect(parsed.about).toContain("\\"); + }); +}); diff --git a/extensions/nostr/src/nostr-profile.test.ts b/extensions/nostr/src/nostr-profile.test.ts new file mode 100644 index 000000000..277c605b6 --- /dev/null +++ b/extensions/nostr/src/nostr-profile.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { verifyEvent, getPublicKey } from "nostr-tools"; +import { + createProfileEvent, + profileToContent, + contentToProfile, + validateProfile, + sanitizeProfileForDisplay, + type ProfileContent, +} from "./nostr-profile.js"; +import type { NostrProfile } from "./config-schema.js"; + +// Test private key (DO NOT use in production - this is a known test key) +const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const TEST_SK = new Uint8Array( + TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)) +); +const TEST_PUBKEY = getPublicKey(TEST_SK); + +// ============================================================================ +// Profile Content Conversion Tests +// ============================================================================ + +describe("profileToContent", () => { + it("converts full profile to NIP-01 content format", () => { + const profile: NostrProfile = { + name: "testuser", + displayName: "Test User", + about: "A test user for unit testing", + picture: "https://example.com/avatar.png", + banner: "https://example.com/banner.png", + website: "https://example.com", + nip05: "testuser@example.com", + lud16: "testuser@walletofsatoshi.com", + }; + + const content = profileToContent(profile); + + expect(content.name).toBe("testuser"); + expect(content.display_name).toBe("Test User"); + expect(content.about).toBe("A test user for unit testing"); + expect(content.picture).toBe("https://example.com/avatar.png"); + expect(content.banner).toBe("https://example.com/banner.png"); + expect(content.website).toBe("https://example.com"); + expect(content.nip05).toBe("testuser@example.com"); + expect(content.lud16).toBe("testuser@walletofsatoshi.com"); + }); + + it("omits undefined fields from content", () => { + const profile: NostrProfile = { + name: "minimaluser", + }; + + const content = profileToContent(profile); + + expect(content.name).toBe("minimaluser"); + expect("display_name" in content).toBe(false); + expect("about" in content).toBe(false); + expect("picture" in content).toBe(false); + }); + + it("handles empty profile", () => { + const profile: NostrProfile = {}; + const content = profileToContent(profile); + expect(Object.keys(content)).toHaveLength(0); + }); +}); + +describe("contentToProfile", () => { + it("converts NIP-01 content to profile format", () => { + const content: ProfileContent = { + name: "testuser", + display_name: "Test User", + about: "A test user", + picture: "https://example.com/avatar.png", + nip05: "test@example.com", + }; + + const profile = contentToProfile(content); + + expect(profile.name).toBe("testuser"); + expect(profile.displayName).toBe("Test User"); + expect(profile.about).toBe("A test user"); + expect(profile.picture).toBe("https://example.com/avatar.png"); + expect(profile.nip05).toBe("test@example.com"); + }); + + it("handles empty content", () => { + const content: ProfileContent = {}; + const profile = contentToProfile(content); + expect(Object.keys(profile).filter((k) => profile[k as keyof NostrProfile] !== undefined)).toHaveLength(0); + }); + + it("round-trips profile data", () => { + const original: NostrProfile = { + name: "roundtrip", + displayName: "Round Trip Test", + about: "Testing round-trip conversion", + }; + + const content = profileToContent(original); + const restored = contentToProfile(content); + + expect(restored.name).toBe(original.name); + expect(restored.displayName).toBe(original.displayName); + expect(restored.about).toBe(original.about); + }); +}); + +// ============================================================================ +// Event Creation Tests +// ============================================================================ + +describe("createProfileEvent", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-15T12:00:00Z")); + }); + + it("creates a valid kind:0 event", () => { + const profile: NostrProfile = { + name: "testbot", + about: "A test bot", + }; + + const event = createProfileEvent(TEST_SK, profile); + + expect(event.kind).toBe(0); + expect(event.pubkey).toBe(TEST_PUBKEY); + expect(event.tags).toEqual([]); + expect(event.id).toMatch(/^[0-9a-f]{64}$/); + expect(event.sig).toMatch(/^[0-9a-f]{128}$/); + }); + + it("includes profile content as JSON in event content", () => { + const profile: NostrProfile = { + name: "jsontest", + displayName: "JSON Test User", + about: "Testing JSON serialization", + }; + + const event = createProfileEvent(TEST_SK, profile); + const parsedContent = JSON.parse(event.content) as ProfileContent; + + expect(parsedContent.name).toBe("jsontest"); + expect(parsedContent.display_name).toBe("JSON Test User"); + expect(parsedContent.about).toBe("Testing JSON serialization"); + }); + + it("produces a verifiable signature", () => { + const profile: NostrProfile = { name: "signaturetest" }; + const event = createProfileEvent(TEST_SK, profile); + + expect(verifyEvent(event)).toBe(true); + }); + + it("uses current timestamp when no lastPublishedAt provided", () => { + const profile: NostrProfile = { name: "timestamptest" }; + const event = createProfileEvent(TEST_SK, profile); + + const expectedTimestamp = Math.floor(Date.now() / 1000); + expect(event.created_at).toBe(expectedTimestamp); + }); + + it("ensures monotonic timestamp when lastPublishedAt is in the future", () => { + // Current time is 2024-01-15T12:00:00Z = 1705320000 + const futureTimestamp = 1705320000 + 3600; // 1 hour in the future + const profile: NostrProfile = { name: "monotonictest" }; + + const event = createProfileEvent(TEST_SK, profile, futureTimestamp); + + expect(event.created_at).toBe(futureTimestamp + 1); + }); + + it("uses current time when lastPublishedAt is in the past", () => { + const pastTimestamp = 1705320000 - 3600; // 1 hour in the past + const profile: NostrProfile = { name: "pasttest" }; + + const event = createProfileEvent(TEST_SK, profile, pastTimestamp); + + const expectedTimestamp = Math.floor(Date.now() / 1000); + expect(event.created_at).toBe(expectedTimestamp); + }); + + vi.useRealTimers(); +}); + +// ============================================================================ +// Profile Validation Tests +// ============================================================================ + +describe("validateProfile", () => { + it("validates a correct profile", () => { + const profile = { + name: "validuser", + about: "A valid user", + picture: "https://example.com/pic.png", + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(true); + expect(result.profile).toBeDefined(); + expect(result.errors).toBeUndefined(); + }); + + it("rejects profile with invalid URL", () => { + const profile = { + name: "invalidurl", + picture: "http://insecure.example.com/pic.png", // HTTP not HTTPS + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.some((e) => e.includes("https://"))).toBe(true); + }); + + it("rejects profile with javascript: URL", () => { + const profile = { + name: "xssattempt", + picture: "javascript:alert('xss')", + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(false); + }); + + it("rejects profile with data: URL", () => { + const profile = { + name: "dataurl", + picture: "data:image/png;base64,abc123", + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(false); + }); + + it("rejects name exceeding 256 characters", () => { + const profile = { + name: "a".repeat(257), + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(false); + expect(result.errors!.some((e) => e.includes("256"))).toBe(true); + }); + + it("rejects about exceeding 2000 characters", () => { + const profile = { + about: "a".repeat(2001), + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(false); + expect(result.errors!.some((e) => e.includes("2000"))).toBe(true); + }); + + it("accepts empty profile", () => { + const result = validateProfile({}); + expect(result.valid).toBe(true); + }); + + it("rejects null input", () => { + const result = validateProfile(null); + expect(result.valid).toBe(false); + }); + + it("rejects non-object input", () => { + const result = validateProfile("not an object"); + expect(result.valid).toBe(false); + }); +}); + +// ============================================================================ +// Sanitization Tests +// ============================================================================ + +describe("sanitizeProfileForDisplay", () => { + it("escapes HTML in name field", () => { + const profile: NostrProfile = { + name: "", + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.name).toBe("<script>alert('xss')</script>"); + }); + + it("escapes HTML in about field", () => { + const profile: NostrProfile = { + about: 'Check out ', + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.about).toBe( + 'Check out <img src="x" onerror="alert(1)">' + ); + }); + + it("preserves URLs without modification", () => { + const profile: NostrProfile = { + picture: "https://example.com/pic.png", + website: "https://example.com", + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.picture).toBe("https://example.com/pic.png"); + expect(sanitized.website).toBe("https://example.com"); + }); + + it("handles undefined fields", () => { + const profile: NostrProfile = { + name: "test", + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.name).toBe("test"); + expect(sanitized.about).toBeUndefined(); + expect(sanitized.picture).toBeUndefined(); + }); + + it("escapes ampersands", () => { + const profile: NostrProfile = { + name: "Tom & Jerry", + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.name).toBe("Tom & Jerry"); + }); + + it("escapes quotes", () => { + const profile: NostrProfile = { + about: 'Say "hello" to everyone', + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.about).toBe("Say "hello" to everyone"); + }); +}); + +// ============================================================================ +// Edge Cases +// ============================================================================ + +describe("edge cases", () => { + it("handles emoji in profile fields", () => { + const profile: NostrProfile = { + name: "🤖 Bot", + about: "I am a 🤖 robot! 🎉", + }; + + const content = profileToContent(profile); + expect(content.name).toBe("🤖 Bot"); + expect(content.about).toBe("I am a 🤖 robot! 🎉"); + + const event = createProfileEvent(TEST_SK, profile); + const parsed = JSON.parse(event.content) as ProfileContent; + expect(parsed.name).toBe("🤖 Bot"); + }); + + it("handles unicode in profile fields", () => { + const profile: NostrProfile = { + name: "日本語ユーザー", + about: "Привет мир! 你好世界!", + }; + + const content = profileToContent(profile); + expect(content.name).toBe("日本語ユーザー"); + + const event = createProfileEvent(TEST_SK, profile); + expect(verifyEvent(event)).toBe(true); + }); + + it("handles newlines in about field", () => { + const profile: NostrProfile = { + about: "Line 1\nLine 2\nLine 3", + }; + + const content = profileToContent(profile); + expect(content.about).toBe("Line 1\nLine 2\nLine 3"); + + const event = createProfileEvent(TEST_SK, profile); + const parsed = JSON.parse(event.content) as ProfileContent; + expect(parsed.about).toBe("Line 1\nLine 2\nLine 3"); + }); + + it("handles maximum length fields", () => { + const profile: NostrProfile = { + name: "a".repeat(256), + about: "b".repeat(2000), + }; + + const result = validateProfile(profile); + expect(result.valid).toBe(true); + + const event = createProfileEvent(TEST_SK, profile); + expect(verifyEvent(event)).toBe(true); + }); +}); diff --git a/extensions/nostr/src/nostr-profile.ts b/extensions/nostr/src/nostr-profile.ts new file mode 100644 index 000000000..1dba8e100 --- /dev/null +++ b/extensions/nostr/src/nostr-profile.ts @@ -0,0 +1,242 @@ +/** + * Nostr Profile Management (NIP-01 kind:0) + * + * Profile events are "replaceable" - the latest created_at wins. + * This module handles profile event creation and publishing. + */ + +import { finalizeEvent, SimplePool, type Event } from "nostr-tools"; +import { type NostrProfile, NostrProfileSchema } from "./config-schema.js"; + +// ============================================================================ +// Types +// ============================================================================ + +/** Result of a profile publish attempt */ +export interface ProfilePublishResult { + /** Event ID of the published profile */ + eventId: string; + /** Relays that successfully received the event */ + successes: string[]; + /** Relays that failed with their error messages */ + failures: Array<{ relay: string; error: string }>; + /** Unix timestamp when the event was created */ + createdAt: number; +} + +/** NIP-01 profile content (JSON inside kind:0 event) */ +export interface ProfileContent { + name?: string; + display_name?: string; + about?: string; + picture?: string; + banner?: string; + website?: string; + nip05?: string; + lud16?: string; +} + +// ============================================================================ +// Profile Content Conversion +// ============================================================================ + +/** + * Convert our config profile schema to NIP-01 content format. + * Strips undefined fields and validates URLs. + */ +export function profileToContent(profile: NostrProfile): ProfileContent { + const validated = NostrProfileSchema.parse(profile); + + const content: ProfileContent = {}; + + if (validated.name !== undefined) content.name = validated.name; + if (validated.displayName !== undefined) content.display_name = validated.displayName; + if (validated.about !== undefined) content.about = validated.about; + if (validated.picture !== undefined) content.picture = validated.picture; + if (validated.banner !== undefined) content.banner = validated.banner; + if (validated.website !== undefined) content.website = validated.website; + if (validated.nip05 !== undefined) content.nip05 = validated.nip05; + if (validated.lud16 !== undefined) content.lud16 = validated.lud16; + + return content; +} + +/** + * Convert NIP-01 content format back to our config profile schema. + * Useful for importing existing profiles from relays. + */ +export function contentToProfile(content: ProfileContent): NostrProfile { + const profile: NostrProfile = {}; + + if (content.name !== undefined) profile.name = content.name; + if (content.display_name !== undefined) profile.displayName = content.display_name; + if (content.about !== undefined) profile.about = content.about; + if (content.picture !== undefined) profile.picture = content.picture; + if (content.banner !== undefined) profile.banner = content.banner; + if (content.website !== undefined) profile.website = content.website; + if (content.nip05 !== undefined) profile.nip05 = content.nip05; + if (content.lud16 !== undefined) profile.lud16 = content.lud16; + + return profile; +} + +// ============================================================================ +// Event Creation +// ============================================================================ + +/** + * Create a signed kind:0 profile event. + * + * @param sk - Private key as Uint8Array (32 bytes) + * @param profile - Profile data to include + * @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee) + * @returns Signed Nostr event + */ +export function createProfileEvent( + sk: Uint8Array, + profile: NostrProfile, + lastPublishedAt?: number +): Event { + const content = profileToContent(profile); + const contentJson = JSON.stringify(content); + + // Ensure monotonic timestamp (new event > previous) + const now = Math.floor(Date.now() / 1000); + const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now; + + const event = finalizeEvent( + { + kind: 0, + content: contentJson, + tags: [], + created_at: createdAt, + }, + sk + ); + + return event; +} + +// ============================================================================ +// Profile Publishing +// ============================================================================ + +/** Per-relay publish timeout (ms) */ +const RELAY_PUBLISH_TIMEOUT_MS = 5000; + +/** + * Publish a profile event to multiple relays. + * + * Best-effort: publishes to all relays in parallel, reports per-relay results. + * Does NOT retry automatically - caller should handle retries if needed. + * + * @param pool - SimplePool instance for relay connections + * @param relays - Array of relay WebSocket URLs + * @param event - Signed profile event (kind:0) + * @returns Publish results with successes and failures + */ +export async function publishProfileEvent( + pool: SimplePool, + relays: string[], + event: Event +): Promise { + const successes: string[] = []; + const failures: Array<{ relay: string; error: string }> = []; + + // Publish to each relay in parallel with timeout + const publishPromises = relays.map(async (relay) => { + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS); + }); + + await Promise.race([pool.publish([relay], event), timeoutPromise]); + + successes.push(relay); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + failures.push({ relay, error: errorMessage }); + } + }); + + await Promise.all(publishPromises); + + return { + eventId: event.id, + successes, + failures, + createdAt: event.created_at, + }; +} + +/** + * Create and publish a profile event in one call. + * + * @param pool - SimplePool instance + * @param sk - Private key as Uint8Array + * @param relays - Array of relay URLs + * @param profile - Profile data + * @param lastPublishedAt - Previous timestamp for monotonic ordering + * @returns Publish results + */ +export async function publishProfile( + pool: SimplePool, + sk: Uint8Array, + relays: string[], + profile: NostrProfile, + lastPublishedAt?: number +): Promise { + const event = createProfileEvent(sk, profile, lastPublishedAt); + return publishProfileEvent(pool, relays, event); +} + +// ============================================================================ +// Profile Validation Helpers +// ============================================================================ + +/** + * Validate a profile without throwing (returns result object). + */ +export function validateProfile(profile: unknown): { + valid: boolean; + profile?: NostrProfile; + errors?: string[]; +} { + const result = NostrProfileSchema.safeParse(profile); + + if (result.success) { + return { valid: true, profile: result.data }; + } + + return { + valid: false, + errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`), + }; +} + +/** + * Sanitize profile text fields to prevent XSS when displaying in UI. + * Escapes HTML special characters. + */ +export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile { + const escapeHtml = (str: string | undefined): string | undefined => { + if (str === undefined) return undefined; + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + return { + name: escapeHtml(profile.name), + displayName: escapeHtml(profile.displayName), + about: escapeHtml(profile.about), + picture: profile.picture, // URLs already validated by schema + banner: profile.banner, + website: profile.website, + nip05: escapeHtml(profile.nip05), + lud16: escapeHtml(profile.lud16), + }; +} diff --git a/extensions/nostr/src/nostr-state-store.test.ts b/extensions/nostr/src/nostr-state-store.test.ts new file mode 100644 index 000000000..88d46ac31 --- /dev/null +++ b/extensions/nostr/src/nostr-state-store.test.ts @@ -0,0 +1,128 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +import { + readNostrBusState, + writeNostrBusState, + computeSinceTimestamp, +} from "./nostr-state-store.js"; +import { setNostrRuntime } from "./runtime.js"; + +async function withTempStateDir(fn: (dir: string) => Promise) { + const previous = process.env.CLAWDBOT_STATE_DIR; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-nostr-")); + process.env.CLAWDBOT_STATE_DIR = dir; + setNostrRuntime({ + state: { + resolveStateDir: (env, homedir) => { + const override = env.CLAWDBOT_STATE_DIR?.trim(); + if (override) return override; + return path.join(homedir(), ".clawdbot"); + }, + }, + } as PluginRuntime); + try { + return await fn(dir); + } finally { + if (previous === undefined) delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = previous; + await fs.rm(dir, { recursive: true, force: true }); + } +} + +describe("nostr bus state store", () => { + it("persists and reloads state across restarts", async () => { + await withTempStateDir(async () => { + // Fresh start - no state + expect(await readNostrBusState({ accountId: "test-bot" })).toBeNull(); + + // Write state + await writeNostrBusState({ + accountId: "test-bot", + lastProcessedAt: 1700000000, + gatewayStartedAt: 1700000100, + }); + + // Read it back + const state = await readNostrBusState({ accountId: "test-bot" }); + expect(state).toEqual({ + version: 2, + lastProcessedAt: 1700000000, + gatewayStartedAt: 1700000100, + recentEventIds: [], + }); + }); + }); + + it("isolates state by accountId", async () => { + await withTempStateDir(async () => { + await writeNostrBusState({ + accountId: "bot-a", + lastProcessedAt: 1000, + gatewayStartedAt: 1000, + }); + await writeNostrBusState({ + accountId: "bot-b", + lastProcessedAt: 2000, + gatewayStartedAt: 2000, + }); + + const stateA = await readNostrBusState({ accountId: "bot-a" }); + const stateB = await readNostrBusState({ accountId: "bot-b" }); + + expect(stateA?.lastProcessedAt).toBe(1000); + expect(stateB?.lastProcessedAt).toBe(2000); + }); + }); +}); + +describe("computeSinceTimestamp", () => { + it("returns now for null state (fresh start)", () => { + const now = 1700000000; + expect(computeSinceTimestamp(null, now)).toBe(now); + }); + + it("uses lastProcessedAt when available", () => { + const state = { + version: 2, + lastProcessedAt: 1699999000, + gatewayStartedAt: null, + recentEventIds: [], + }; + expect(computeSinceTimestamp(state, 1700000000)).toBe(1699999000); + }); + + it("uses gatewayStartedAt when lastProcessedAt is null", () => { + const state = { + version: 2, + lastProcessedAt: null, + gatewayStartedAt: 1699998000, + recentEventIds: [], + }; + expect(computeSinceTimestamp(state, 1700000000)).toBe(1699998000); + }); + + it("uses the max of both timestamps", () => { + const state = { + version: 2, + lastProcessedAt: 1699999000, + gatewayStartedAt: 1699998000, + recentEventIds: [], + }; + expect(computeSinceTimestamp(state, 1700000000)).toBe(1699999000); + }); + + it("falls back to now if both are null", () => { + const state = { + version: 2, + lastProcessedAt: null, + gatewayStartedAt: null, + recentEventIds: [], + }; + expect(computeSinceTimestamp(state, 1700000000)).toBe(1700000000); + }); +}); diff --git a/extensions/nostr/src/nostr-state-store.ts b/extensions/nostr/src/nostr-state-store.ts new file mode 100644 index 000000000..b184b3c52 --- /dev/null +++ b/extensions/nostr/src/nostr-state-store.ts @@ -0,0 +1,226 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { getNostrRuntime } from "./runtime.js"; + +const STORE_VERSION = 2; +const PROFILE_STATE_VERSION = 1; + +type NostrBusStateV1 = { + version: 1; + /** Unix timestamp (seconds) of the last processed event */ + lastProcessedAt: number | null; + /** Gateway startup timestamp (seconds) - events before this are old */ + gatewayStartedAt: number | null; +}; + +type NostrBusState = { + version: 2; + /** Unix timestamp (seconds) of the last processed event */ + lastProcessedAt: number | null; + /** Gateway startup timestamp (seconds) - events before this are old */ + gatewayStartedAt: number | null; + /** Recent processed event IDs for overlap dedupe across restarts */ + recentEventIds: string[]; +}; + +/** Profile publish state (separate from bus state) */ +export type NostrProfileState = { + version: 1; + /** Unix timestamp (seconds) of last successful profile publish */ + lastPublishedAt: number | null; + /** Event ID of the last published profile */ + lastPublishedEventId: string | null; + /** Per-relay publish results from last attempt */ + lastPublishResults: Record | null; +}; + +function normalizeAccountId(accountId?: string): string { + const trimmed = accountId?.trim(); + if (!trimmed) return "default"; + return trimmed.replace(/[^a-z0-9._-]+/gi, "_"); +} + +function resolveNostrStatePath( + accountId?: string, + env: NodeJS.ProcessEnv = process.env +): string { + const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir); + const normalized = normalizeAccountId(accountId); + return path.join(stateDir, "nostr", `bus-state-${normalized}.json`); +} + +function resolveNostrProfileStatePath( + accountId?: string, + env: NodeJS.ProcessEnv = process.env +): string { + const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir); + const normalized = normalizeAccountId(accountId); + return path.join(stateDir, "nostr", `profile-state-${normalized}.json`); +} + +function safeParseState(raw: string): NostrBusState | null { + try { + const parsed = JSON.parse(raw) as Partial & Partial; + + if (parsed?.version === 2) { + return { + version: 2, + lastProcessedAt: typeof parsed.lastProcessedAt === "number" ? parsed.lastProcessedAt : null, + gatewayStartedAt: typeof parsed.gatewayStartedAt === "number" ? parsed.gatewayStartedAt : null, + recentEventIds: Array.isArray(parsed.recentEventIds) + ? parsed.recentEventIds.filter((x): x is string => typeof x === "string") + : [], + }; + } + + // Back-compat: v1 state files + if (parsed?.version === 1) { + return { + version: 2, + lastProcessedAt: typeof parsed.lastProcessedAt === "number" ? parsed.lastProcessedAt : null, + gatewayStartedAt: typeof parsed.gatewayStartedAt === "number" ? parsed.gatewayStartedAt : null, + recentEventIds: [], + }; + } + + return null; + } catch { + return null; + } +} + +export async function readNostrBusState(params: { + accountId?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const filePath = resolveNostrStatePath(params.accountId, params.env); + try { + const raw = await fs.readFile(filePath, "utf-8"); + return safeParseState(raw); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") return null; + return null; + } +} + +export async function writeNostrBusState(params: { + accountId?: string; + lastProcessedAt: number; + gatewayStartedAt: number; + recentEventIds?: string[]; + env?: NodeJS.ProcessEnv; +}): Promise { + const filePath = resolveNostrStatePath(params.accountId, params.env); + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = path.join( + dir, + `${path.basename(filePath)}.${crypto.randomUUID()}.tmp` + ); + const payload: NostrBusState = { + version: STORE_VERSION, + lastProcessedAt: params.lastProcessedAt, + gatewayStartedAt: params.gatewayStartedAt, + recentEventIds: (params.recentEventIds ?? []).filter((x): x is string => typeof x === "string"), + }; + await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf-8", + }); + await fs.chmod(tmp, 0o600); + await fs.rename(tmp, filePath); +} + +/** + * Determine the `since` timestamp for subscription. + * Returns the later of: lastProcessedAt or gatewayStartedAt (both from disk), + * falling back to `now` for fresh starts. + */ +export function computeSinceTimestamp( + state: NostrBusState | null, + nowSec: number = Math.floor(Date.now() / 1000) +): number { + if (!state) return nowSec; + + // Use the most recent timestamp we have + const candidates = [ + state.lastProcessedAt, + state.gatewayStartedAt, + ].filter((t): t is number => t !== null && t > 0); + + if (candidates.length === 0) return nowSec; + return Math.max(...candidates); +} + +// ============================================================================ +// Profile State Management +// ============================================================================ + +function safeParseProfileState(raw: string): NostrProfileState | null { + try { + const parsed = JSON.parse(raw) as Partial; + + if (parsed?.version === 1) { + return { + version: 1, + lastPublishedAt: + typeof parsed.lastPublishedAt === "number" ? parsed.lastPublishedAt : null, + lastPublishedEventId: + typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null, + lastPublishResults: + parsed.lastPublishResults && typeof parsed.lastPublishResults === "object" + ? (parsed.lastPublishResults as Record) + : null, + }; + } + + return null; + } catch { + return null; + } +} + +export async function readNostrProfileState(params: { + accountId?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const filePath = resolveNostrProfileStatePath(params.accountId, params.env); + try { + const raw = await fs.readFile(filePath, "utf-8"); + return safeParseProfileState(raw); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") return null; + return null; + } +} + +export async function writeNostrProfileState(params: { + accountId?: string; + lastPublishedAt: number; + lastPublishedEventId: string; + lastPublishResults: Record; + env?: NodeJS.ProcessEnv; +}): Promise { + const filePath = resolveNostrProfileStatePath(params.accountId, params.env); + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = path.join( + dir, + `${path.basename(filePath)}.${crypto.randomUUID()}.tmp` + ); + const payload: NostrProfileState = { + version: PROFILE_STATE_VERSION, + lastPublishedAt: params.lastPublishedAt, + lastPublishedEventId: params.lastPublishedEventId, + lastPublishResults: params.lastPublishResults, + }; + await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf-8", + }); + await fs.chmod(tmp, 0o600); + await fs.rename(tmp, filePath); +} diff --git a/extensions/nostr/src/runtime.ts b/extensions/nostr/src/runtime.ts new file mode 100644 index 000000000..d5f04cbc4 --- /dev/null +++ b/extensions/nostr/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setNostrRuntime(next: PluginRuntime): void { + runtime = next; +} + +export function getNostrRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Nostr runtime not initialized"); + } + return runtime; +} diff --git a/extensions/nostr/src/seen-tracker.ts b/extensions/nostr/src/seen-tracker.ts new file mode 100644 index 000000000..29d19ed5d --- /dev/null +++ b/extensions/nostr/src/seen-tracker.ts @@ -0,0 +1,271 @@ +/** + * LRU-based seen event tracker with TTL support. + * Prevents unbounded memory growth under high load or abuse. + */ + +export interface SeenTrackerOptions { + /** Maximum number of entries to track (default: 100,000) */ + maxEntries?: number; + /** TTL in milliseconds (default: 1 hour) */ + ttlMs?: number; + /** Prune interval in milliseconds (default: 10 minutes) */ + pruneIntervalMs?: number; +} + +export interface SeenTracker { + /** Check if an ID has been seen (also marks it as seen if not) */ + has: (id: string) => boolean; + /** Mark an ID as seen */ + add: (id: string) => void; + /** Check if ID exists without marking */ + peek: (id: string) => boolean; + /** Delete an ID */ + delete: (id: string) => void; + /** Clear all entries */ + clear: () => void; + /** Get current size */ + size: () => number; + /** Stop the pruning timer */ + stop: () => void; + /** Pre-seed with IDs (useful for restart recovery) */ + seed: (ids: string[]) => void; +} + +interface Entry { + seenAt: number; + // For LRU: track order via doubly-linked list + prev: string | null; + next: string | null; +} + +/** + * Create a new seen tracker with LRU eviction and TTL expiration. + */ +export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker { + const maxEntries = options?.maxEntries ?? 100_000; + const ttlMs = options?.ttlMs ?? 60 * 60 * 1000; // 1 hour + const pruneIntervalMs = options?.pruneIntervalMs ?? 10 * 60 * 1000; // 10 minutes + + // Main storage + const entries = new Map(); + + // LRU tracking: head = most recent, tail = least recent + let head: string | null = null; + let tail: string | null = null; + + // Move an entry to the front (most recently used) + function moveToFront(id: string): void { + const entry = entries.get(id); + if (!entry) return; + + // Already at front + if (head === id) return; + + // Remove from current position + if (entry.prev) { + const prevEntry = entries.get(entry.prev); + if (prevEntry) prevEntry.next = entry.next; + } + if (entry.next) { + const nextEntry = entries.get(entry.next); + if (nextEntry) nextEntry.prev = entry.prev; + } + + // Update tail if this was the tail + if (tail === id) { + tail = entry.prev; + } + + // Move to front + entry.prev = null; + entry.next = head; + if (head) { + const headEntry = entries.get(head); + if (headEntry) headEntry.prev = id; + } + head = id; + + // If no tail, this is also the tail + if (!tail) tail = id; + } + + // Remove an entry from the linked list + function removeFromList(id: string): void { + const entry = entries.get(id); + if (!entry) return; + + if (entry.prev) { + const prevEntry = entries.get(entry.prev); + if (prevEntry) prevEntry.next = entry.next; + } else { + head = entry.next; + } + + if (entry.next) { + const nextEntry = entries.get(entry.next); + if (nextEntry) nextEntry.prev = entry.prev; + } else { + tail = entry.prev; + } + } + + // Evict the least recently used entry + function evictLRU(): void { + if (!tail) return; + const idToEvict = tail; + removeFromList(idToEvict); + entries.delete(idToEvict); + } + + // Prune expired entries + function pruneExpired(): void { + const now = Date.now(); + const toDelete: string[] = []; + + for (const [id, entry] of entries) { + if (now - entry.seenAt > ttlMs) { + toDelete.push(id); + } + } + + for (const id of toDelete) { + removeFromList(id); + entries.delete(id); + } + } + + // Start pruning timer + let pruneTimer: ReturnType | undefined; + if (pruneIntervalMs > 0) { + pruneTimer = setInterval(pruneExpired, pruneIntervalMs); + // Don't keep process alive just for pruning + if (pruneTimer.unref) pruneTimer.unref(); + } + + function add(id: string): void { + const now = Date.now(); + + // If already exists, update and move to front + const existing = entries.get(id); + if (existing) { + existing.seenAt = now; + moveToFront(id); + return; + } + + // Evict if at capacity + while (entries.size >= maxEntries) { + evictLRU(); + } + + // Add new entry at front + const newEntry: Entry = { + seenAt: now, + prev: null, + next: head, + }; + + if (head) { + const headEntry = entries.get(head); + if (headEntry) headEntry.prev = id; + } + + entries.set(id, newEntry); + head = id; + if (!tail) tail = id; + } + + function has(id: string): boolean { + const entry = entries.get(id); + if (!entry) { + add(id); + return false; + } + + // Check if expired + if (Date.now() - entry.seenAt > ttlMs) { + removeFromList(id); + entries.delete(id); + add(id); + return false; + } + + // Mark as recently used + entry.seenAt = Date.now(); + moveToFront(id); + return true; + } + + function peek(id: string): boolean { + const entry = entries.get(id); + if (!entry) return false; + + // Check if expired + if (Date.now() - entry.seenAt > ttlMs) { + removeFromList(id); + entries.delete(id); + return false; + } + + return true; + } + + function deleteEntry(id: string): void { + if (entries.has(id)) { + removeFromList(id); + entries.delete(id); + } + } + + function clear(): void { + entries.clear(); + head = null; + tail = null; + } + + function size(): number { + return entries.size; + } + + function stop(): void { + if (pruneTimer) { + clearInterval(pruneTimer); + pruneTimer = undefined; + } + } + + function seed(ids: string[]): void { + const now = Date.now(); + // Seed in reverse order so first IDs end up at front + for (let i = ids.length - 1; i >= 0; i--) { + const id = ids[i]; + if (!entries.has(id) && entries.size < maxEntries) { + const newEntry: Entry = { + seenAt: now, + prev: null, + next: head, + }; + + if (head) { + const headEntry = entries.get(head); + if (headEntry) headEntry.prev = id; + } + + entries.set(id, newEntry); + head = id; + if (!tail) tail = id; + } + } + } + + return { + has, + add, + peek, + delete: deleteEntry, + clear, + size, + stop, + seed, + }; +} diff --git a/extensions/nostr/src/types.test.ts b/extensions/nostr/src/types.test.ts new file mode 100644 index 000000000..c87f62d38 --- /dev/null +++ b/extensions/nostr/src/types.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { + listNostrAccountIds, + resolveDefaultNostrAccountId, + resolveNostrAccount, +} from "./types.js"; + +const TEST_PRIVATE_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +describe("listNostrAccountIds", () => { + it("returns empty array when not configured", () => { + const cfg = { channels: {} }; + expect(listNostrAccountIds(cfg)).toEqual([]); + }); + + it("returns empty array when nostr section exists but no privateKey", () => { + const cfg = { channels: { nostr: { enabled: true } } }; + expect(listNostrAccountIds(cfg)).toEqual([]); + }); + + it("returns default when privateKey is configured", () => { + const cfg = { + channels: { + nostr: { privateKey: TEST_PRIVATE_KEY }, + }, + }; + expect(listNostrAccountIds(cfg)).toEqual(["default"]); + }); +}); + +describe("resolveDefaultNostrAccountId", () => { + it("returns default when configured", () => { + const cfg = { + channels: { + nostr: { privateKey: TEST_PRIVATE_KEY }, + }, + }; + expect(resolveDefaultNostrAccountId(cfg)).toBe("default"); + }); + + it("returns default when not configured", () => { + const cfg = { channels: {} }; + expect(resolveDefaultNostrAccountId(cfg)).toBe("default"); + }); +}); + +describe("resolveNostrAccount", () => { + it("resolves configured account", () => { + const cfg = { + channels: { + nostr: { + privateKey: TEST_PRIVATE_KEY, + name: "Test Bot", + relays: ["wss://test.relay"], + dmPolicy: "pairing" as const, + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.accountId).toBe("default"); + expect(account.name).toBe("Test Bot"); + expect(account.enabled).toBe(true); + expect(account.configured).toBe(true); + expect(account.privateKey).toBe(TEST_PRIVATE_KEY); + expect(account.publicKey).toMatch(/^[0-9a-f]{64}$/); + expect(account.relays).toEqual(["wss://test.relay"]); + }); + + it("resolves unconfigured account with defaults", () => { + const cfg = { channels: {} }; + const account = resolveNostrAccount({ cfg }); + + expect(account.accountId).toBe("default"); + expect(account.enabled).toBe(true); + expect(account.configured).toBe(false); + expect(account.privateKey).toBe(""); + expect(account.publicKey).toBe(""); + expect(account.relays).toContain("wss://relay.damus.io"); + expect(account.relays).toContain("wss://nos.lol"); + }); + + it("handles disabled channel", () => { + const cfg = { + channels: { + nostr: { + enabled: false, + privateKey: TEST_PRIVATE_KEY, + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.enabled).toBe(false); + expect(account.configured).toBe(true); + }); + + it("handles custom accountId parameter", () => { + const cfg = { + channels: { + nostr: { privateKey: TEST_PRIVATE_KEY }, + }, + }; + const account = resolveNostrAccount({ cfg, accountId: "custom" }); + + expect(account.accountId).toBe("custom"); + }); + + it("handles allowFrom config", () => { + const cfg = { + channels: { + nostr: { + privateKey: TEST_PRIVATE_KEY, + allowFrom: ["npub1test", "0123456789abcdef"], + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.config.allowFrom).toEqual(["npub1test", "0123456789abcdef"]); + }); + + it("handles invalid private key gracefully", () => { + const cfg = { + channels: { + nostr: { + privateKey: "invalid-key", + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.configured).toBe(true); // key is present + expect(account.publicKey).toBe(""); // but can't derive pubkey + }); + + it("preserves all config options", () => { + const cfg = { + channels: { + nostr: { + privateKey: TEST_PRIVATE_KEY, + name: "Bot", + enabled: true, + relays: ["wss://relay1", "wss://relay2"], + dmPolicy: "allowlist" as const, + allowFrom: ["pubkey1", "pubkey2"], + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.config).toEqual({ + privateKey: TEST_PRIVATE_KEY, + name: "Bot", + enabled: true, + relays: ["wss://relay1", "wss://relay2"], + dmPolicy: "allowlist", + allowFrom: ["pubkey1", "pubkey2"], + }); + }); +}); diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts new file mode 100644 index 000000000..7b0862e4b --- /dev/null +++ b/extensions/nostr/src/types.ts @@ -0,0 +1,99 @@ +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { getPublicKeyFromPrivate } from "./nostr-bus.js"; +import { DEFAULT_RELAYS } from "./nostr-bus.js"; +import type { NostrProfile } from "./config-schema.js"; + +export interface NostrAccountConfig { + enabled?: boolean; + name?: string; + privateKey?: string; + relays?: string[]; + dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; + allowFrom?: Array; + profile?: NostrProfile; +} + +export interface ResolvedNostrAccount { + accountId: string; + name?: string; + enabled: boolean; + configured: boolean; + privateKey: string; + publicKey: string; + relays: string[]; + profile?: NostrProfile; + config: NostrAccountConfig; +} + +const DEFAULT_ACCOUNT_ID = "default"; + +/** + * List all configured Nostr account IDs + */ +export function listNostrAccountIds(cfg: ClawdbotConfig): string[] { + const nostrCfg = (cfg.channels as Record | undefined)?.nostr as + | NostrAccountConfig + | undefined; + + // If privateKey is configured at top level, we have a default account + if (nostrCfg?.privateKey) { + return [DEFAULT_ACCOUNT_ID]; + } + + return []; +} + +/** + * Get the default account ID + */ +export function resolveDefaultNostrAccountId(cfg: ClawdbotConfig): string { + const ids = listNostrAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +/** + * Resolve a Nostr account from config + */ +export function resolveNostrAccount(opts: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedNostrAccount { + const accountId = opts.accountId ?? DEFAULT_ACCOUNT_ID; + const nostrCfg = (opts.cfg.channels as Record | undefined)?.nostr as + | NostrAccountConfig + | undefined; + + const baseEnabled = nostrCfg?.enabled !== false; + const privateKey = nostrCfg?.privateKey ?? ""; + const configured = Boolean(privateKey.trim()); + + let publicKey = ""; + if (configured) { + try { + publicKey = getPublicKeyFromPrivate(privateKey); + } catch { + // Invalid key - leave publicKey empty, configured will indicate issues + } + } + + return { + accountId, + name: nostrCfg?.name?.trim() || undefined, + enabled: baseEnabled, + configured, + privateKey, + publicKey, + relays: nostrCfg?.relays ?? DEFAULT_RELAYS, + profile: nostrCfg?.profile, + config: { + enabled: nostrCfg?.enabled, + name: nostrCfg?.name, + privateKey: nostrCfg?.privateKey, + relays: nostrCfg?.relays, + dmPolicy: nostrCfg?.dmPolicy, + allowFrom: nostrCfg?.allowFrom, + profile: nostrCfg?.profile, + }, + }; +} diff --git a/extensions/nostr/test/setup.ts b/extensions/nostr/test/setup.ts new file mode 100644 index 000000000..92926dc48 --- /dev/null +++ b/extensions/nostr/test/setup.ts @@ -0,0 +1,5 @@ +// Test setup file for nostr extension +import { vi } from "vitest"; + +// Mock console.error to suppress noise in tests +vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a5589a80..49339d3c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,6 +353,18 @@ importers: extensions/nextcloud-talk: {} + extensions/nostr: + dependencies: + clawdbot: + specifier: workspace:* + version: link:../.. + nostr-tools: + specifier: ^2.10.4 + version: 2.19.4(typescript@5.9.3) + zod: + specifier: ^4.3.5 + version: 4.3.5 + extensions/signal: {} extensions/slack: {} @@ -1333,9 +1345,26 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@noble/ciphers@0.5.3': + resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} + + '@noble/curves@1.1.0': + resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} + + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/ed25519@3.0.0': resolution: {integrity: sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==} + '@noble/hashes@1.3.1': + resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==} + engines: {node: '>= 16'} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@node-llama-cpp/linux-arm64@3.15.0': resolution: {integrity: sha512-IaHIllWlj6tGjhhCtyp1w6xA7AHaGJiVaXAZ+78hDs8X1SL9ySBN2Qceju8AQJALePtynbAfjgjTqjQ7Hyk+IQ==} engines: {node: '>=20.0.0'} @@ -2114,6 +2143,15 @@ packages: cpu: [x64] os: [win32] + '@scure/base@1.1.1': + resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} + + '@scure/bip32@1.3.1': + resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==} + + '@scure/bip39@1.2.1': + resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -4075,6 +4113,17 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nostr-tools@2.19.4: + resolution: {integrity: sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + npmlog@6.0.2: resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -6356,8 +6405,22 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/ciphers@0.5.3': {} + + '@noble/curves@1.1.0': + dependencies: + '@noble/hashes': 1.3.1 + + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + '@noble/ed25519@3.0.0': {} + '@noble/hashes@1.3.1': {} + + '@noble/hashes@1.3.2': {} + '@node-llama-cpp/linux-arm64@3.15.0': optional: true @@ -7070,6 +7133,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.2': optional: true + '@scure/base@1.1.1': {} + + '@scure/bip32@1.3.1': + dependencies: + '@noble/curves': 1.1.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + + '@scure/bip39@1.2.1': + dependencies: + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -9429,6 +9505,20 @@ snapshots: normalize-path@3.0.0: {} + nostr-tools@2.19.4(typescript@5.9.3): + dependencies: + '@noble/ciphers': 0.5.3 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@scure/bip32': 1.3.1 + '@scure/bip39': 1.2.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.9.3 + + nostr-wasm@0.1.0: {} + npmlog@6.0.2: dependencies: are-we-there-yet: 3.0.1 diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index ffa2d1ae7..87f06c2c6 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -102,6 +102,52 @@ describe("ensureOnboardingPluginInstalled", () => { expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true); }); + it("defaults to local on dev channel when local path exists", async () => { + const runtime = makeRuntime(); + const select = vi.fn(async () => "skip") as WizardPrompter["select"]; + const prompter = makePrompter({ select }); + const cfg: ClawdbotConfig = { update: { channel: "dev" } }; + vi.mocked(fs.existsSync).mockImplementation((value) => { + const raw = String(value); + return ( + raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`) + ); + }); + + await ensureOnboardingPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + }); + + const firstCall = select.mock.calls[0]?.[0]; + expect(firstCall?.initialValue).toBe("local"); + }); + + it("defaults to npm on beta channel even when local path exists", async () => { + const runtime = makeRuntime(); + const select = vi.fn(async () => "skip") as WizardPrompter["select"]; + const prompter = makePrompter({ select }); + const cfg: ClawdbotConfig = { update: { channel: "beta" } }; + vi.mocked(fs.existsSync).mockImplementation((value) => { + const raw = String(value); + return ( + raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`) + ); + }); + + await ensureOnboardingPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + }); + + const firstCall = select.mock.calls[0]?.[0]; + expect(firstCall?.initialValue).toBe("npm"); + }); + it("falls back to local path after npm install failure", async () => { const runtime = makeRuntime(); const note = vi.fn(async () => {}); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index bf1e43e6e..d676c26ef 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -67,9 +67,10 @@ function addPluginLoadPath(cfg: ClawdbotConfig, pluginPath: string): ClawdbotCon async function promptInstallChoice(params: { entry: ChannelPluginCatalogEntry; localPath?: string | null; + defaultChoice: InstallChoice; prompter: WizardPrompter; }): Promise { - const { entry, localPath, prompter } = params; + const { entry, localPath, prompter, defaultChoice } = params; const localOptions: Array<{ value: InstallChoice; label: string; hint?: string }> = localPath ? [ { @@ -84,7 +85,8 @@ async function promptInstallChoice(params: { ...localOptions, { value: "skip", label: "Skip for now" }, ]; - const initialValue: InstallChoice = localPath ? "local" : "npm"; + const initialValue: InstallChoice = + defaultChoice === "local" && !localPath ? "npm" : defaultChoice; return await prompter.select({ message: `Install ${entry.meta.label} plugin?`, options, @@ -92,6 +94,25 @@ async function promptInstallChoice(params: { }); } +function resolveInstallDefaultChoice(params: { + cfg: ClawdbotConfig; + entry: ChannelPluginCatalogEntry; + localPath?: string | null; +}): InstallChoice { + const { cfg, entry, localPath } = params; + const updateChannel = cfg.update?.channel; + if (updateChannel === "dev") { + return localPath ? "local" : "npm"; + } + if (updateChannel === "stable" || updateChannel === "beta") { + return "npm"; + } + const entryDefault = entry.install.defaultChoice; + if (entryDefault === "local") return localPath ? "local" : "npm"; + if (entryDefault === "npm") return "npm"; + return localPath ? "local" : "npm"; +} + export async function ensureOnboardingPluginInstalled(params: { cfg: ClawdbotConfig; entry: ChannelPluginCatalogEntry; @@ -103,9 +124,15 @@ export async function ensureOnboardingPluginInstalled(params: { let next = params.cfg; const allowLocal = hasGitWorkspace(workspaceDir); const localPath = resolveLocalPath(entry, workspaceDir, allowLocal); + const defaultChoice = resolveInstallDefaultChoice({ + cfg: next, + entry, + localPath, + }); const choice = await promptInstallChoice({ entry, localPath, + defaultChoice, prompter, }); diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index 1fd7319fe..a58119702 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -32,5 +32,5 @@ export const ChannelsSchema = z bluebubbles: BlueBubblesConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(), }) - .passthrough() + .passthrough() // Allow extension channel configs (nostr, matrix, zalo, etc.) .optional(); diff --git a/ui/src/ui/app-channels.ts b/ui/src/ui/app-channels.ts index 3647f00dd..3a3921a8d 100644 --- a/ui/src/ui/app-channels.ts +++ b/ui/src/ui/app-channels.ts @@ -6,6 +6,8 @@ import { } from "./controllers/channels"; import { loadConfig, saveConfig } from "./controllers/config"; import type { ClawdbotApp } from "./app"; +import type { NostrProfile } from "./types"; +import { createNostrProfileFormState } from "./views/channels.nostr-profile-form"; export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) { await startWhatsAppLogin(host, force); @@ -32,3 +34,200 @@ export async function handleChannelConfigReload(host: ClawdbotApp) { await loadConfig(host); await loadChannels(host, true); } + +function parseValidationErrors(details: unknown): Record { + if (!Array.isArray(details)) return {}; + const errors: Record = {}; + for (const entry of details) { + if (typeof entry !== "string") continue; + const [rawField, ...rest] = entry.split(":"); + if (!rawField || rest.length === 0) continue; + const field = rawField.trim(); + const message = rest.join(":").trim(); + if (field && message) errors[field] = message; + } + return errors; +} + +function resolveNostrAccountId(host: ClawdbotApp): string { + const accounts = host.channelsSnapshot?.channelAccounts?.nostr ?? []; + return accounts[0]?.accountId ?? host.nostrProfileAccountId ?? "default"; +} + +function buildNostrProfileUrl(accountId: string, suffix = ""): string { + return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`; +} + +export function handleNostrProfileEdit( + host: ClawdbotApp, + accountId: string, + profile: NostrProfile | null, +) { + host.nostrProfileAccountId = accountId; + host.nostrProfileFormState = createNostrProfileFormState(profile ?? undefined); +} + +export function handleNostrProfileCancel(host: ClawdbotApp) { + host.nostrProfileFormState = null; + host.nostrProfileAccountId = null; +} + +export function handleNostrProfileFieldChange( + host: ClawdbotApp, + field: keyof NostrProfile, + value: string, +) { + const state = host.nostrProfileFormState; + if (!state) return; + host.nostrProfileFormState = { + ...state, + values: { + ...state.values, + [field]: value, + }, + fieldErrors: { + ...state.fieldErrors, + [field]: "", + }, + }; +} + +export function handleNostrProfileToggleAdvanced(host: ClawdbotApp) { + const state = host.nostrProfileFormState; + if (!state) return; + host.nostrProfileFormState = { + ...state, + showAdvanced: !state.showAdvanced, + }; +} + +export async function handleNostrProfileSave(host: ClawdbotApp) { + const state = host.nostrProfileFormState; + if (!state || state.saving) return; + const accountId = resolveNostrAccountId(host); + + host.nostrProfileFormState = { + ...state, + saving: true, + error: null, + success: null, + fieldErrors: {}, + }; + + try { + const response = await fetch(buildNostrProfileUrl(accountId), { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(state.values), + }); + const data = (await response.json().catch(() => null)) as + | { ok?: boolean; error?: string; details?: unknown; persisted?: boolean } + | null; + + if (!response.ok || data?.ok === false || !data) { + const errorMessage = data?.error ?? `Profile update failed (${response.status})`; + host.nostrProfileFormState = { + ...state, + saving: false, + error: errorMessage, + success: null, + fieldErrors: parseValidationErrors(data?.details), + }; + return; + } + + if (!data.persisted) { + host.nostrProfileFormState = { + ...state, + saving: false, + error: "Profile publish failed on all relays.", + success: null, + }; + return; + } + + host.nostrProfileFormState = { + ...state, + saving: false, + error: null, + success: "Profile published to relays.", + fieldErrors: {}, + original: { ...state.values }, + }; + await loadChannels(host, true); + } catch (err) { + host.nostrProfileFormState = { + ...state, + saving: false, + error: `Profile update failed: ${String(err)}`, + success: null, + }; + } +} + +export async function handleNostrProfileImport(host: ClawdbotApp) { + const state = host.nostrProfileFormState; + if (!state || state.importing) return; + const accountId = resolveNostrAccountId(host); + + host.nostrProfileFormState = { + ...state, + importing: true, + error: null, + success: null, + }; + + try { + const response = await fetch(buildNostrProfileUrl(accountId, "/import"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ autoMerge: true }), + }); + const data = (await response.json().catch(() => null)) as + | { ok?: boolean; error?: string; imported?: NostrProfile; merged?: NostrProfile; saved?: boolean } + | null; + + if (!response.ok || data?.ok === false || !data) { + const errorMessage = data?.error ?? `Profile import failed (${response.status})`; + host.nostrProfileFormState = { + ...state, + importing: false, + error: errorMessage, + success: null, + }; + return; + } + + const merged = data.merged ?? data.imported ?? null; + const nextValues = merged ? { ...state.values, ...merged } : state.values; + const showAdvanced = Boolean( + nextValues.banner || nextValues.website || nextValues.nip05 || nextValues.lud16, + ); + + host.nostrProfileFormState = { + ...state, + importing: false, + values: nextValues, + error: null, + success: data.saved + ? "Profile imported from relays. Review and publish." + : "Profile imported. Review and publish.", + showAdvanced, + }; + + if (data.saved) { + await loadChannels(host, true); + } + } catch (err) { + host.nostrProfileFormState = { + ...state, + importing: false, + error: `Profile import failed: ${String(err)}`, + success: null, + }; + } +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 63d44300a..f8d9e9444 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -222,6 +222,8 @@ export function renderApp(state: AppViewState) { configUiHints: state.configUiHints, configSaving: state.configSaving, configFormDirty: state.configFormDirty, + nostrProfileFormState: state.nostrProfileFormState, + nostrProfileAccountId: state.nostrProfileAccountId, onRefresh: (probe) => loadChannels(state, probe), onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onWhatsAppWait: () => state.handleWhatsAppWait(), @@ -229,6 +231,14 @@ export function renderApp(state: AppViewState) { onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), onConfigSave: () => state.handleChannelConfigSave(), onConfigReload: () => state.handleChannelConfigReload(), + onNostrProfileEdit: (accountId, profile) => + state.handleNostrProfileEdit(accountId, profile), + onNostrProfileCancel: () => state.handleNostrProfileCancel(), + onNostrProfileFieldChange: (field, value) => + state.handleNostrProfileFieldChange(field, value), + onNostrProfileSave: () => state.handleNostrProfileSave(), + onNostrProfileImport: () => state.handleNostrProfileImport(), + onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(), }) : nothing} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index f5f1ce95a..70d318482 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -12,6 +12,7 @@ import type { HealthSnapshot, LogEntry, LogLevel, + NostrProfile, PresenceEntry, SessionsListResult, SkillStatusReport, @@ -26,6 +27,7 @@ import type { } from "./controllers/exec-approvals"; import type { DevicePairingList } from "./controllers/devices"; import type { ExecApprovalRequest } from "./controllers/exec-approval"; +import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"; export type AppViewState = { settings: UiSettings; @@ -85,6 +87,8 @@ export type AppViewState = { whatsappLoginQrDataUrl: string | null; whatsappLoginConnected: boolean | null; whatsappBusy: boolean; + nostrProfileFormState: NostrProfileFormState | null; + nostrProfileAccountId: string | null; configFormDirty: boolean; presenceLoading: boolean; presenceEntries: PresenceEntry[]; @@ -141,6 +145,12 @@ export type AppViewState = { handleWhatsAppLogout: () => Promise; handleChannelConfigSave: () => Promise; handleChannelConfigReload: () => Promise; + handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void; + handleNostrProfileCancel: () => void; + handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void; + handleNostrProfileSave: () => Promise; + handleNostrProfileImport: () => Promise; + handleNostrProfileToggleAdvanced: () => void; handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise; handleConfigLoad: () => Promise; handleConfigSave: () => Promise; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 63c2ed13d..97b4d4da2 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -20,6 +20,7 @@ import type { SessionsListResult, SkillStatusReport, StatusSummary, + NostrProfile, } from "./types"; import { type ChatQueueItem, type CronFormState } from "./ui-types"; import type { EventLogEntry } from "./app-events"; @@ -64,10 +65,17 @@ import { import { handleChannelConfigReload as handleChannelConfigReloadInternal, handleChannelConfigSave as handleChannelConfigSaveInternal, + handleNostrProfileCancel as handleNostrProfileCancelInternal, + handleNostrProfileEdit as handleNostrProfileEditInternal, + handleNostrProfileFieldChange as handleNostrProfileFieldChangeInternal, + handleNostrProfileImport as handleNostrProfileImportInternal, + handleNostrProfileSave as handleNostrProfileSaveInternal, + handleNostrProfileToggleAdvanced as handleNostrProfileToggleAdvancedInternal, handleWhatsAppLogout as handleWhatsAppLogoutInternal, handleWhatsAppStart as handleWhatsAppStartInternal, handleWhatsAppWait as handleWhatsAppWaitInternal, } from "./app-channels"; +import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"; declare global { interface Window { @@ -153,6 +161,8 @@ export class ClawdbotApp extends LitElement { @state() whatsappLoginQrDataUrl: string | null = null; @state() whatsappLoginConnected: boolean | null = null; @state() whatsappBusy = false; + @state() nostrProfileFormState: NostrProfileFormState | null = null; + @state() nostrProfileAccountId: string | null = null; @state() presenceLoading = false; @state() presenceEntries: PresenceEntry[] = []; @@ -372,6 +382,30 @@ export class ClawdbotApp extends LitElement { await handleChannelConfigReloadInternal(this); } + handleNostrProfileEdit(accountId: string, profile: NostrProfile | null) { + handleNostrProfileEditInternal(this, accountId, profile); + } + + handleNostrProfileCancel() { + handleNostrProfileCancelInternal(this); + } + + handleNostrProfileFieldChange(field: keyof NostrProfile, value: string) { + handleNostrProfileFieldChangeInternal(this, field, value); + } + + async handleNostrProfileSave() { + await handleNostrProfileSaveInternal(this); + } + + async handleNostrProfileImport() { + await handleNostrProfileImportInternal(this); + } + + handleNostrProfileToggleAdvanced() { + handleNostrProfileToggleAdvancedInternal(this); + } + async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") { const active = this.execApprovalQueue[0]; if (!active || !this.client || this.execApprovalBusy) return; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index dfd832c97..6cdbfb029 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -200,6 +200,27 @@ export type IMessageStatus = { lastProbeAt?: number | null; }; +export type NostrProfile = { + name?: string | null; + displayName?: string | null; + about?: string | null; + picture?: string | null; + banner?: string | null; + website?: string | null; + nip05?: string | null; + lud16?: string | null; +}; + +export type NostrStatus = { + configured: boolean; + publicKey?: string | null; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + profile?: NostrProfile | null; +}; + export type MSTeamsProbe = { ok: boolean; error?: string | null; @@ -254,7 +275,6 @@ export type ConfigSchemaResponse = { }; export type PresenceEntry = { - deviceId?: string | null; instanceId?: string | null; host?: string | null; ip?: string | null; @@ -265,8 +285,6 @@ export type PresenceEntry = { mode?: string | null; lastInputSeconds?: number | null; reason?: string | null; - roles?: string[] | null; - scopes?: string[] | null; text?: string | null; ts?: number | null; }; @@ -339,8 +357,15 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - channel?: string; - provider?: string; + provider?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage" + | "msteams"; to?: string; bestEffortDeliver?: boolean; }; diff --git a/ui/src/ui/views/channels.nostr-profile-form.ts b/ui/src/ui/views/channels.nostr-profile-form.ts new file mode 100644 index 000000000..8565d8ef9 --- /dev/null +++ b/ui/src/ui/views/channels.nostr-profile-form.ts @@ -0,0 +1,312 @@ +/** + * Nostr Profile Edit Form + * + * Provides UI for editing and publishing Nostr profile (kind:0). + */ + +import { html, nothing, type TemplateResult } from "lit"; + +import type { NostrProfile as NostrProfileType } from "../types"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface NostrProfileFormState { + /** Current form values */ + values: NostrProfileType; + /** Original values for dirty detection */ + original: NostrProfileType; + /** Whether the form is currently submitting */ + saving: boolean; + /** Whether import is in progress */ + importing: boolean; + /** Last error message */ + error: string | null; + /** Last success message */ + success: string | null; + /** Validation errors per field */ + fieldErrors: Record; + /** Whether to show advanced fields */ + showAdvanced: boolean; +} + +export interface NostrProfileFormCallbacks { + /** Called when a field value changes */ + onFieldChange: (field: keyof NostrProfileType, value: string) => void; + /** Called when save is clicked */ + onSave: () => void; + /** Called when import is clicked */ + onImport: () => void; + /** Called when cancel is clicked */ + onCancel: () => void; + /** Called when toggle advanced is clicked */ + onToggleAdvanced: () => void; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function isFormDirty(state: NostrProfileFormState): boolean { + const { values, original } = state; + return ( + values.name !== original.name || + values.displayName !== original.displayName || + values.about !== original.about || + values.picture !== original.picture || + values.banner !== original.banner || + values.website !== original.website || + values.nip05 !== original.nip05 || + values.lud16 !== original.lud16 + ); +} + +// ============================================================================ +// Form Rendering +// ============================================================================ + +export function renderNostrProfileForm(params: { + state: NostrProfileFormState; + callbacks: NostrProfileFormCallbacks; + accountId: string; +}): TemplateResult { + const { state, callbacks, accountId } = params; + const isDirty = isFormDirty(state); + + const renderField = ( + field: keyof NostrProfileType, + label: string, + opts: { + type?: "text" | "url" | "textarea"; + placeholder?: string; + maxLength?: number; + help?: string; + } = {} + ) => { + const { type = "text", placeholder, maxLength, help } = opts; + const value = state.values[field] ?? ""; + const error = state.fieldErrors[field]; + + const inputId = `nostr-profile-${field}`; + + if (type === "textarea") { + return html` +
+ + + ${help ? html`
${help}
` : nothing} + ${error ? html`
${error}
` : nothing} +
+ `; + } + + return html` +
+ + { + const target = e.target as HTMLInputElement; + callbacks.onFieldChange(field, target.value); + }} + ?disabled=${state.saving} + /> + ${help ? html`
${help}
` : nothing} + ${error ? html`
${error}
` : nothing} +
+ `; + }; + + const renderPicturePreview = () => { + const picture = state.values.picture; + if (!picture) return nothing; + + return html` +
+ Profile picture preview { + const img = e.target as HTMLImageElement; + img.style.display = "none"; + }} + @load=${(e: Event) => { + const img = e.target as HTMLImageElement; + img.style.display = "block"; + }} + /> +
+ `; + }; + + return html` +
+
+
Edit Profile
+
Account: ${accountId}
+
+ + ${state.error + ? html`
${state.error}
` + : nothing} + + ${state.success + ? html`
${state.success}
` + : nothing} + + ${renderPicturePreview()} + + ${renderField("name", "Username", { + placeholder: "satoshi", + maxLength: 256, + help: "Short username (e.g., satoshi)", + })} + + ${renderField("displayName", "Display Name", { + placeholder: "Satoshi Nakamoto", + maxLength: 256, + help: "Your full display name", + })} + + ${renderField("about", "Bio", { + type: "textarea", + placeholder: "Tell people about yourself...", + maxLength: 2000, + help: "A brief bio or description", + })} + + ${renderField("picture", "Avatar URL", { + type: "url", + placeholder: "https://example.com/avatar.jpg", + help: "HTTPS URL to your profile picture", + })} + + ${state.showAdvanced + ? html` +
+
Advanced
+ + ${renderField("banner", "Banner URL", { + type: "url", + placeholder: "https://example.com/banner.jpg", + help: "HTTPS URL to a banner image", + })} + + ${renderField("website", "Website", { + type: "url", + placeholder: "https://example.com", + help: "Your personal website", + })} + + ${renderField("nip05", "NIP-05 Identifier", { + placeholder: "you@example.com", + help: "Verifiable identifier (e.g., you@domain.com)", + })} + + ${renderField("lud16", "Lightning Address", { + placeholder: "you@getalby.com", + help: "Lightning address for tips (LUD-16)", + })} +
+ ` + : nothing} + +
+ + + + + + + +
+ + ${isDirty + ? html`
+ You have unsaved changes +
` + : nothing} +
+ `; +} + +// ============================================================================ +// Factory +// ============================================================================ + +/** + * Create initial form state from existing profile + */ +export function createNostrProfileFormState( + profile: NostrProfileType | undefined +): NostrProfileFormState { + const values: NostrProfileType = { + name: profile?.name ?? "", + displayName: profile?.displayName ?? "", + about: profile?.about ?? "", + picture: profile?.picture ?? "", + banner: profile?.banner ?? "", + website: profile?.website ?? "", + nip05: profile?.nip05 ?? "", + lud16: profile?.lud16 ?? "", + }; + + return { + values, + original: { ...values }, + saving: false, + importing: false, + error: null, + success: null, + fieldErrors: {}, + showAdvanced: Boolean( + profile?.banner || profile?.website || profile?.nip05 || profile?.lud16 + ), + }; +} diff --git a/ui/src/ui/views/channels.nostr.ts b/ui/src/ui/views/channels.nostr.ts new file mode 100644 index 000000000..05152d80b --- /dev/null +++ b/ui/src/ui/views/channels.nostr.ts @@ -0,0 +1,217 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { ChannelAccountSnapshot, NostrStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { renderChannelConfigSection } from "./channels.config"; +import { + renderNostrProfileForm, + type NostrProfileFormState, + type NostrProfileFormCallbacks, +} from "./channels.nostr-profile-form"; + +/** + * Truncate a pubkey for display (shows first and last 8 chars) + */ +function truncatePubkey(pubkey: string | null | undefined): string { + if (!pubkey) return "n/a"; + if (pubkey.length <= 20) return pubkey; + return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`; +} + +export function renderNostrCard(params: { + props: ChannelsProps; + nostr?: NostrStatus | null; + nostrAccounts: ChannelAccountSnapshot[]; + accountCountLabel: unknown; + /** Profile form state (optional - if provided, shows form) */ + profileFormState?: NostrProfileFormState | null; + /** Profile form callbacks */ + profileFormCallbacks?: NostrProfileFormCallbacks | null; + /** Called when Edit Profile is clicked */ + onEditProfile?: () => void; +}) { + const { + props, + nostr, + nostrAccounts, + accountCountLabel, + profileFormState, + profileFormCallbacks, + onEditProfile, + } = params; + const primaryAccount = nostrAccounts[0]; + const summaryConfigured = nostr?.configured ?? primaryAccount?.configured ?? false; + const summaryRunning = nostr?.running ?? primaryAccount?.running ?? false; + const summaryPublicKey = + nostr?.publicKey ?? + (primaryAccount as { publicKey?: string } | undefined)?.publicKey; + const summaryLastStartAt = nostr?.lastStartAt ?? primaryAccount?.lastStartAt ?? null; + const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null; + const hasMultipleAccounts = nostrAccounts.length > 1; + const showingForm = profileFormState !== null && profileFormState !== undefined; + + const renderAccountCard = (account: ChannelAccountSnapshot) => { + const publicKey = (account as { publicKey?: string }).publicKey; + const profile = (account as { profile?: { name?: string; displayName?: string } }).profile; + const displayName = profile?.displayName ?? profile?.name ?? account.name ?? account.accountId; + + return html` + + `; + }; + + const renderProfileSection = () => { + // If showing form, render the form instead of the read-only view + if (showingForm && profileFormCallbacks) { + return renderNostrProfileForm({ + state: profileFormState, + callbacks: profileFormCallbacks, + accountId: nostrAccounts[0]?.accountId ?? "default", + }); + } + + const profile = + (primaryAccount as + | { + profile?: { + name?: string; + displayName?: string; + about?: string; + picture?: string; + nip05?: string; + }; + } + | undefined)?.profile ?? nostr?.profile; + const { name, displayName, about, picture, nip05 } = profile ?? {}; + const hasAnyProfileData = name || displayName || about || picture || nip05; + + return html` +
+
+
Profile
+ ${summaryConfigured + ? html` + + ` + : nothing} +
+ ${hasAnyProfileData + ? html` +
+ ${picture + ? html` +
+ Profile picture { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+ ` + : nothing} + ${name ? html`
Name${name}
` : nothing} + ${displayName + ? html`
Display Name${displayName}
` + : nothing} + ${about + ? html`
About${about}
` + : nothing} + ${nip05 ? html`
NIP-05${nip05}
` : nothing} +
+ ` + : html` +
+ No profile set. Click "Edit Profile" to add your name, bio, and avatar. +
+ `} +
+ `; + }; + + return html` +
+
Nostr
+
Decentralized DMs via Nostr relays (NIP-04).
+ ${accountCountLabel} + + ${hasMultipleAccounts + ? html` + + ` + : html` +
+
+ Configured + ${summaryConfigured ? "Yes" : "No"} +
+
+ Running + ${summaryRunning ? "Yes" : "No"} +
+
+ Public Key + ${truncatePubkey(summaryPublicKey)} +
+
+ Last start + ${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"} +
+
+ `} + + ${summaryLastError + ? html`
${summaryLastError}
` + : nothing} + + ${renderProfileSection()} + + ${renderChannelConfigSection({ channelId: "nostr", props })} + +
+ +
+
+ `; +} diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index 4489d7000..232cf2c85 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -7,6 +7,8 @@ import type { ChannelsStatusSnapshot, DiscordStatus, IMessageStatus, + NostrProfile, + NostrStatus, SignalStatus, SlackStatus, TelegramStatus, @@ -21,6 +23,7 @@ import { channelEnabled, renderChannelAccountCount } from "./channels.shared"; import { renderChannelConfigSection } from "./channels.config"; import { renderDiscordCard } from "./channels.discord"; import { renderIMessageCard } from "./channels.imessage"; +import { renderNostrCard } from "./channels.nostr"; import { renderSignalCard } from "./channels.signal"; import { renderSlackCard } from "./channels.slack"; import { renderTelegramCard } from "./channels.telegram"; @@ -38,6 +41,7 @@ export function renderChannels(props: ChannelsProps) { const slack = (channels?.slack ?? null) as SlackStatus | null; const signal = (channels?.signal ?? null) as SignalStatus | null; const imessage = (channels?.imessage ?? null) as IMessageStatus | null; + const nostr = (channels?.nostr ?? null) as NostrStatus | null; const channelOrder = resolveChannelOrder(props.snapshot); const orderedChannels = channelOrder .map((key, index) => ({ @@ -60,6 +64,7 @@ export function renderChannels(props: ChannelsProps) { slack, signal, imessage, + nostr, channelAccounts: props.snapshot?.channelAccounts ?? null, }), )} @@ -92,7 +97,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe if (snapshot?.channelOrder?.length) { return snapshot.channelOrder; } - return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]; + return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "nostr"]; } function renderChannel( @@ -142,6 +147,33 @@ function renderChannel( imessage: data.imessage, accountCountLabel, }); + case "nostr": { + const nostrAccounts = data.channelAccounts?.nostr ?? []; + const primaryAccount = nostrAccounts[0]; + const accountId = primaryAccount?.accountId ?? "default"; + const profile = + (primaryAccount as { profile?: NostrProfile | null } | undefined)?.profile ?? null; + const showForm = + props.nostrProfileAccountId === accountId ? props.nostrProfileFormState : null; + const profileFormCallbacks = showForm + ? { + onFieldChange: props.onNostrProfileFieldChange, + onSave: props.onNostrProfileSave, + onImport: props.onNostrProfileImport, + onCancel: props.onNostrProfileCancel, + onToggleAdvanced: props.onNostrProfileToggleAdvanced, + } + : null; + return renderNostrCard({ + props, + nostr: data.nostr, + nostrAccounts, + accountCountLabel, + profileFormState: showForm, + profileFormCallbacks, + onEditProfile: () => props.onNostrProfileEdit(accountId, profile), + }); + } default: return renderGenericChannelCard(key, props, data.channelAccounts ?? {}); } diff --git a/ui/src/ui/views/channels.types.ts b/ui/src/ui/views/channels.types.ts index 9984ee9c4..43576d54a 100644 --- a/ui/src/ui/views/channels.types.ts +++ b/ui/src/ui/views/channels.types.ts @@ -4,11 +4,14 @@ import type { ConfigUiHints, DiscordStatus, IMessageStatus, + NostrProfile, + NostrStatus, SignalStatus, SlackStatus, TelegramStatus, WhatsAppStatus, } from "../types"; +import type { NostrProfileFormState } from "./channels.nostr-profile-form"; export type ChannelKey = string; @@ -28,6 +31,8 @@ export type ChannelsProps = { configUiHints: ConfigUiHints; configSaving: boolean; configFormDirty: boolean; + nostrProfileFormState: NostrProfileFormState | null; + nostrProfileAccountId: string | null; onRefresh: (probe: boolean) => void; onWhatsAppStart: (force: boolean) => void; onWhatsAppWait: () => void; @@ -35,6 +40,12 @@ export type ChannelsProps = { onConfigPatch: (path: Array, value: unknown) => void; onConfigSave: () => void; onConfigReload: () => void; + onNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void; + onNostrProfileCancel: () => void; + onNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void; + onNostrProfileSave: () => void; + onNostrProfileImport: () => void; + onNostrProfileToggleAdvanced: () => void; }; export type ChannelsChannelData = { @@ -44,5 +55,6 @@ export type ChannelsChannelData = { slack?: SlackStatus | null; signal?: SignalStatus | null; imessage?: IMessageStatus | null; + nostr?: NostrStatus | null; channelAccounts?: Record | null; };