Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
142
extensions/nostr/README.md
Normal file
142
extensions/nostr/README.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# @openclaw/nostr
|
||||
|
||||
Nostr DM channel plugin for OpenClaw using NIP-04 encrypted direct messages.
|
||||
|
||||
## Overview
|
||||
|
||||
This extension adds Nostr as a messaging channel to OpenClaw. 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
|
||||
openclaw plugins install @openclaw/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
|
||||
|
||||
Inbound event signatures are verified before policy enforcement and NIP-04 decryption.
|
||||
Unknown senders in `pairing` mode can receive a pairing reply, but their original DM body is not
|
||||
processed unless approved.
|
||||
|
||||
### 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 openclaw 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
|
||||
- Sender policy is checked before expensive crypto work
|
||||
- Inbound DMs are rate-limited and oversized payloads are dropped before decrypt
|
||||
- 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
|
||||
11
extensions/nostr/api.ts
Normal file
11
extensions/nostr/api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Nostr API module exposes the plugin public contract.
|
||||
export {
|
||||
getPluginRuntimeGatewayRequestScope,
|
||||
type OpenClawConfig,
|
||||
type PluginRuntime,
|
||||
} from "./runtime-api.js";
|
||||
export { nostrPlugin } from "./src/channel.js";
|
||||
export { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
|
||||
export { getNostrRuntime, setNostrRuntime } from "./src/runtime.js";
|
||||
export { resolveNostrAccount } from "./src/types.js";
|
||||
export type { ResolvedNostrAccount } from "./src/types.js";
|
||||
2
extensions/nostr/channel-plugin-api.ts
Normal file
2
extensions/nostr/channel-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Nostr API module exposes the plugin public contract.
|
||||
export { nostrPlugin } from "./src/channel.js";
|
||||
134
extensions/nostr/doctor-contract-api.test.ts
Normal file
134
extensions/nostr/doctor-contract-api.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Nostr tests cover doctor contract api plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginDoctorStateMigrationContext,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { stateMigrations } from "./doctor-contract-api.js";
|
||||
|
||||
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
|
||||
return {
|
||||
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
|
||||
return createPluginStateKeyedStoreForTests<T>("nostr", {
|
||||
...options,
|
||||
env: options.env ?? env,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("nostr doctor state migration", () => {
|
||||
let stateDir = "";
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetPluginStateStoreForTests();
|
||||
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-nostr-doctor-"));
|
||||
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("imports legacy bus and profile state into plugin state", async () => {
|
||||
const nostrDir = path.join(stateDir, "nostr");
|
||||
const busPath = path.join(nostrDir, "bus-state-main.json");
|
||||
const profilePath = path.join(nostrDir, "profile-state-main.json");
|
||||
await fs.mkdir(nostrDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
busPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
lastProcessedAt: 1700,
|
||||
gatewayStartedAt: 1600,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
profilePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
lastPublishedAt: 1800,
|
||||
lastPublishedEventId: "event-1",
|
||||
lastPublishResults: { "wss://relay.example": "ok", bad: "nope" },
|
||||
}),
|
||||
);
|
||||
|
||||
const context = createDoctorContext(env);
|
||||
const busResult = await stateMigrations[0].migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context,
|
||||
});
|
||||
const profileResult = await stateMigrations[1].migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context,
|
||||
});
|
||||
|
||||
expect(busResult.warnings).toEqual([]);
|
||||
expect(profileResult.warnings).toEqual([]);
|
||||
await expect(fs.access(busPath)).rejects.toThrow();
|
||||
await expect(fs.access(profilePath)).rejects.toThrow();
|
||||
await expect(fs.access(`${busPath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(fs.access(`${profilePath}.migrated`)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
context.openPluginStateKeyedStore({ namespace: "bus-state", maxEntries: 256 }).lookup("main"),
|
||||
).resolves.toEqual({
|
||||
version: 2,
|
||||
lastProcessedAt: 1700,
|
||||
gatewayStartedAt: 1600,
|
||||
recentEventIds: [],
|
||||
});
|
||||
await expect(
|
||||
context
|
||||
.openPluginStateKeyedStore({ namespace: "profile-state", maxEntries: 256 })
|
||||
.lookup("main"),
|
||||
).resolves.toEqual({
|
||||
version: 1,
|
||||
lastPublishedAt: 1800,
|
||||
lastPublishedEventId: "event-1",
|
||||
lastPublishResults: { "wss://relay.example": "ok" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legacy account key bytes when importing state files", async () => {
|
||||
const nostrDir = path.join(stateDir, "nostr");
|
||||
const busPath = path.join(nostrDir, "bus-state-Team.A.json");
|
||||
await fs.mkdir(nostrDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
busPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
lastProcessedAt: 1700,
|
||||
gatewayStartedAt: 1600,
|
||||
}),
|
||||
);
|
||||
|
||||
const context = createDoctorContext(env);
|
||||
await stateMigrations[0].migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context,
|
||||
});
|
||||
|
||||
const store = context.openPluginStateKeyedStore({ namespace: "bus-state", maxEntries: 256 });
|
||||
await expect(store.lookup("Team.A")).resolves.toMatchObject({
|
||||
lastProcessedAt: 1700,
|
||||
});
|
||||
await expect(store.lookup("team-a")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
263
extensions/nostr/doctor-contract-api.ts
Normal file
263
extensions/nostr/doctor-contract-api.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// Nostr API module exposes the plugin public contract.
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
archiveLegacyStateSource,
|
||||
type PluginDoctorStateMigration,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { normalizeNostrStateAccountId } from "./src/state-account-id.js";
|
||||
|
||||
type NostrBusState = {
|
||||
version: 2;
|
||||
lastProcessedAt: number | null;
|
||||
gatewayStartedAt: number | null;
|
||||
recentEventIds: string[];
|
||||
};
|
||||
|
||||
type NostrProfileState = {
|
||||
version: 1;
|
||||
lastPublishedAt: number | null;
|
||||
lastPublishedEventId: string | null;
|
||||
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
|
||||
};
|
||||
|
||||
const BUS_STATE_NAMESPACE = "bus-state";
|
||||
const PROFILE_STATE_NAMESPACE = "profile-state";
|
||||
const MAX_NOSTR_STATE_ENTRIES = 256;
|
||||
|
||||
function finiteNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseBusState(value: unknown): NostrBusState | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = value as Record<string, unknown>;
|
||||
if (parsed.version !== 1 && parsed.version !== 2) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
lastProcessedAt: finiteNumberOrNull(parsed.lastProcessedAt),
|
||||
gatewayStartedAt: finiteNumberOrNull(parsed.gatewayStartedAt),
|
||||
recentEventIds:
|
||||
parsed.version === 2 && Array.isArray(parsed.recentEventIds)
|
||||
? parsed.recentEventIds.filter((entry): entry is string => typeof entry === "string")
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseProfileState(value: unknown): NostrProfileState | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = value as Record<string, unknown>;
|
||||
if (parsed.version !== 1) {
|
||||
return null;
|
||||
}
|
||||
const rawResults = parsed.lastPublishResults;
|
||||
const lastPublishResults: Record<string, "ok" | "failed" | "timeout"> = {};
|
||||
if (rawResults && typeof rawResults === "object" && !Array.isArray(rawResults)) {
|
||||
for (const [relay, result] of Object.entries(rawResults)) {
|
||||
if (result === "ok" || result === "failed" || result === "timeout") {
|
||||
lastPublishResults[relay] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
lastPublishedAt: finiteNumberOrNull(parsed.lastPublishedAt),
|
||||
lastPublishedEventId:
|
||||
typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null,
|
||||
lastPublishResults:
|
||||
rawResults === null || Object.keys(lastPublishResults).length === 0
|
||||
? null
|
||||
: lastPublishResults,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<unknown> {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
async function listLegacyFiles(params: {
|
||||
stateDir: string;
|
||||
prefix: string;
|
||||
parse: (value: unknown) => unknown;
|
||||
}): Promise<Array<{ accountId: string; filePath: string; value: unknown }>> {
|
||||
const dir = path.join(params.stateDir, "nostr");
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const suffix = ".json";
|
||||
const files: Array<{ accountId: string; filePath: string; value: unknown }> = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.startsWith(params.prefix) || !entry.name.endsWith(suffix)) {
|
||||
continue;
|
||||
}
|
||||
const rawAccountId = entry.name.slice(params.prefix.length, -suffix.length);
|
||||
const accountId = normalizeNostrStateAccountId(rawAccountId);
|
||||
const filePath = path.join(dir, entry.name);
|
||||
try {
|
||||
const value = params.parse(await readJsonFile(filePath));
|
||||
if (value) {
|
||||
files.push({ accountId, filePath, value });
|
||||
}
|
||||
} catch {
|
||||
// Malformed legacy cache/cursor files are ignored by migration.
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function ensureStoreCapacity(params: {
|
||||
files: Array<{ accountId: string }>;
|
||||
store: { entries: () => Promise<Array<{ key: string; value: unknown }>> };
|
||||
maxEntries: number;
|
||||
label: string;
|
||||
warnings: string[];
|
||||
}): Promise<Set<string> | null> {
|
||||
const existingKeys = new Set((await params.store.entries()).map((entry) => entry.key));
|
||||
const missingKeys = new Set(
|
||||
params.files.map((file) => file.accountId).filter((key) => !existingKeys.has(key)),
|
||||
);
|
||||
if (missingKeys.size > params.maxEntries - existingKeys.size) {
|
||||
params.warnings.push(
|
||||
`Skipped migrating ${params.label} because plugin state has room for ${params.maxEntries - existingKeys.size} of ${missingKeys.size} missing entries; left legacy sources in place`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return existingKeys;
|
||||
}
|
||||
|
||||
export const stateMigrations: PluginDoctorStateMigration[] = [
|
||||
{
|
||||
id: "nostr-bus-state-json-to-plugin-state",
|
||||
label: "Nostr bus state",
|
||||
async detectLegacyState(params) {
|
||||
const files = await listLegacyFiles({
|
||||
stateDir: params.stateDir,
|
||||
prefix: "bus-state-",
|
||||
parse: parseBusState,
|
||||
});
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
preview: [
|
||||
`- Nostr bus state: ${files.length} ${files.length === 1 ? "account" : "accounts"} -> plugin state (${BUS_STATE_NAMESPACE})`,
|
||||
],
|
||||
};
|
||||
},
|
||||
async migrateLegacyState(params) {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const files = await listLegacyFiles({
|
||||
stateDir: params.stateDir,
|
||||
prefix: "bus-state-",
|
||||
parse: parseBusState,
|
||||
});
|
||||
const store = params.context.openPluginStateKeyedStore<NostrBusState>({
|
||||
namespace: BUS_STATE_NAMESPACE,
|
||||
maxEntries: MAX_NOSTR_STATE_ENTRIES,
|
||||
});
|
||||
const existingKeys = await ensureStoreCapacity({
|
||||
files,
|
||||
store,
|
||||
maxEntries: MAX_NOSTR_STATE_ENTRIES,
|
||||
label: "Nostr bus state",
|
||||
warnings,
|
||||
});
|
||||
if (!existingKeys) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
let imported = 0;
|
||||
for (const file of files) {
|
||||
if (!existingKeys.has(file.accountId)) {
|
||||
await store.register(file.accountId, file.value as NostrBusState);
|
||||
existingKeys.add(file.accountId);
|
||||
imported++;
|
||||
}
|
||||
await archiveLegacyStateSource({
|
||||
filePath: file.filePath,
|
||||
label: "Nostr bus state",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
if (imported > 0) {
|
||||
changes.unshift(
|
||||
`Migrated ${imported} Nostr bus-state ${imported === 1 ? "entry" : "entries"} -> plugin state`,
|
||||
);
|
||||
}
|
||||
return { changes, warnings };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nostr-profile-state-json-to-plugin-state",
|
||||
label: "Nostr profile state",
|
||||
async detectLegacyState(params) {
|
||||
const files = await listLegacyFiles({
|
||||
stateDir: params.stateDir,
|
||||
prefix: "profile-state-",
|
||||
parse: parseProfileState,
|
||||
});
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
preview: [
|
||||
`- Nostr profile state: ${files.length} ${files.length === 1 ? "account" : "accounts"} -> plugin state (${PROFILE_STATE_NAMESPACE})`,
|
||||
],
|
||||
};
|
||||
},
|
||||
async migrateLegacyState(params) {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const files = await listLegacyFiles({
|
||||
stateDir: params.stateDir,
|
||||
prefix: "profile-state-",
|
||||
parse: parseProfileState,
|
||||
});
|
||||
const store = params.context.openPluginStateKeyedStore<NostrProfileState>({
|
||||
namespace: PROFILE_STATE_NAMESPACE,
|
||||
maxEntries: MAX_NOSTR_STATE_ENTRIES,
|
||||
});
|
||||
const existingKeys = await ensureStoreCapacity({
|
||||
files,
|
||||
store,
|
||||
maxEntries: MAX_NOSTR_STATE_ENTRIES,
|
||||
label: "Nostr profile state",
|
||||
warnings,
|
||||
});
|
||||
if (!existingKeys) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
let imported = 0;
|
||||
for (const file of files) {
|
||||
if (!existingKeys.has(file.accountId)) {
|
||||
await store.register(file.accountId, file.value as NostrProfileState);
|
||||
existingKeys.add(file.accountId);
|
||||
imported++;
|
||||
}
|
||||
await archiveLegacyStateSource({
|
||||
filePath: file.filePath,
|
||||
label: "Nostr profile state",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
if (imported > 0) {
|
||||
changes.unshift(
|
||||
`Migrated ${imported} Nostr profile-state ${imported === 1 ? "entry" : "entries"} -> plugin state`,
|
||||
);
|
||||
}
|
||||
return { changes, warnings };
|
||||
},
|
||||
},
|
||||
];
|
||||
96
extensions/nostr/index.ts
Normal file
96
extensions/nostr/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Nostr plugin entrypoint registers its OpenClaw integration.
|
||||
import {
|
||||
defineBundledChannelEntry,
|
||||
loadBundledEntryExportSync,
|
||||
} from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
import type { OpenClawConfig, PluginRuntime, ResolvedNostrAccount } from "./api.js";
|
||||
|
||||
function createNostrProfileHttpHandler() {
|
||||
return loadBundledEntryExportSync<
|
||||
(params: Record<string, unknown>) => (ctx: unknown) => Promise<void> | void
|
||||
>(import.meta.url, {
|
||||
specifier: "./api.js",
|
||||
exportName: "createNostrProfileHttpHandler",
|
||||
});
|
||||
}
|
||||
|
||||
function getNostrRuntime() {
|
||||
return loadBundledEntryExportSync<() => PluginRuntime>(import.meta.url, {
|
||||
specifier: "./api.js",
|
||||
exportName: "getNostrRuntime",
|
||||
})();
|
||||
}
|
||||
|
||||
function resolveNostrAccount(params: { cfg: unknown; accountId: string }) {
|
||||
return loadBundledEntryExportSync<
|
||||
(params: { cfg: unknown; accountId: string }) => ResolvedNostrAccount
|
||||
>(import.meta.url, {
|
||||
specifier: "./api.js",
|
||||
exportName: "resolveNostrAccount",
|
||||
})(params);
|
||||
}
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "nostr",
|
||||
name: "Nostr",
|
||||
description: "Nostr DM channel plugin via NIP-04",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "nostrPlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./api.js",
|
||||
exportName: "setNostrRuntime",
|
||||
},
|
||||
registerFull(api) {
|
||||
const httpHandler = createNostrProfileHttpHandler()({
|
||||
getConfigProfile: (accountId: string) => {
|
||||
const runtime = getNostrRuntime();
|
||||
const cfg = runtime.config.current() as OpenClawConfig;
|
||||
const account = resolveNostrAccount({ cfg, accountId });
|
||||
return account.profile;
|
||||
},
|
||||
updateConfigProfile: async (_accountId: string, profile: unknown) => {
|
||||
const runtime = getNostrRuntime();
|
||||
|
||||
await runtime.config.mutateConfigFile({
|
||||
afterWrite: { mode: "auto" },
|
||||
mutate: (draft) => {
|
||||
const channels = (draft.channels ?? {}) as Record<string, unknown>;
|
||||
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
|
||||
|
||||
draft.channels = {
|
||||
...channels,
|
||||
nostr: {
|
||||
...nostrConfig,
|
||||
profile,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
getAccountInfo: (accountId: string) => {
|
||||
const runtime = getNostrRuntime();
|
||||
const cfg = runtime.config.current() as OpenClawConfig;
|
||||
const account = resolveNostrAccount({ cfg, accountId });
|
||||
if (!account.configured || !account.publicKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pubkey: account.publicKey,
|
||||
relays: account.relays,
|
||||
};
|
||||
},
|
||||
log: api.logger,
|
||||
});
|
||||
|
||||
api.registerHttpRoute({
|
||||
path: "/api/channels/nostr",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
gatewayRuntimeScopeSurface: "trusted-operator",
|
||||
handler: httpHandler,
|
||||
});
|
||||
},
|
||||
});
|
||||
137
extensions/nostr/npm-shrinkwrap.json
generated
Normal file
137
extensions/nostr/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"name": "@openclaw/nostr",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/nostr",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"nostr-tools": "2.23.9",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools": {
|
||||
"version": "2.23.9",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.9.tgz",
|
||||
"integrity": "sha512-PBN3TpGh+EszlTZWpQdvcqEMqhcKcY1vTw0Xkkccg+TyP7y/icu9/iXJw72aFZ/ETm35ehU0hneGTXLaTSkImw==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "2.1.1",
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0",
|
||||
"@scure/bip32": "2.0.1",
|
||||
"@scure/bip39": "2.0.1",
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-wasm": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
||||
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
extensions/nostr/openclaw.plugin.json
Normal file
17
extensions/nostr/openclaw.plugin.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "nostr",
|
||||
"name": "Nostr",
|
||||
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted direct messages.",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["nostr"],
|
||||
"channelEnvVars": {
|
||||
"nostr": ["NOSTR_PRIVATE_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
67
extensions/nostr/package.json
Normal file
67
extensions/nostr/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@openclaw/nostr",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted direct messages.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"nostr-tools": "2.23.9",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.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,
|
||||
"cliAddOptions": [
|
||||
{
|
||||
"flags": "--private-key <key>",
|
||||
"description": "Nostr private key (nsec... or hex)"
|
||||
},
|
||||
{
|
||||
"flags": "--relay-urls <list>",
|
||||
"description": "Nostr relay URLs (comma-separated)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/nostr",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
6
extensions/nostr/runtime-api.ts
Normal file
6
extensions/nostr/runtime-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Private runtime barrel for the bundled Nostr extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
export { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
2
extensions/nostr/setup-api.ts
Normal file
2
extensions/nostr/setup-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Nostr API module exposes the plugin public contract.
|
||||
export { nostrSetupAdapter, nostrSetupWizard } from "./src/setup-surface.js";
|
||||
10
extensions/nostr/setup-entry.ts
Normal file
10
extensions/nostr/setup-entry.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Nostr plugin module implements setup entry behavior.
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "nostrSetupPlugin",
|
||||
},
|
||||
});
|
||||
3
extensions/nostr/setup-plugin-api.ts
Normal file
3
extensions/nostr/setup-plugin-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Keep bundled setup entry imports narrow so setup loads do not pull the
|
||||
// broader Nostr runtime plugin surface.
|
||||
export { nostrSetupPlugin } from "./src/channel.setup.js";
|
||||
12
extensions/nostr/src/channel-api.ts
Normal file
12
extensions/nostr/src/channel-api.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Nostr API module exposes the plugin public contract.
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatPairingApproveHint,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
export type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
export {
|
||||
collectStatusIssuesFromLastError,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
203
extensions/nostr/src/channel.inbound.test.ts
Normal file
203
extensions/nostr/src/channel.inbound.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// Nostr tests cover channel.inbound plugin behavior.
|
||||
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import { startNostrGatewayAccount } from "./gateway.js";
|
||||
import { setNostrRuntime } from "./runtime.js";
|
||||
import { buildResolvedNostrAccount } from "./test-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
normalizePubkey: vi.fn((value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.replace(/^nostr:/i, "")
|
||||
.toLowerCase(),
|
||||
),
|
||||
startNostrBus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./nostr-bus.js", () => ({
|
||||
DEFAULT_RELAYS: ["wss://relay.example.com"],
|
||||
startNostrBus: mocks.startNostrBus,
|
||||
}));
|
||||
|
||||
vi.mock("./nostr-key-utils.js", () => ({
|
||||
getPublicKeyFromPrivate: vi.fn(() => "bot-pubkey"),
|
||||
normalizePubkey: mocks.normalizePubkey,
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
await import("./inbound-direct-dm-runtime.js");
|
||||
});
|
||||
|
||||
function createMockBus() {
|
||||
return {
|
||||
sendDm: vi.fn(async () => {}),
|
||||
close: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({ counters: {} })),
|
||||
publishProfile: vi.fn(),
|
||||
getProfileState: vi.fn(async () => null),
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeHarness() {
|
||||
const recordInboundSession = vi.fn(async () => {});
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "|a|b|" });
|
||||
});
|
||||
const runtime = {
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode: vi.fn(() => "off"),
|
||||
convertMarkdownTables: vi.fn((text: string) => `converted:${text}`),
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => true),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => true),
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: vi.fn(({ accountId, peer }) => ({
|
||||
agentId: "agent-nostr",
|
||||
accountId,
|
||||
sessionKey: `nostr:${peer.id}`,
|
||||
})),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn(() => "/tmp/nostr-session-store"),
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordInboundSession,
|
||||
},
|
||||
reply: {
|
||||
formatAgentEnvelope: vi.fn(({ body }) => `envelope:${body}`),
|
||||
resolveEnvelopeFormatOptions: vi.fn(() => ({ mode: "agent" })),
|
||||
finalizeInboundContext: vi.fn((ctx) => ctx),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
},
|
||||
pairing: {
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
upsertPairingRequest: vi.fn(async () => ({ code: "PAIR1234", created: true })),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
return {
|
||||
runtime,
|
||||
recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
};
|
||||
}
|
||||
|
||||
async function startGatewayHarness(params: {
|
||||
account: ReturnType<typeof buildResolvedNostrAccount>;
|
||||
cfg?: Parameters<typeof createStartAccountContext>[0]["cfg"];
|
||||
}) {
|
||||
const harness = createRuntimeHarness();
|
||||
const bus = createMockBus();
|
||||
setNostrRuntime(harness.runtime);
|
||||
mocks.startNostrBus.mockResolvedValueOnce(bus as never);
|
||||
const abort = new AbortController();
|
||||
|
||||
const task = startNostrGatewayAccount(
|
||||
createStartAccountContext({
|
||||
account: params.account,
|
||||
cfg: params.cfg,
|
||||
abortSignal: abort.signal,
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.startNostrBus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const cleanup = {
|
||||
stop: async () => {
|
||||
abort.abort();
|
||||
await task;
|
||||
},
|
||||
};
|
||||
|
||||
return { harness, bus, cleanup };
|
||||
}
|
||||
|
||||
function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
|
||||
const call = mock.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`Expected mock call ${callIndex}`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
describe("nostr inbound gateway path", () => {
|
||||
afterEach(() => {
|
||||
mocks.normalizePubkey.mockClear();
|
||||
mocks.startNostrBus.mockReset();
|
||||
});
|
||||
|
||||
it("issues a pairing reply before decrypt for unknown senders", async () => {
|
||||
const { cleanup } = await startGatewayHarness({
|
||||
account: buildResolvedNostrAccount({
|
||||
config: { dmPolicy: "pairing", allowFrom: [] },
|
||||
}),
|
||||
});
|
||||
|
||||
const options = mockCallArg(mocks.startNostrBus) as {
|
||||
authorizeSender: (params: {
|
||||
senderPubkey: string;
|
||||
reply: (text: string) => Promise<void>;
|
||||
}) => Promise<string>;
|
||||
};
|
||||
const sendPairingReply = vi.fn(async (_text: string) => {});
|
||||
|
||||
await expect(
|
||||
options.authorizeSender({
|
||||
senderPubkey: "nostr:UNKNOWN-SENDER",
|
||||
reply: sendPairingReply,
|
||||
}),
|
||||
).resolves.toBe("pairing");
|
||||
expect(sendPairingReply).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallArg(sendPairingReply)).toContain("Pairing code:");
|
||||
|
||||
await cleanup.stop();
|
||||
});
|
||||
|
||||
it("routes allowed DMs through the standard reply pipeline", async () => {
|
||||
const { harness, cleanup } = await startGatewayHarness({
|
||||
account: buildResolvedNostrAccount({
|
||||
publicKey: "bot-pubkey",
|
||||
config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] },
|
||||
}),
|
||||
cfg: {
|
||||
session: { store: { type: "jsonl" } },
|
||||
commands: { useAccessGroups: true },
|
||||
} as never,
|
||||
});
|
||||
|
||||
const options = mockCallArg(mocks.startNostrBus) as {
|
||||
onMessage: (
|
||||
senderPubkey: string,
|
||||
text: string,
|
||||
reply: (text: string) => Promise<void>,
|
||||
meta: { eventId: string; createdAt: number },
|
||||
) => Promise<void>;
|
||||
};
|
||||
const sendReply = vi.fn(async (_text: string) => {});
|
||||
|
||||
await options.onMessage("sender-pubkey", "hello from nostr", sendReply, {
|
||||
eventId: "event-123",
|
||||
createdAt: 1_710_000_000,
|
||||
});
|
||||
|
||||
expect(harness.recordInboundSession).toHaveBeenCalledTimes(1);
|
||||
expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
const ctx = (
|
||||
mockCallArg(harness.dispatchReplyWithBufferedBlockDispatcher) as {
|
||||
ctx?: Record<string, unknown>;
|
||||
}
|
||||
).ctx;
|
||||
expect(ctx?.BodyForAgent).toBe("hello from nostr");
|
||||
expect(ctx?.SenderId).toBe("sender-pubkey");
|
||||
expect(ctx?.MessageSid).toBe("event-123");
|
||||
expect(ctx?.CommandAuthorized).toBe(true);
|
||||
expect(sendReply).toHaveBeenCalledWith("converted:|a|b|");
|
||||
|
||||
await cleanup.stop();
|
||||
});
|
||||
});
|
||||
97
extensions/nostr/src/channel.lifecycle.test.ts
Normal file
97
extensions/nostr/src/channel.lifecycle.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// Nostr tests cover channel.lifecycle plugin behavior.
|
||||
import {
|
||||
createStartAccountContext,
|
||||
createPluginRuntimeMock,
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getActiveNostrBuses, startNostrGatewayAccount } from "./gateway.js";
|
||||
import { setNostrRuntime } from "./runtime.js";
|
||||
import { buildResolvedNostrAccount } from "./test-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
startNostrBus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./nostr-bus.js", () => ({
|
||||
DEFAULT_RELAYS: ["wss://relay.example.com"],
|
||||
startNostrBus: mocks.startNostrBus,
|
||||
}));
|
||||
|
||||
function createMockBus() {
|
||||
return {
|
||||
sendDm: vi.fn(async () => {}),
|
||||
close: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({ counters: {} })),
|
||||
publishProfile: vi.fn(),
|
||||
getProfileState: vi.fn(async () => null),
|
||||
};
|
||||
}
|
||||
|
||||
describe("nostr gateway lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
setNostrRuntime(createPluginRuntimeMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mocks.startNostrBus.mockReset();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then closes the bus", async () => {
|
||||
const bus = createMockBus();
|
||||
mocks.startNostrBus.mockResolvedValueOnce(bus as never);
|
||||
|
||||
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: startNostrGatewayAccount,
|
||||
account: buildResolvedNostrAccount(),
|
||||
});
|
||||
|
||||
await expectStopPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(mocks.startNostrBus),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
stop: bus.close,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the active bus registered while pending and removes it after abort", async () => {
|
||||
const bus = createMockBus();
|
||||
mocks.startNostrBus.mockResolvedValueOnce(bus as never);
|
||||
|
||||
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: startNostrGatewayAccount,
|
||||
account: buildResolvedNostrAccount(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getActiveNostrBuses().get("default")).toBe(bus);
|
||||
});
|
||||
expect(isSettled()).toBe(false);
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
|
||||
expect(bus.close).toHaveBeenCalledOnce();
|
||||
expect(getActiveNostrBuses().has("default")).toBe(false);
|
||||
});
|
||||
|
||||
it("stops immediately when startAccount receives an already-aborted signal", async () => {
|
||||
const bus = createMockBus();
|
||||
mocks.startNostrBus.mockResolvedValueOnce(bus as never);
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
|
||||
await startNostrGatewayAccount(
|
||||
createStartAccountContext({
|
||||
account: buildResolvedNostrAccount(),
|
||||
abortSignal: abort.signal,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.startNostrBus).toHaveBeenCalledOnce();
|
||||
expect(bus.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
175
extensions/nostr/src/channel.outbound.test.ts
Normal file
175
extensions/nostr/src/channel.outbound.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// Nostr tests cover channel.outbound plugin behavior.
|
||||
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import { nostrPlugin } from "./channel.js";
|
||||
import { nostrOutboundAdapter, startNostrGatewayAccount } from "./gateway.js";
|
||||
import { setNostrRuntime } from "./runtime.js";
|
||||
import { TEST_RESOLVED_PRIVATE_KEY, buildResolvedNostrAccount } from "./test-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
normalizePubkey: vi.fn((value: string) => `normalized-${value.toLowerCase()}`),
|
||||
startNostrBus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./nostr-bus.js", () => ({
|
||||
DEFAULT_RELAYS: ["wss://relay.example.com"],
|
||||
startNostrBus: mocks.startNostrBus,
|
||||
}));
|
||||
|
||||
vi.mock("./nostr-key-utils.js", () => ({
|
||||
getPublicKeyFromPrivate: vi.fn(() => "pubkey"),
|
||||
normalizePubkey: mocks.normalizePubkey,
|
||||
}));
|
||||
|
||||
function createCfg() {
|
||||
return {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: TEST_RESOLVED_PRIVATE_KEY, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installOutboundRuntime(convertMarkdownTables = vi.fn((text: string) => text)) {
|
||||
const resolveMarkdownTableMode = vi.fn(() => "off");
|
||||
setNostrRuntime({
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode,
|
||||
convertMarkdownTables,
|
||||
},
|
||||
},
|
||||
reply: {},
|
||||
} as unknown as PluginRuntime);
|
||||
return { resolveMarkdownTableMode, convertMarkdownTables };
|
||||
}
|
||||
|
||||
async function startOutboundAccount(accountId?: string) {
|
||||
const sendDm = vi.fn(async () => {});
|
||||
const bus = {
|
||||
sendDm,
|
||||
close: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({ counters: {} })),
|
||||
publishProfile: vi.fn(),
|
||||
getProfileState: vi.fn(async () => null),
|
||||
};
|
||||
mocks.startNostrBus.mockResolvedValueOnce(bus as unknown);
|
||||
const abort = new AbortController();
|
||||
|
||||
const task = startNostrGatewayAccount(
|
||||
createStartAccountContext({
|
||||
account: buildResolvedNostrAccount(accountId ? { accountId } : undefined),
|
||||
abortSignal: abort.signal,
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.startNostrBus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const cleanup = {
|
||||
stop: async () => {
|
||||
abort.abort();
|
||||
await task;
|
||||
},
|
||||
};
|
||||
|
||||
return { cleanup, sendDm };
|
||||
}
|
||||
|
||||
describe("nostr outbound cfg threading", () => {
|
||||
afterEach(() => {
|
||||
mocks.normalizePubkey.mockClear();
|
||||
mocks.startNostrBus.mockReset();
|
||||
});
|
||||
|
||||
it("uses resolved cfg when converting markdown tables before send", async () => {
|
||||
const { resolveMarkdownTableMode, convertMarkdownTables } = installOutboundRuntime(
|
||||
vi.fn((text: string) => `converted:${text}`),
|
||||
);
|
||||
const { cleanup, sendDm } = await startOutboundAccount();
|
||||
|
||||
const cfg = createCfg();
|
||||
await nostrOutboundAdapter.sendText({
|
||||
cfg: cfg as OpenClawConfig,
|
||||
to: "NPUB123",
|
||||
text: "|a|b|",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveMarkdownTableMode).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
channel: "nostr",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(convertMarkdownTables).toHaveBeenCalledWith("|a|b|", "off");
|
||||
expect(mocks.normalizePubkey).toHaveBeenCalledWith("NPUB123");
|
||||
expect(sendDm).toHaveBeenCalledWith("normalized-npub123", "converted:|a|b|");
|
||||
|
||||
await cleanup.stop();
|
||||
});
|
||||
|
||||
it("uses the configured defaultAccount when accountId is omitted", async () => {
|
||||
const { resolveMarkdownTableMode } = installOutboundRuntime();
|
||||
const { cleanup, sendDm } = await startOutboundAccount("work");
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: TEST_RESOLVED_PRIVATE_KEY, // pragma: allowlist secret
|
||||
defaultAccount: "work",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await nostrOutboundAdapter.sendText({
|
||||
cfg: cfg as OpenClawConfig,
|
||||
to: "NPUB123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
expect(resolveMarkdownTableMode).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
channel: "nostr",
|
||||
accountId: "work",
|
||||
});
|
||||
expect(sendDm).toHaveBeenCalledWith("normalized-npub123", "hello");
|
||||
|
||||
await cleanup.stop();
|
||||
});
|
||||
|
||||
it("backs declared message adapter capabilities with outbound sends", async () => {
|
||||
installOutboundRuntime();
|
||||
const { cleanup, sendDm } = await startOutboundAccount();
|
||||
const adapter = nostrPlugin.message;
|
||||
if (!adapter?.send?.text) {
|
||||
throw new Error("expected Nostr message adapter with text sender");
|
||||
}
|
||||
const sendText = adapter.send.text;
|
||||
expect(adapter.send.media).toBeUndefined();
|
||||
|
||||
await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "nostrMessageAdapter",
|
||||
adapter,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await sendText({
|
||||
cfg: createCfg() as OpenClawConfig,
|
||||
to: "NPUB123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(sendDm).toHaveBeenCalledWith("normalized-npub123", "hello");
|
||||
expect(result.receipt.parts[0]?.kind).toBe("text");
|
||||
},
|
||||
messageSendingHooks: () => {
|
||||
expect(sendText).toBeTypeOf("function");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await cleanup.stop();
|
||||
});
|
||||
});
|
||||
161
extensions/nostr/src/channel.setup.ts
Normal file
161
extensions/nostr/src/channel.setup.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// Nostr plugin module implements channel.setup behavior.
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
createDelegatedSetupWizardProxy,
|
||||
createStandardChannelSetupStatus,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
createSetupTranslator,
|
||||
} from "openclaw/plugin-sdk/setup-runtime";
|
||||
import { buildChannelConfigSchema, type ChannelPlugin } from "./channel-api.js";
|
||||
import { NostrConfigSchema } from "./config-schema.js";
|
||||
import { DEFAULT_RELAYS } from "./default-relays.js";
|
||||
import { createNostrSetupAdapter } from "./setup-adapter.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "nostr" as const;
|
||||
|
||||
type NostrAccountConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
defaultAccount?: string;
|
||||
privateKey?: unknown;
|
||||
relays?: string[];
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
profile?: unknown;
|
||||
};
|
||||
|
||||
type ResolvedNostrSetupAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
relays: string[];
|
||||
profile?: unknown;
|
||||
config: NostrAccountConfig;
|
||||
};
|
||||
|
||||
function getNostrConfig(cfg: OpenClawConfig): NostrAccountConfig | undefined {
|
||||
return (cfg.channels as Record<string, unknown> | undefined)?.nostr as
|
||||
| NostrAccountConfig
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function listSetupNostrAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const nostrCfg = getNostrConfig(cfg);
|
||||
const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : "";
|
||||
if (!privateKey) {
|
||||
return [];
|
||||
}
|
||||
return [resolveDefaultSetupNostrAccountId(cfg)];
|
||||
}
|
||||
|
||||
function resolveDefaultSetupNostrAccountId(cfg: OpenClawConfig): string {
|
||||
const configured = getNostrConfig(cfg)?.defaultAccount;
|
||||
return typeof configured === "string" && configured.trim()
|
||||
? configured.trim()
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveSetupNostrAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedNostrSetupAccount {
|
||||
const nostrCfg = getNostrConfig(params.cfg);
|
||||
const accountId = params.accountId?.trim() || resolveDefaultSetupNostrAccountId(params.cfg);
|
||||
const privateKey = typeof nostrCfg?.privateKey === "string" ? nostrCfg.privateKey.trim() : "";
|
||||
const configured = Boolean(privateKey);
|
||||
return {
|
||||
accountId,
|
||||
name: typeof nostrCfg?.name === "string" ? nostrCfg.name : undefined,
|
||||
enabled: nostrCfg?.enabled !== false,
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function looksLikeNostrPrivateKey(privateKey: string): boolean {
|
||||
return privateKey.startsWith("nsec1") || /^[0-9a-fA-F]{64}$/.test(privateKey);
|
||||
}
|
||||
|
||||
const nostrSetupAdapter = createNostrSetupAdapter({
|
||||
resolveAccountId: (cfg, accountId) => accountId?.trim() || resolveDefaultSetupNostrAccountId(cfg),
|
||||
validatePrivateKey: looksLikeNostrPrivateKey,
|
||||
});
|
||||
|
||||
const nostrSetupWizard = createDelegatedSetupWizardProxy({
|
||||
channel,
|
||||
loadWizard: async () => (await import("./setup-surface.js")).nostrSetupWizard,
|
||||
status: {
|
||||
...createStandardChannelSetupStatus({
|
||||
channelLabel: "Nostr",
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsPrivateKey"),
|
||||
configuredHint: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredHint: t("wizard.channels.statusNeedsPrivateKey"),
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 0,
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
resolveSetupNostrAccount({ cfg, accountId }).configured,
|
||||
resolveExtraStatusLines: ({ cfg }) => {
|
||||
const account = resolveSetupNostrAccount({ cfg });
|
||||
return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`];
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShouldPromptAccountIds: () => false,
|
||||
delegatePrepare: true,
|
||||
delegateFinalize: true,
|
||||
});
|
||||
|
||||
export const nostrSetupPlugin: ChannelPlugin<ResolvedNostrSetupAccount> = {
|
||||
id: channel,
|
||||
meta: {
|
||||
id: channel,
|
||||
label: "Nostr",
|
||||
selectionLabel: "Nostr",
|
||||
docsPath: "/channels/nostr",
|
||||
docsLabel: "nostr",
|
||||
blurb: "Decentralized DMs via Nostr relays (NIP-04)",
|
||||
order: 100,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
media: false,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.nostr"] },
|
||||
configSchema: buildChannelConfigSchema(NostrConfigSchema),
|
||||
setup: nostrSetupAdapter,
|
||||
setupWizard: nostrSetupWizard,
|
||||
config: {
|
||||
listAccountIds: listSetupNostrAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveSetupNostrAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultSetupNostrAccountId,
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
publicKey: account.publicKey,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
527
extensions/nostr/src/channel.test.ts
Normal file
527
extensions/nostr/src/channel.test.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
// Nostr tests cover channel plugin behavior.
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { nostrSetupWizard } from "./setup-surface.js";
|
||||
import {
|
||||
TEST_HEX_PRIVATE_KEY,
|
||||
TEST_SETUP_RELAY_URLS,
|
||||
buildResolvedNostrAccount,
|
||||
createConfiguredNostrCfg,
|
||||
} from "./test-fixtures.js";
|
||||
import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js";
|
||||
|
||||
function normalizeNostrTestEntry(entry: string): string {
|
||||
return entry
|
||||
.trim()
|
||||
.replace(/^nostr:/i, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function resolveNostrTestDmPolicy(params: {
|
||||
cfg: OpenClawConfig;
|
||||
account: ReturnType<typeof resolveNostrAccount>;
|
||||
}) {
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
accountId: params.account.accountId,
|
||||
policy: params.account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: params.account.config.allowFrom ?? [],
|
||||
normalizeEntry: normalizeNostrTestEntry,
|
||||
};
|
||||
}
|
||||
|
||||
const nostrTestPlugin = {
|
||||
id: "nostr",
|
||||
meta: {
|
||||
label: "Nostr",
|
||||
docsPath: "/channels/nostr",
|
||||
blurb: "Decentralized DMs via Nostr relays (NIP-04)",
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
media: false,
|
||||
},
|
||||
config: {
|
||||
listAccountIds: listNostrAccountIds,
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
||||
resolveNostrAccount({ cfg, accountId }),
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (target: string) => normalizeNostrTestEntry(target),
|
||||
targetResolver: {
|
||||
looksLikeId: (input: string) => {
|
||||
const trimmed = input.trim();
|
||||
return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
|
||||
},
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
textChunkLimit: 4000,
|
||||
},
|
||||
pairing: {
|
||||
idLabel: "nostrPubkey",
|
||||
normalizeAllowEntry: normalizeNostrTestEntry,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveNostrTestDmPolicy,
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: "default",
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
},
|
||||
setupWizard: nostrSetupWizard,
|
||||
setup: {
|
||||
resolveAccountId: ({
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
input: unknown;
|
||||
}) => accountId?.trim() || resolveDefaultNostrAccountId(cfg),
|
||||
},
|
||||
};
|
||||
|
||||
const nostrConfigure = createPluginSetupWizardConfigure(nostrTestPlugin);
|
||||
|
||||
function requireNostrLooksLikeId() {
|
||||
const looksLikeId = nostrTestPlugin.messaging?.targetResolver?.looksLikeId;
|
||||
if (!looksLikeId) {
|
||||
throw new Error("nostr messaging.targetResolver.looksLikeId missing");
|
||||
}
|
||||
return looksLikeId;
|
||||
}
|
||||
|
||||
function requireNostrNormalizeTarget() {
|
||||
const normalize = nostrTestPlugin.messaging?.normalizeTarget;
|
||||
if (!normalize) {
|
||||
throw new Error("nostr messaging.normalizeTarget missing");
|
||||
}
|
||||
return normalize;
|
||||
}
|
||||
|
||||
function requireNostrPairingNormalizer() {
|
||||
const normalize = nostrTestPlugin.pairing?.normalizeAllowEntry;
|
||||
if (!normalize) {
|
||||
throw new Error("nostr pairing.normalizeAllowEntry missing");
|
||||
}
|
||||
return normalize;
|
||||
}
|
||||
|
||||
function requireNostrResolveDmPolicy() {
|
||||
const resolveDmPolicy = nostrTestPlugin.security?.resolveDmPolicy;
|
||||
if (!resolveDmPolicy) {
|
||||
throw new Error("nostr security.resolveDmPolicy missing");
|
||||
}
|
||||
return resolveDmPolicy;
|
||||
}
|
||||
|
||||
function createUnresolvedNostrPrivateKeyCfg() {
|
||||
return {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: {
|
||||
source: "env" as const,
|
||||
provider: "default",
|
||||
id: "NOSTR_PRIVATE_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const unresolvedSecretRefPrivateKeyCases = [
|
||||
{
|
||||
name: "listNostrAccountIds",
|
||||
assert: (cfg: ReturnType<typeof createUnresolvedNostrPrivateKeyCfg>) => {
|
||||
expect(listNostrAccountIds(cfg)).toStrictEqual([]);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolveNostrAccount",
|
||||
assert: (cfg: ReturnType<typeof createUnresolvedNostrPrivateKeyCfg>) => {
|
||||
const account = resolveNostrAccount({ cfg });
|
||||
|
||||
expect(account.configured).toBe(false);
|
||||
expect(account.privateKey).toBe("");
|
||||
expect(account.publicKey).toBe("");
|
||||
expect(account.config.privateKey).toEqual(cfg.channels.nostr.privateKey);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("nostrPlugin", () => {
|
||||
describe("meta", () => {
|
||||
it("has correct id", () => {
|
||||
expect(nostrTestPlugin.id).toBe("nostr");
|
||||
});
|
||||
|
||||
it("has required meta fields", () => {
|
||||
expect(nostrTestPlugin.meta.label).toBe("Nostr");
|
||||
expect(nostrTestPlugin.meta.docsPath).toBe("/channels/nostr");
|
||||
expect(nostrTestPlugin.meta.blurb).toContain("NIP-04");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities", () => {
|
||||
it("supports direct messages", () => {
|
||||
expect(nostrTestPlugin.capabilities.chatTypes).toContain("direct");
|
||||
});
|
||||
|
||||
it("does not support groups (MVP)", () => {
|
||||
expect(nostrTestPlugin.capabilities.chatTypes).not.toContain("group");
|
||||
});
|
||||
|
||||
it("does not support media (MVP)", () => {
|
||||
expect(nostrTestPlugin.capabilities.media).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config adapter", () => {
|
||||
it("listAccountIds returns empty array for unconfigured", () => {
|
||||
const cfg = { channels: {} };
|
||||
const ids = nostrTestPlugin.config.listAccountIds(cfg);
|
||||
expect(ids).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("listAccountIds returns default for configured", () => {
|
||||
const cfg = createConfiguredNostrCfg();
|
||||
const ids = nostrTestPlugin.config.listAccountIds(cfg);
|
||||
expect(ids).toContain("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging", () => {
|
||||
it("recognizes npub as valid target", () => {
|
||||
const looksLikeId = requireNostrLooksLikeId();
|
||||
|
||||
expect(looksLikeId("npub1xyz123")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes hex pubkey as valid target", () => {
|
||||
const looksLikeId = requireNostrLooksLikeId();
|
||||
|
||||
expect(looksLikeId(TEST_HEX_PRIVATE_KEY)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid input", () => {
|
||||
const looksLikeId = requireNostrLooksLikeId();
|
||||
|
||||
expect(looksLikeId("not-a-pubkey")).toBe(false);
|
||||
expect(looksLikeId("")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizeTarget strips spaced nostr prefixes", () => {
|
||||
const normalize = requireNostrNormalizeTarget();
|
||||
|
||||
expect(normalize(`nostr:${TEST_HEX_PRIVATE_KEY}`)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
expect(normalize(` nostr:${TEST_HEX_PRIVATE_KEY} `)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outbound", () => {
|
||||
it("has correct delivery mode", () => {
|
||||
expect(nostrTestPlugin.outbound?.deliveryMode).toBe("direct");
|
||||
});
|
||||
|
||||
it("has reasonable text chunk limit", () => {
|
||||
expect(nostrTestPlugin.outbound?.textChunkLimit).toBe(4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairing", () => {
|
||||
it("has id label for pairing", () => {
|
||||
expect(nostrTestPlugin.pairing?.idLabel).toBe("nostrPubkey");
|
||||
});
|
||||
|
||||
it("normalizes spaced nostr prefixes in allow entries", () => {
|
||||
const normalize = requireNostrPairingNormalizer();
|
||||
|
||||
expect(normalize(`nostr:${TEST_HEX_PRIVATE_KEY}`)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
expect(normalize(` nostr:${TEST_HEX_PRIVATE_KEY} `)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security", () => {
|
||||
it("normalizes dm allowlist entries through the dm policy adapter", () => {
|
||||
const resolveDmPolicy = requireNostrResolveDmPolicy();
|
||||
|
||||
const cfg = createConfiguredNostrCfg({
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [` nostr:${TEST_HEX_PRIVATE_KEY} `],
|
||||
});
|
||||
const account = buildResolvedNostrAccount({
|
||||
config: cfg.channels.nostr,
|
||||
});
|
||||
|
||||
const result = resolveDmPolicy({ cfg, account });
|
||||
if (!result) {
|
||||
throw new Error("nostr resolveDmPolicy returned null");
|
||||
}
|
||||
|
||||
expect(result.policy).toBe("allowlist");
|
||||
expect(result.allowFrom).toEqual([` nostr:${TEST_HEX_PRIVATE_KEY} `]);
|
||||
expect(result.normalizeEntry?.(` nostr:${TEST_HEX_PRIVATE_KEY} `)).toBe(
|
||||
TEST_HEX_PRIVATE_KEY,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status", () => {
|
||||
it("has default runtime", () => {
|
||||
expect(nostrTestPlugin.status?.defaultRuntime).toEqual({
|
||||
accountId: "default",
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("nostr setup wizard", () => {
|
||||
it("configures a private key and relay URLs", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Nostr private key (nsec... or hex)") {
|
||||
return TEST_HEX_PRIVATE_KEY;
|
||||
}
|
||||
if (message === "Relay URLs (comma-separated, optional)") {
|
||||
return TEST_SETUP_RELAY_URLS.join(", ");
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: nostrConfigure,
|
||||
cfg: {} as OpenClawConfig,
|
||||
prompter,
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.nostr?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.nostr?.privateKey).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
expect(result.cfg.channels?.nostr?.relays).toEqual(TEST_SETUP_RELAY_URLS);
|
||||
});
|
||||
|
||||
it("preserves the selected named account label during setup", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Nostr private key (nsec... or hex)") {
|
||||
return TEST_HEX_PRIVATE_KEY;
|
||||
}
|
||||
if (message === "Relay URLs (comma-separated, optional)") {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: nostrConfigure,
|
||||
cfg: {} as OpenClawConfig,
|
||||
prompter,
|
||||
options: {},
|
||||
accountOverrides: {
|
||||
nostr: "work",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("work");
|
||||
expect(result.cfg.channels?.nostr?.defaultAccount).toBe("work");
|
||||
expect(result.cfg.channels?.nostr?.privateKey).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when setup accountId is omitted", () => {
|
||||
expect(
|
||||
nostrTestPlugin.setup?.resolveAccountId?.({
|
||||
cfg: createConfiguredNostrCfg({ defaultAccount: "work" }) as OpenClawConfig,
|
||||
accountId: undefined,
|
||||
input: {},
|
||||
} as never),
|
||||
).toBe("work");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nostr unresolved SecretRef privateKey", () => {
|
||||
it.each(unresolvedSecretRefPrivateKeyCases)(
|
||||
"$name does not treat unresolved SecretRef privateKey as configured",
|
||||
({ assert }) => {
|
||||
assert(createUnresolvedNostrPrivateKeyCfg());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("nostr account helpers", () => {
|
||||
describe("listNostrAccountIds", () => {
|
||||
it("returns empty array when not configured", () => {
|
||||
const cfg = { channels: {} };
|
||||
expect(listNostrAccountIds(cfg)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when nostr section exists but no privateKey", () => {
|
||||
const cfg = { channels: { nostr: { enabled: true } } };
|
||||
expect(listNostrAccountIds(cfg)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("returns default when privateKey is configured", () => {
|
||||
const cfg = createConfiguredNostrCfg();
|
||||
expect(listNostrAccountIds(cfg)).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("returns configured defaultAccount when privateKey is configured", () => {
|
||||
const cfg = createConfiguredNostrCfg({ defaultAccount: "work" });
|
||||
expect(listNostrAccountIds(cfg)).toEqual(["work"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultNostrAccountId", () => {
|
||||
it("returns default when configured", () => {
|
||||
const cfg = createConfiguredNostrCfg();
|
||||
expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("returns default when not configured", () => {
|
||||
const cfg = { channels: {} };
|
||||
expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("prefers configured defaultAccount when present", () => {
|
||||
const cfg = createConfiguredNostrCfg({ defaultAccount: "work" });
|
||||
expect(resolveDefaultNostrAccountId(cfg)).toBe("work");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNostrAccount", () => {
|
||||
it("resolves configured account", () => {
|
||||
const cfg = createConfiguredNostrCfg({
|
||||
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_HEX_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 = createConfiguredNostrCfg({ enabled: false });
|
||||
const account = resolveNostrAccount({ cfg });
|
||||
|
||||
expect(account.enabled).toBe(false);
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("handles custom accountId parameter", () => {
|
||||
const cfg = createConfiguredNostrCfg();
|
||||
const account = resolveNostrAccount({ cfg, accountId: "custom" });
|
||||
|
||||
expect(account.accountId).toBe("custom");
|
||||
});
|
||||
|
||||
it("handles allowFrom config", () => {
|
||||
const cfg = createConfiguredNostrCfg({
|
||||
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);
|
||||
expect(account.publicKey).toBe("");
|
||||
});
|
||||
|
||||
it("preserves all config options", () => {
|
||||
const cfg = createConfiguredNostrCfg({
|
||||
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_HEX_PRIVATE_KEY,
|
||||
name: "Bot",
|
||||
enabled: true,
|
||||
relays: ["wss://relay1", "wss://relay2"],
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["pubkey1", "pubkey2"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup wizard", () => {
|
||||
it("keeps unresolved SecretRef privateKey visible without marking the account configured", () => {
|
||||
const secretRef = {
|
||||
source: "env" as const,
|
||||
provider: "default",
|
||||
id: "NOSTR_PRIVATE_KEY",
|
||||
};
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: secretRef,
|
||||
},
|
||||
},
|
||||
};
|
||||
const credential = nostrSetupWizard.credentials?.[0];
|
||||
if (!credential?.inspect) {
|
||||
throw new Error("nostr setup credential inspect missing");
|
||||
}
|
||||
|
||||
expect(credential.inspect({ cfg, accountId: "default" })).toEqual({
|
||||
accountConfigured: false,
|
||||
hasConfiguredValue: true,
|
||||
resolvedValue: undefined,
|
||||
envValue: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
215
extensions/nostr/src/channel.ts
Normal file
215
extensions/nostr/src/channel.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
// Nostr plugin module implements channel behavior.
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import {
|
||||
createScopedDmSecurityResolver,
|
||||
createTopLevelChannelConfigAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
buildPassiveChannelStatusSummary,
|
||||
buildTrafficStatusSummary,
|
||||
} from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
collectStatusIssuesFromLastError,
|
||||
createDefaultChannelRuntimeState,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatPairingApproveHint,
|
||||
type ChannelPlugin,
|
||||
} from "./channel-api.js";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import { NostrConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
getActiveNostrBuses,
|
||||
nostrOutboundAdapter,
|
||||
nostrPairingTextAdapter,
|
||||
startNostrGatewayAccount,
|
||||
} from "./gateway.js";
|
||||
import { normalizePubkey } from "./nostr-key-utils.js";
|
||||
import type { ProfilePublishResult } from "./nostr-profile.js";
|
||||
import { resolveNostrOutboundSessionRoute } from "./session-route.js";
|
||||
import { nostrSetupAdapter, nostrSetupWizard } from "./setup-surface.js";
|
||||
import {
|
||||
listNostrAccountIds,
|
||||
resolveDefaultNostrAccountId,
|
||||
resolveNostrAccount,
|
||||
type ResolvedNostrAccount,
|
||||
} from "./types.js";
|
||||
|
||||
const resolveNostrDmPolicy = createScopedDmSecurityResolver<ResolvedNostrAccount>({
|
||||
channelKey: "nostr",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
defaultPolicy: "pairing",
|
||||
approveHint: formatPairingApproveHint("nostr"),
|
||||
normalizeEntry: (raw) => {
|
||||
try {
|
||||
return normalizePubkey(raw.trim().replace(/^nostr:/i, ""));
|
||||
} catch {
|
||||
return raw.trim();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const nostrConfigAdapter = createTopLevelChannelConfigAdapter<ResolvedNostrAccount>({
|
||||
sectionKey: "nostr",
|
||||
resolveAccount: (cfg) => resolveNostrAccount({ cfg }),
|
||||
listAccountIds: listNostrAccountIds,
|
||||
defaultAccountId: resolveDefaultNostrAccountId,
|
||||
deleteMode: "clear-fields",
|
||||
clearBaseFields: [
|
||||
"name",
|
||||
"defaultAccount",
|
||||
"privateKey",
|
||||
"relays",
|
||||
"dmPolicy",
|
||||
"allowFrom",
|
||||
"profile",
|
||||
],
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
normalizeStringEntries(allowFrom)
|
||||
.map((entry) => {
|
||||
if (entry === "*") {
|
||||
return "*";
|
||||
}
|
||||
try {
|
||||
return normalizePubkey(entry);
|
||||
} catch {
|
||||
return entry;
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
});
|
||||
|
||||
const nostrMessageAdapter = createChannelMessageAdapterFromOutbound({
|
||||
id: "nostr",
|
||||
outbound: nostrOutboundAdapter,
|
||||
});
|
||||
|
||||
export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = createChatChannelPlugin({
|
||||
base: {
|
||||
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),
|
||||
setup: nostrSetupAdapter,
|
||||
setupWizard: nostrSetupWizard,
|
||||
config: {
|
||||
...nostrConfigAdapter,
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
publicKey: account.publicKey,
|
||||
},
|
||||
}),
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["nostr"],
|
||||
normalizeTarget: (target) => {
|
||||
// Strip nostr: prefix if present
|
||||
const cleaned = target.trim().replace(/^nostr:/i, "");
|
||||
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: "<npub|hex pubkey|nostr:npub...>",
|
||||
},
|
||||
resolveOutboundSessionRoute: (params) => resolveNostrOutboundSessionRoute(params),
|
||||
},
|
||||
message: nostrMessageAdapter,
|
||||
status: {
|
||||
...createComputedAccountStatusAdapter<ResolvedNostrAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("nostr", accounts),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildPassiveChannelStatusSummary(snapshot, {
|
||||
publicKey: snapshot.publicKey ?? null,
|
||||
}),
|
||||
resolveAccountSnapshot: ({ account, runtime }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
publicKey: account.publicKey,
|
||||
profile: account.profile,
|
||||
...buildTrafficStatusSummary(runtime),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: startNostrGatewayAccount,
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
text: nostrPairingTextAdapter,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveNostrDmPolicy,
|
||||
},
|
||||
outbound: nostrOutboundAdapter,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 | undefined,
|
||||
profile: NostrProfile,
|
||||
): Promise<ProfilePublishResult> {
|
||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const bus = getActiveNostrBuses().get(resolvedAccountId);
|
||||
if (!bus) {
|
||||
throw new Error(`Nostr bus not running for account ${resolvedAccountId}`);
|
||||
}
|
||||
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<string, "ok" | "failed" | "timeout"> | null;
|
||||
} | null> {
|
||||
const bus = getActiveNostrBuses().get(accountId);
|
||||
if (!bus) {
|
||||
return null;
|
||||
}
|
||||
return bus.getProfileState();
|
||||
}
|
||||
99
extensions/nostr/src/config-schema.ts
Normal file
99
extensions/nostr/src/config-schema.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// Nostr helper module supports config schema behavior.
|
||||
import {
|
||||
AllowFromListSchema,
|
||||
DmPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
} from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* 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 interface NostrProfile {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
banner?: string;
|
||||
website?: string;
|
||||
nip05?: string;
|
||||
lud16?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema for channels.nostr.* configuration
|
||||
*/
|
||||
export const NostrConfigSchema = z.object({
|
||||
/** Account name (optional display name) */
|
||||
name: z.string().optional(),
|
||||
|
||||
/** Optional default account id for routing/account selection. */
|
||||
defaultAccount: z.string().optional(),
|
||||
|
||||
/** Whether this channel is enabled */
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown: MarkdownConfigSchema,
|
||||
|
||||
/** Private key in hex or nsec bech32 format */
|
||||
privateKey: buildSecretInputSchema().optional(),
|
||||
|
||||
/** WebSocket relay URLs to connect to */
|
||||
relays: z.array(z.string()).optional(),
|
||||
|
||||
/** DM access policy: pairing, allowlist, open, or disabled */
|
||||
dmPolicy: DmPolicySchema.optional(),
|
||||
|
||||
/** Allowed sender pubkeys (npub or hex format) */
|
||||
allowFrom: AllowFromListSchema,
|
||||
|
||||
/** Profile metadata (NIP-01 kind:0 content) */
|
||||
profile: NostrProfileSchema.optional(),
|
||||
});
|
||||
2
extensions/nostr/src/default-relays.ts
Normal file
2
extensions/nostr/src/default-relays.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Nostr plugin module implements default relays behavior.
|
||||
export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];
|
||||
338
extensions/nostr/src/gateway.ts
Normal file
338
extensions/nostr/src/gateway.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
// Nostr plugin module implements gateway behavior.
|
||||
import {
|
||||
resolveStableChannelMessageIngress,
|
||||
type StableChannelIngressIdentityParams,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
|
||||
import type { ChannelOutboundAdapter, ChannelPlugin } from "./channel-api.js";
|
||||
import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
|
||||
import { startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
|
||||
import { normalizePubkey } from "./nostr-key-utils.js";
|
||||
import { getNostrRuntime } from "./runtime.js";
|
||||
import { resolveDefaultNostrAccountId, type ResolvedNostrAccount } from "./types.js";
|
||||
|
||||
type NostrGatewayStart = NonNullable<
|
||||
NonNullable<ChannelPlugin<ResolvedNostrAccount>["gateway"]>["startAccount"]
|
||||
>;
|
||||
type NostrOutboundAdapter = Pick<
|
||||
ChannelOutboundAdapter,
|
||||
"deliveryCapabilities" | "deliveryMode" | "textChunkLimit" | "sendText"
|
||||
> & {
|
||||
sendText: NonNullable<ChannelOutboundAdapter["sendText"]>;
|
||||
};
|
||||
|
||||
const activeBuses = new Map<string, NostrBusHandle>();
|
||||
const metricsSnapshots = new Map<string, MetricsSnapshot>();
|
||||
const ACCESS_GROUP_PREFIX = "accessGroup:";
|
||||
|
||||
function parseNostrAccessGroupAllowFromEntry(entry: string): string | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed.startsWith(ACCESS_GROUP_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const name = trimmed.slice(ACCESS_GROUP_PREFIX.length).trim();
|
||||
return name || null;
|
||||
}
|
||||
|
||||
function normalizeNostrAllowEntry(entry: string): string | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
const accessGroup = parseNostrAccessGroupAllowFromEntry(trimmed);
|
||||
if (accessGroup) {
|
||||
return `accessGroup:${accessGroup}`;
|
||||
}
|
||||
try {
|
||||
return normalizePubkey(trimmed.replace(/^nostr:/i, ""));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNostrSenderPubkey(value: string): string | null {
|
||||
try {
|
||||
return normalizePubkey(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const nostrIngressIdentity = {
|
||||
key: "nostr-pubkey",
|
||||
normalizeEntry: normalizeNostrAllowEntry,
|
||||
normalizeSubject: normalizeNostrSenderPubkey,
|
||||
sensitivity: "pii",
|
||||
entryIdPrefix: "nostr-entry",
|
||||
} satisfies StableChannelIngressIdentityParams;
|
||||
|
||||
export const startNostrGatewayAccount: NostrGatewayStart = 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();
|
||||
const pairing = createChannelPairingController({
|
||||
core: runtime,
|
||||
channel: "nostr",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const resolveInboundAccess = async (senderPubkey: string, rawBody: string) =>
|
||||
await resolveStableChannelMessageIngress({
|
||||
channelId: "nostr",
|
||||
accountId: account.accountId,
|
||||
identity: nostrIngressIdentity,
|
||||
cfg: ctx.cfg,
|
||||
useDefaultPairingStore: true,
|
||||
subject: { stableId: senderPubkey },
|
||||
conversation: {
|
||||
kind: "direct",
|
||||
id: senderPubkey,
|
||||
},
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom,
|
||||
command: runtime.channel.commands.shouldComputeCommandAuthorized(rawBody, ctx.cfg)
|
||||
? {
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
let busHandle: NostrBusHandle | null = null;
|
||||
|
||||
const authorizeSender = async (input: {
|
||||
senderId: string;
|
||||
reply: (text: string) => Promise<void>;
|
||||
}): Promise<"allow" | "block" | "pairing"> => {
|
||||
const resolved = await resolveInboundAccess(input.senderId, "");
|
||||
if (resolved.senderAccess.decision === "allow") {
|
||||
return "allow";
|
||||
}
|
||||
if (resolved.senderAccess.decision === "pairing") {
|
||||
await pairing.issueChallenge({
|
||||
senderId: input.senderId,
|
||||
senderIdLine: `Your Nostr pubkey: ${input.senderId}`,
|
||||
sendPairingReply: input.reply,
|
||||
onCreated: () => {
|
||||
ctx.log?.debug?.(`[${account.accountId}] nostr pairing request sender=${input.senderId}`);
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
ctx.log?.warn?.(
|
||||
`[${account.accountId}] nostr pairing reply failed for ${input.senderId}: ${String(
|
||||
err,
|
||||
)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
return "pairing";
|
||||
}
|
||||
ctx.log?.debug?.(
|
||||
`[${account.accountId}] blocked Nostr sender ${input.senderId} (${resolved.senderAccess.reasonCode})`,
|
||||
);
|
||||
return "block";
|
||||
};
|
||||
|
||||
await runStoppablePassiveMonitor({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () => {
|
||||
const bus = await startNostrBus({
|
||||
accountId: account.accountId,
|
||||
privateKey: account.privateKey,
|
||||
relays: account.relays,
|
||||
authorizeSender: async ({ senderPubkey, reply }) =>
|
||||
await authorizeSender({ senderId: senderPubkey, reply }),
|
||||
onMessage: async (senderPubkey, text, reply, meta) => {
|
||||
const resolvedAccess = await resolveInboundAccess(senderPubkey, text);
|
||||
if (resolvedAccess.senderAccess.decision !== "allow") {
|
||||
ctx.log?.warn?.(
|
||||
`[${account.accountId}] dropping Nostr DM after preflight drift (${senderPubkey}, ${resolvedAccess.senderAccess.reasonCode})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatchInboundDirectDmWithRuntime } =
|
||||
await import("./inbound-direct-dm-runtime.js");
|
||||
await dispatchInboundDirectDmWithRuntime({
|
||||
cfg: ctx.cfg,
|
||||
runtime,
|
||||
channel: "nostr",
|
||||
channelLabel: "Nostr",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: "direct",
|
||||
id: senderPubkey,
|
||||
},
|
||||
senderId: senderPubkey,
|
||||
senderAddress: `nostr:${senderPubkey}`,
|
||||
recipientAddress: `nostr:${account.publicKey}`,
|
||||
conversationLabel: senderPubkey,
|
||||
rawBody: text,
|
||||
messageId: meta.eventId,
|
||||
timestamp: meta.createdAt * 1000,
|
||||
commandAuthorized: resolvedAccess.commandAccess.requested
|
||||
? resolvedAccess.commandAccess.authorized
|
||||
: undefined,
|
||||
deliver: async (payload) => {
|
||||
const outboundText =
|
||||
payload && typeof payload === "object" && "text" in payload
|
||||
? ((payload as { text?: string }).text ?? "")
|
||||
: "";
|
||||
if (!outboundText.trim()) {
|
||||
return;
|
||||
}
|
||||
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
||||
cfg: ctx.cfg,
|
||||
channel: "nostr",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
await reply(runtime.channel.text.convertMarkdownTables(outboundText, tableMode));
|
||||
},
|
||||
onRecordError: (err) => {
|
||||
ctx.log?.error?.(
|
||||
`[${account.accountId}] failed recording Nostr inbound session: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
onDispatchError: (err, info) => {
|
||||
ctx.log?.error?.(
|
||||
`[${account.accountId}] Nostr ${info.kind} reply failed: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
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) => {
|
||||
if (event.name.startsWith("event.rejected.")) {
|
||||
ctx.log?.debug?.(
|
||||
`[${account.accountId}] Metric: ${event.name} ${JSON.stringify(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}`);
|
||||
}
|
||||
if (busHandle) {
|
||||
metricsSnapshots.set(account.accountId, busHandle.getMetrics());
|
||||
}
|
||||
},
|
||||
});
|
||||
let stopped = false;
|
||||
busHandle = bus;
|
||||
activeBuses.set(account.accountId, bus);
|
||||
|
||||
ctx.log?.info?.(
|
||||
`[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`,
|
||||
);
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
bus.close();
|
||||
if (busHandle === bus) {
|
||||
busHandle = null;
|
||||
}
|
||||
if (activeBuses.get(account.accountId) === bus) {
|
||||
activeBuses.delete(account.accountId);
|
||||
}
|
||||
metricsSnapshots.delete(account.accountId);
|
||||
ctx.log?.info?.(`[${account.accountId}] Nostr provider stopped`);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const nostrPairingTextAdapter = {
|
||||
idLabel: "nostrPubkey",
|
||||
message: "Your pairing request has been approved!",
|
||||
normalizeAllowEntry: (entry: string) => {
|
||||
try {
|
||||
return normalizePubkey(entry.trim().replace(/^nostr:/i, ""));
|
||||
} catch {
|
||||
return entry.trim();
|
||||
}
|
||||
},
|
||||
notify: async ({
|
||||
cfg,
|
||||
id,
|
||||
message,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
id: string;
|
||||
message: string;
|
||||
accountId?: string;
|
||||
}) => {
|
||||
const bus = activeBuses.get(accountId ?? resolveDefaultNostrAccountId(cfg));
|
||||
if (bus) {
|
||||
await bus.sendDm(id, message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const nostrOutboundAdapter: NostrOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
textChunkLimit: 4000,
|
||||
deliveryCapabilities: {
|
||||
durableFinal: {
|
||||
text: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
const core = getNostrRuntime();
|
||||
const aid = accountId ?? resolveDefaultNostrAccountId(cfg);
|
||||
const bus = activeBuses.get(aid);
|
||||
if (!bus) {
|
||||
throw new Error(`Nostr bus not running for account ${aid}`);
|
||||
}
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "nostr",
|
||||
accountId: aid,
|
||||
});
|
||||
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||
const normalizedTo = normalizePubkey(to);
|
||||
await bus.sendDm(normalizedTo, message);
|
||||
return attachChannelToResult("nostr", {
|
||||
to: normalizedTo,
|
||||
messageId: `nostr-${Date.now()}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export function getActiveNostrBuses(): Map<string, NostrBusHandle> {
|
||||
return new Map(activeBuses);
|
||||
}
|
||||
2
extensions/nostr/src/inbound-direct-dm-runtime.ts
Normal file
2
extensions/nostr/src/inbound-direct-dm-runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Nostr plugin module implements inbound direct dm runtime behavior.
|
||||
export { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/channel-inbound";
|
||||
454
extensions/nostr/src/metrics.ts
Normal file
454
extensions/nostr/src/metrics.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* Comprehensive metrics system for Nostr bus observability.
|
||||
* Provides clear insight into what's happening with events, relays, and operations.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Metric Types
|
||||
// ============================================================================
|
||||
|
||||
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";
|
||||
|
||||
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";
|
||||
|
||||
type RateLimitMetricName = "rate_limit.per_sender" | "rate_limit.global";
|
||||
|
||||
type DecryptMetricName = "decrypt.success" | "decrypt.failure";
|
||||
|
||||
type MemoryMetricName = "memory.seen_tracker_size" | "memory.rate_limiter_entries";
|
||||
|
||||
export type MetricName =
|
||||
| EventMetricName
|
||||
| RelayMetricName
|
||||
| RateLimitMetricName
|
||||
| DecryptMetricName
|
||||
| MemoryMetricName;
|
||||
|
||||
type RelayMetrics = {
|
||||
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;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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<string, string | number>;
|
||||
}
|
||||
|
||||
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, RelayMetrics>;
|
||||
|
||||
/** 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<string, string | number>) => 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, RelayMetrics>();
|
||||
|
||||
// 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 = 1, labels?: Record<string, string | number>): 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: () => {},
|
||||
};
|
||||
}
|
||||
383
extensions/nostr/src/nostr-bus.fuzz.test.ts
Normal file
383
extensions/nostr/src/nostr-bus.fuzz.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
// Nostr tests cover nostr bus.fuzz plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMetrics, type MetricName } from "./metrics.js";
|
||||
import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-key-utils.js";
|
||||
import { createSeenTracker } from "./seen-tracker.js";
|
||||
import { TEST_HEX_PRIVATE_KEY } from "./test-fixtures.js";
|
||||
|
||||
function createTracker(maxEntries = 100) {
|
||||
return createSeenTracker({ maxEntries });
|
||||
}
|
||||
|
||||
function createPlainMetrics() {
|
||||
return createMetrics();
|
||||
}
|
||||
|
||||
function createCollectingMetrics() {
|
||||
const events: unknown[] = [];
|
||||
return {
|
||||
events,
|
||||
metrics: createMetrics((event) => events.push(event)),
|
||||
};
|
||||
}
|
||||
|
||||
function expectThrowsError(run: () => unknown): void {
|
||||
let error: unknown;
|
||||
try {
|
||||
run();
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fuzz Tests for validatePrivateKey
|
||||
// ============================================================================
|
||||
|
||||
describe("validatePrivateKey fuzz", () => {
|
||||
describe("validatePrivateKey type confusion", () => {
|
||||
it("rejects non-string input", () => {
|
||||
for (const value of [null, undefined, 123, true, {}, [], () => {}]) {
|
||||
expectThrowsError(() => validatePrivateKey(value as unknown as string));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("unicode attacks", () => {
|
||||
it("rejects unicode and control-character attacks", () => {
|
||||
const invalidKeys = [
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u200Bf",
|
||||
`\u202E${TEST_HEX_PRIVATE_KEY}`,
|
||||
"0123456789\u0430bcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab😀",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u0301",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\x00f",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\nf",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\rf",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\tf",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\ff",
|
||||
];
|
||||
|
||||
for (const key of invalidKeys) {
|
||||
expectThrowsError(() => validatePrivateKey(key));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("rejects very long string", () => {
|
||||
const veryLong = "a".repeat(10000);
|
||||
expectThrowsError(() => validatePrivateKey(veryLong));
|
||||
});
|
||||
|
||||
it("rejects string of spaces matching length", () => {
|
||||
const spaces = " ".repeat(64);
|
||||
expectThrowsError(() => validatePrivateKey(spaces));
|
||||
});
|
||||
|
||||
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";
|
||||
expectThrowsError(() => validatePrivateKey(withSpaces));
|
||||
});
|
||||
});
|
||||
|
||||
describe("nsec format edge cases", () => {
|
||||
it("rejects nsec with invalid bech32 characters", () => {
|
||||
// 'b', 'i', 'o' are not valid bech32 characters
|
||||
const invalidBech32 = "nsec1qypqxpq9qtpqscx7peytbfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
|
||||
expectThrowsError(() => validatePrivateKey(invalidBech32));
|
||||
});
|
||||
|
||||
it("rejects nsec with wrong prefix", () => {
|
||||
expectThrowsError(() => validatePrivateKey("nsec0aaaa"));
|
||||
});
|
||||
|
||||
it("rejects partial nsec", () => {
|
||||
expectThrowsError(() => validatePrivateKey("nsec1"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Fuzz Tests for isValidPubkey
|
||||
// ============================================================================
|
||||
|
||||
describe("isValidPubkey fuzz", () => {
|
||||
describe("isValidPubkey type confusion", () => {
|
||||
it("handles non-string input gracefully", () => {
|
||||
for (const value of [null, undefined, 123, {}]) {
|
||||
expect(isValidPubkey(value as unknown as string)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("malicious inputs", () => {
|
||||
it("rejects prototype property names", () => {
|
||||
for (const value of ["__proto__", "constructor", "toString"]) {
|
||||
expect(isValidPubkey(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Fuzz Tests for normalizePubkey
|
||||
// ============================================================================
|
||||
|
||||
describe("normalizePubkey fuzz", () => {
|
||||
describe("prototype pollution attempts", () => {
|
||||
it("throws for prototype property names", () => {
|
||||
for (const value of ["__proto__", "constructor", "prototype"]) {
|
||||
expectThrowsError(() => normalizePubkey(value));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("case sensitivity", () => {
|
||||
it("normalizes uppercase to lowercase", () => {
|
||||
const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
expect(normalizePubkey(upper)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
});
|
||||
|
||||
it("normalizes mixed case to lowercase", () => {
|
||||
const mixed = "0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf";
|
||||
expect(normalizePubkey(mixed)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Fuzz Tests for SeenTracker
|
||||
// ============================================================================
|
||||
|
||||
describe("SeenTracker fuzz", () => {
|
||||
describe("malformed IDs", () => {
|
||||
it("handles empty string IDs", () => {
|
||||
const tracker = createTracker();
|
||||
expect(tracker.add("")).toBeUndefined();
|
||||
expect(tracker.peek("")).toBe(true);
|
||||
tracker.stop();
|
||||
});
|
||||
|
||||
it("handles very long IDs", () => {
|
||||
const tracker = createTracker();
|
||||
const longId = "a".repeat(100000);
|
||||
expect(tracker.add(longId)).toBeUndefined();
|
||||
expect(tracker.peek(longId)).toBe(true);
|
||||
tracker.stop();
|
||||
});
|
||||
|
||||
it("handles unicode IDs", () => {
|
||||
const tracker = createTracker();
|
||||
const unicodeId = "事件ID_🎉_тест";
|
||||
expect(tracker.add(unicodeId)).toBeUndefined();
|
||||
expect(tracker.peek(unicodeId)).toBe(true);
|
||||
tracker.stop();
|
||||
});
|
||||
|
||||
it("handles IDs with null bytes", () => {
|
||||
const tracker = createTracker();
|
||||
const idWithNull = "event\x00id";
|
||||
expect(tracker.add(idWithNull)).toBeUndefined();
|
||||
expect(tracker.peek(idWithNull)).toBe(true);
|
||||
tracker.stop();
|
||||
});
|
||||
|
||||
it("handles prototype property names as IDs", () => {
|
||||
const tracker = createTracker();
|
||||
|
||||
// These should not affect the tracker's internal operation
|
||||
expect(tracker.add("__proto__")).toBeUndefined();
|
||||
expect(tracker.add("constructor")).toBeUndefined();
|
||||
expect(tracker.add("toString")).toBeUndefined();
|
||||
expect(tracker.add("hasOwnProperty")).toBeUndefined();
|
||||
|
||||
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 = createTracker(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 = createTracker();
|
||||
|
||||
// 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()).toBeGreaterThan(0);
|
||||
tracker.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("seed edge cases", () => {
|
||||
it("handles empty seed array", () => {
|
||||
const tracker = createTracker();
|
||||
expect(tracker.seed([])).toBeUndefined();
|
||||
expect(tracker.size()).toBe(0);
|
||||
tracker.stop();
|
||||
});
|
||||
|
||||
it("handles seed with duplicate IDs", () => {
|
||||
const tracker = createTracker();
|
||||
tracker.seed(["id1", "id1", "id1", "id2", "id2"]);
|
||||
expect(tracker.size()).toBe(2);
|
||||
tracker.stop();
|
||||
});
|
||||
|
||||
it("handles seed larger than maxEntries", () => {
|
||||
const tracker = createTracker(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 = createPlainMetrics();
|
||||
|
||||
// Cast to bypass type checking - testing runtime behavior
|
||||
expect(metrics.emit("invalid.metric.name" as MetricName)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid label values", () => {
|
||||
it("handles null relay label", () => {
|
||||
const metrics = createPlainMetrics();
|
||||
expect(
|
||||
metrics.emit("relay.connect", 1, { relay: null as unknown as string }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles undefined relay label", () => {
|
||||
const metrics = createPlainMetrics();
|
||||
expect(
|
||||
metrics.emit("relay.connect", 1, { relay: undefined as unknown as string }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles very long relay URL", () => {
|
||||
const metrics = createPlainMetrics();
|
||||
const longUrl = "wss://" + "a".repeat(10000) + ".com";
|
||||
expect(metrics.emit("relay.connect", 1, { relay: longUrl })).toBeUndefined();
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.relays[longUrl]).toEqual({
|
||||
connects: 1,
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extreme values", () => {
|
||||
it("handles NaN value", () => {
|
||||
const metrics = createPlainMetrics();
|
||||
expect(metrics.emit("event.received", Number.NaN)).toBeUndefined();
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
expect(Number.isNaN(snapshot.eventsReceived)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles Infinity value", () => {
|
||||
const metrics = createPlainMetrics();
|
||||
expect(metrics.emit("event.received", Infinity)).toBeUndefined();
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.eventsReceived).toBe(Infinity);
|
||||
});
|
||||
|
||||
it("handles negative value", () => {
|
||||
const metrics = createPlainMetrics();
|
||||
metrics.emit("event.received", -1);
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.eventsReceived).toBe(-1);
|
||||
});
|
||||
|
||||
it("handles very large value", () => {
|
||||
const metrics = createPlainMetrics();
|
||||
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, metrics } = createCollectingMetrics();
|
||||
|
||||
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 = createPlainMetrics();
|
||||
|
||||
metrics.emit("event.received");
|
||||
metrics.emit("event.received");
|
||||
metrics.reset();
|
||||
metrics.emit("event.received");
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.eventsReceived).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
598
extensions/nostr/src/nostr-bus.inbound.test.ts
Normal file
598
extensions/nostr/src/nostr-bus.inbound.test.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
// Nostr tests cover nostr bus.inbound plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startNostrBus } from "./nostr-bus.js";
|
||||
import { TEST_HEX_PRIVATE_KEY } from "./test-fixtures.js";
|
||||
|
||||
const BOT_PUBKEY = "b".repeat(64);
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
handlers: null as {
|
||||
onevent: (event: Record<string, unknown>) => void | Promise<void>;
|
||||
oneose?: () => void;
|
||||
onclose?: (reason: string[]) => void;
|
||||
} | null,
|
||||
subscribeMany: vi.fn(),
|
||||
close: vi.fn(),
|
||||
subscriptionClose: vi.fn(),
|
||||
verifyEvent: vi.fn(() => true),
|
||||
decrypt: vi.fn(() => "plaintext"),
|
||||
publishProfile: vi.fn(async () => ({
|
||||
createdAt: 0,
|
||||
eventId: "profile-event",
|
||||
successes: [],
|
||||
failures: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("nostr-tools", () => {
|
||||
class MockSimplePool {
|
||||
subscribeMany(
|
||||
relays: string[],
|
||||
filters: unknown,
|
||||
handlers: {
|
||||
onevent: (event: Record<string, unknown>) => void | Promise<void>;
|
||||
oneose?: () => void;
|
||||
onclose?: (reason: string[]) => void;
|
||||
},
|
||||
) {
|
||||
mockState.subscribeMany(relays, filters, handlers);
|
||||
mockState.handlers = handlers;
|
||||
return {
|
||||
close: mockState.subscriptionClose,
|
||||
};
|
||||
}
|
||||
|
||||
publish = vi.fn(async () => {});
|
||||
|
||||
close(relays: string[]) {
|
||||
mockState.close(relays);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SimplePool: MockSimplePool,
|
||||
finalizeEvent: vi.fn((event: unknown) => event),
|
||||
getPublicKey: vi.fn(() => BOT_PUBKEY),
|
||||
verifyEvent: mockState.verifyEvent,
|
||||
nip19: {
|
||||
decode: vi.fn(),
|
||||
npubEncode: vi.fn((value: string) => `npub-${value}`),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("nostr-tools/nip04", () => ({
|
||||
decrypt: mockState.decrypt,
|
||||
encrypt: vi.fn(() => "ciphertext"),
|
||||
}));
|
||||
|
||||
vi.mock("./nostr-state-store.js", () => ({
|
||||
readNostrBusState: vi.fn(async () => null),
|
||||
writeNostrBusState: vi.fn(async () => {}),
|
||||
computeSinceTimestamp: vi.fn(() => 0),
|
||||
readNostrProfileState: vi.fn(async () => null),
|
||||
writeNostrProfileState: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./nostr-profile.js", () => ({
|
||||
publishProfile: mockState.publishProfile,
|
||||
}));
|
||||
|
||||
function createEvent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "event-1",
|
||||
kind: 4,
|
||||
pubkey: "a".repeat(64),
|
||||
content: "ciphertext",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", BOT_PUBKEY]],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function emitEvent(event: Record<string, unknown>) {
|
||||
if (!mockState.handlers) {
|
||||
throw new Error("missing subscription handlers");
|
||||
}
|
||||
await mockState.handlers.onevent(event);
|
||||
}
|
||||
|
||||
describe("startNostrBus inbound guards", () => {
|
||||
beforeEach(() => {
|
||||
mockState.handlers = null;
|
||||
mockState.subscribeMany.mockClear();
|
||||
mockState.close.mockClear();
|
||||
mockState.subscriptionClose.mockReset();
|
||||
mockState.verifyEvent.mockClear();
|
||||
mockState.verifyEvent.mockReturnValue(true);
|
||||
mockState.decrypt.mockClear();
|
||||
mockState.decrypt.mockReturnValue("plaintext");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockState.handlers = null;
|
||||
});
|
||||
|
||||
it("subscribes to DMs with a single Nostr filter object", async () => {
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage: vi.fn(async () => {}),
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
expect(mockState.subscribeMany).toHaveBeenCalledTimes(1);
|
||||
const filters = mockState.subscribeMany.mock.calls[0]?.[1];
|
||||
expect(Array.isArray(filters)).toBe(false);
|
||||
expect(filters).toMatchObject({
|
||||
kinds: [4],
|
||||
"#p": [BOT_PUBKEY],
|
||||
since: 0,
|
||||
});
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("closes the relay pool when the bus closes", async () => {
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
relays: ["wss://relay.example"],
|
||||
onMessage: vi.fn(async () => {}),
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
bus.close();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockState.close).toHaveBeenCalledWith(["wss://relay.example"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the relay pool after the active subscription closes", async () => {
|
||||
let releaseClose = () => {};
|
||||
const subscriptionClosed = new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
});
|
||||
mockState.subscriptionClose.mockImplementationOnce(async () => {
|
||||
await subscriptionClosed;
|
||||
});
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
relays: ["wss://relay.example"],
|
||||
onMessage: vi.fn(async () => {}),
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
bus.close();
|
||||
|
||||
expect(mockState.subscriptionClose).toHaveBeenCalledWith("closed by caller");
|
||||
expect(mockState.close).not.toHaveBeenCalled();
|
||||
|
||||
releaseClose();
|
||||
await vi.waitFor(() => {
|
||||
expect(mockState.close).toHaveBeenCalledWith(["wss://relay.example"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("checks sender authorization after verify and before decrypt", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const authorizeSender = vi.fn(async () => "block" as const);
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
authorizeSender,
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
await emitEvent(createEvent());
|
||||
|
||||
expect(authorizeSender).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.decrypt).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(bus.getMetrics().eventsReceived).toBe(1);
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("rejects invalid signatures before sender authorization", async () => {
|
||||
mockState.verifyEvent.mockReturnValueOnce(false);
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const authorizeSender = vi.fn(async () => "allow" as const);
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
authorizeSender,
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
await emitEvent(createEvent());
|
||||
|
||||
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
||||
expect(authorizeSender).not.toHaveBeenCalled();
|
||||
expect(mockState.decrypt).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(bus.getMetrics().eventsRejected.invalidSignature).toBe(1);
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("dedupes replayed invalid-signature events before verify fans out again", async () => {
|
||||
mockState.verifyEvent.mockReturnValue(false);
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const authorizeSender = vi.fn(async () => "allow" as const);
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
authorizeSender,
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
const invalidEvent = createEvent({ id: "invalid-replay" });
|
||||
|
||||
await emitEvent(invalidEvent);
|
||||
await emitEvent(invalidEvent);
|
||||
|
||||
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
||||
expect(authorizeSender).not.toHaveBeenCalled();
|
||||
expect(mockState.decrypt).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(bus.getMetrics().eventsRejected.invalidSignature).toBe(1);
|
||||
expect(bus.getMetrics().eventsDuplicate).toBe(1);
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("dedupes replayed self-message events before other guards rerun", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const authorizeSender = vi.fn(async () => "allow" as const);
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
authorizeSender,
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
const selfEvent = createEvent({
|
||||
id: "self-replay",
|
||||
pubkey: BOT_PUBKEY,
|
||||
});
|
||||
|
||||
await emitEvent(selfEvent);
|
||||
await emitEvent(selfEvent);
|
||||
|
||||
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
||||
expect(authorizeSender).not.toHaveBeenCalled();
|
||||
expect(mockState.decrypt).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(bus.getMetrics().eventsDuplicate).toBe(1);
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("rate limits repeated events before decrypt", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
for (let i = 0; i < 21; i += 1) {
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
id: `event-${i}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = bus.getMetrics();
|
||||
expect(snapshot.eventsRejected.rateLimited).toBe(1);
|
||||
expect(mockState.decrypt).toHaveBeenCalledTimes(20);
|
||||
expect(onMessage).toHaveBeenCalledTimes(20);
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("does not let a blocked sender starve a different verified sender", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const authorizeSender = vi.fn(async ({ senderPubkey }: { senderPubkey: string }) =>
|
||||
senderPubkey.startsWith("blocked") ? ("block" as const) : ("allow" as const),
|
||||
);
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
authorizeSender,
|
||||
onMetric: () => {},
|
||||
guardPolicy: {
|
||||
rateLimit: {
|
||||
windowMs: 60_000,
|
||||
maxGlobalPerWindow: 2,
|
||||
maxPerSenderPerWindow: 1,
|
||||
maxTrackedSenderKeys: 32,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
id: "blocked-event",
|
||||
pubkey: `blocked${"a".repeat(57)}`,
|
||||
}),
|
||||
);
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
id: "allowed-event",
|
||||
pubkey: `allowed${"b".repeat(57)}`,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(authorizeSender).toHaveBeenCalledTimes(2);
|
||||
expect(mockState.decrypt).toHaveBeenCalledTimes(1);
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(0);
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("dedupes replayed verified events that authorization blocks", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const authorizeSender = vi.fn(async () => "block" as const);
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
authorizeSender,
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
const blockedEvent = createEvent({
|
||||
id: "blocked-replay",
|
||||
pubkey: `blocked${"a".repeat(57)}`,
|
||||
});
|
||||
|
||||
await emitEvent(blockedEvent);
|
||||
await emitEvent(blockedEvent);
|
||||
|
||||
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
||||
expect(authorizeSender).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.decrypt).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("retries a replayed event after the message handler fails", async () => {
|
||||
const onMessage = vi
|
||||
.fn<(sender: string, plaintext: string) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
const event = createEvent({
|
||||
id: "retry-after-handler-failure",
|
||||
});
|
||||
|
||||
await emitEvent(event);
|
||||
await emitEvent(event);
|
||||
|
||||
expect(mockState.verifyEvent).toHaveBeenCalledTimes(2);
|
||||
expect(mockState.decrypt).toHaveBeenCalledTimes(2);
|
||||
expect(onMessage).toHaveBeenCalledTimes(2);
|
||||
expect(bus.getMetrics().eventsProcessed).toBe(1);
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("does not rate limit an allowed sender while another authorization is still pending", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
let resolveBlocked: ((value: "block") => void) | undefined;
|
||||
const blockedPromise = new Promise<"block">((resolve) => {
|
||||
resolveBlocked = resolve;
|
||||
});
|
||||
const authorizeSender = vi
|
||||
.fn<(params: { senderPubkey: string }) => Promise<"allow" | "block" | "pairing">>()
|
||||
.mockImplementationOnce(async () => await blockedPromise)
|
||||
.mockResolvedValueOnce("allow");
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
authorizeSender,
|
||||
onMetric: () => {},
|
||||
guardPolicy: {
|
||||
rateLimit: {
|
||||
windowMs: 60_000,
|
||||
maxGlobalPerWindow: 2,
|
||||
maxPerSenderPerWindow: 1,
|
||||
maxTrackedSenderKeys: 32,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const blockedEventPromise = emitEvent(
|
||||
createEvent({
|
||||
id: "blocked-pending",
|
||||
pubkey: `blocked${"a".repeat(57)}`,
|
||||
}),
|
||||
);
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
id: "allowed-during-pending-auth",
|
||||
pubkey: `allowed${"b".repeat(57)}`,
|
||||
}),
|
||||
);
|
||||
resolveBlocked?.("block");
|
||||
await blockedEventPromise;
|
||||
|
||||
expect(authorizeSender).toHaveBeenCalledTimes(2);
|
||||
expect(mockState.decrypt).toHaveBeenCalledTimes(1);
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(0);
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("rate limits repeated invalid signatures before authorization work fans out", async () => {
|
||||
mockState.verifyEvent.mockReturnValue(false);
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const authorizeSender = vi.fn(async () => "allow" as const);
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
authorizeSender,
|
||||
onMetric: () => {},
|
||||
guardPolicy: {
|
||||
rateLimit: {
|
||||
windowMs: 60_000,
|
||||
maxGlobalPerWindow: 1,
|
||||
maxPerSenderPerWindow: 10,
|
||||
maxTrackedSenderKeys: 32,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await emitEvent(createEvent({ id: "invalid-1" }));
|
||||
await emitEvent(createEvent({ id: "invalid-2" }));
|
||||
|
||||
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
||||
expect(authorizeSender).not.toHaveBeenCalled();
|
||||
expect(bus.getMetrics().eventsRejected.invalidSignature).toBe(1);
|
||||
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(1);
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("counts oversized ciphertext toward the global inbound rate limit", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
onMetric: () => {},
|
||||
guardPolicy: {
|
||||
maxCiphertextBytes: 4,
|
||||
rateLimit: {
|
||||
windowMs: 60_000,
|
||||
maxGlobalPerWindow: 1,
|
||||
maxPerSenderPerWindow: 10,
|
||||
maxTrackedSenderKeys: 32,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
id: "oversized-global-1",
|
||||
pubkey: `sender1${"a".repeat(57)}`,
|
||||
content: "ciphertext-too-large",
|
||||
}),
|
||||
);
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
id: "oversized-global-2",
|
||||
pubkey: `sender2${"b".repeat(57)}`,
|
||||
content: "ciphertext-too-large",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(bus.getMetrics().eventsRejected.oversizedCiphertext).toBe(1);
|
||||
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(1);
|
||||
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
||||
expect(mockState.decrypt).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("does not spend per-sender buckets on oversized ciphertext before verification", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
onMetric: () => {},
|
||||
guardPolicy: {
|
||||
maxCiphertextBytes: 4,
|
||||
rateLimit: {
|
||||
windowMs: 60_000,
|
||||
maxGlobalPerWindow: 10,
|
||||
maxPerSenderPerWindow: 1,
|
||||
maxTrackedSenderKeys: 32,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
id: "oversized-sender-1",
|
||||
content: "ciphertext-too-large",
|
||||
}),
|
||||
);
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
id: "oversized-sender-2",
|
||||
content: "ciphertext-too-large",
|
||||
}),
|
||||
);
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
id: "allowed-after-oversized",
|
||||
content: "ok",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(bus.getMetrics().eventsRejected.oversizedCiphertext).toBe(2);
|
||||
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(0);
|
||||
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.decrypt).toHaveBeenCalledTimes(1);
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("rejects far-future events before crypto", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
created_at: Math.floor(Date.now() / 1000) + 600,
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = bus.getMetrics();
|
||||
expect(snapshot.eventsRejected.future).toBe(1);
|
||||
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
||||
expect(mockState.decrypt).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
bus.close();
|
||||
});
|
||||
|
||||
it("rejects oversized ciphertext before verify/decrypt", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const bus = await startNostrBus({
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
onMessage,
|
||||
onMetric: () => {},
|
||||
});
|
||||
|
||||
await emitEvent(
|
||||
createEvent({
|
||||
content: "x".repeat(20_000),
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = bus.getMetrics();
|
||||
expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1);
|
||||
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
||||
expect(mockState.decrypt).not.toHaveBeenCalled();
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
bus.close();
|
||||
});
|
||||
});
|
||||
540
extensions/nostr/src/nostr-bus.integration.test.ts
Normal file
540
extensions/nostr/src/nostr-bus.integration.test.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
// Nostr tests cover nostr bus.integration plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMetrics, createNoopMetrics, type MetricEvent } from "./metrics.js";
|
||||
import { createSeenTracker } from "./seen-tracker.js";
|
||||
import { TEST_RELAY_URL } from "./test-fixtures.js";
|
||||
|
||||
const TEST_RELAY_URL_1 = "wss://relay1.com";
|
||||
const TEST_RELAY_URL_2 = "wss://relay2.com";
|
||||
const TEST_RELAY_URL_PRIMARY = "wss://relay.com";
|
||||
const TEST_RELAY_URL_GOOD = "wss://good-relay.com";
|
||||
const TEST_RELAY_URL_BAD = "wss://bad-relay.com";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function createTracker(overrides?: Partial<Parameters<typeof createSeenTracker>[0]>) {
|
||||
return createSeenTracker({
|
||||
maxEntries: 100,
|
||||
ttlMs: 60000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createCollectingMetrics() {
|
||||
const events: MetricEvent[] = [];
|
||||
return {
|
||||
events,
|
||||
metrics: createMetrics((event) => events.push(event)),
|
||||
};
|
||||
}
|
||||
|
||||
function createPlainMetrics() {
|
||||
return createMetrics();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Seen Tracker Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
describe("SeenTracker", () => {
|
||||
describe("basic operations", () => {
|
||||
it("tracks seen IDs", () => {
|
||||
const tracker = createTracker();
|
||||
|
||||
// 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 = createTracker();
|
||||
|
||||
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 = createTracker();
|
||||
|
||||
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 = createTracker();
|
||||
|
||||
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 = createTracker();
|
||||
|
||||
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 = createTracker({ maxEntries: 3 });
|
||||
|
||||
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 = createTracker({ maxEntries: 3 });
|
||||
|
||||
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 = createTracker({ maxEntries: 1 });
|
||||
|
||||
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 = createTracker({ maxEntries: 2 });
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("keeps non-positive capacities usable", () => {
|
||||
const tracker = createTracker({ maxEntries: 0 });
|
||||
|
||||
tracker.add("id1");
|
||||
tracker.add("id2");
|
||||
|
||||
expect(tracker.size()).toBe(1);
|
||||
expect(tracker.peek("id1")).toBe(false);
|
||||
expect(tracker.peek("id2")).toBe(true);
|
||||
|
||||
tracker.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TTL expiration", () => {
|
||||
it("expires entries after TTL", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const tracker = createTracker({
|
||||
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", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const tracker = createTracker({
|
||||
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();
|
||||
});
|
||||
|
||||
it.each([-1, 0])("falls back to default TTL for non-positive ttlMs %s", (ttlMs) => {
|
||||
vi.useFakeTimers();
|
||||
const tracker = createTracker({ ttlMs, pruneIntervalMs: 10 * 60 * 1000 });
|
||||
|
||||
try {
|
||||
tracker.add("id1");
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(tracker.peek("id1")).toBe(true);
|
||||
} finally {
|
||||
tracker.stop();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to default TTL for infinite ttlMs", () => {
|
||||
vi.useFakeTimers();
|
||||
const tracker = createTracker({
|
||||
ttlMs: Number.POSITIVE_INFINITY,
|
||||
pruneIntervalMs: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
try {
|
||||
tracker.add("id1");
|
||||
vi.advanceTimersByTime(60 * 60 * 1000 + 1);
|
||||
expect(tracker.peek("id1")).toBe(false);
|
||||
} finally {
|
||||
tracker.stop();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([-1, 0, Number.POSITIVE_INFINITY])(
|
||||
"uses the default prune interval for unsafe pruneIntervalMs %s",
|
||||
(pruneIntervalMs) => {
|
||||
vi.useFakeTimers();
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
|
||||
const tracker = createTracker({ pruneIntervalMs });
|
||||
|
||||
try {
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setIntervalSpy.mock.calls[0]?.[1]).toBe(10 * 60 * 1000);
|
||||
} finally {
|
||||
tracker.stop();
|
||||
setIntervalSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Metrics Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
describe("Metrics", () => {
|
||||
describe("createMetrics", () => {
|
||||
it("emits metric events to callback", () => {
|
||||
const { events, metrics } = createCollectingMetrics();
|
||||
|
||||
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, metrics } = createCollectingMetrics();
|
||||
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL });
|
||||
|
||||
expect(events[0].labels).toEqual({ relay: TEST_RELAY_URL });
|
||||
});
|
||||
|
||||
it("accumulates counters in snapshot", () => {
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
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 = createPlainMetrics();
|
||||
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_1 });
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_2 });
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_1 });
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_1 });
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
const relayOne = snapshot.relays[TEST_RELAY_URL_1];
|
||||
if (!relayOne) {
|
||||
throw new Error("expected first relay metrics");
|
||||
}
|
||||
expect(relayOne.connects).toBe(1);
|
||||
expect(relayOne.errors).toBe(2);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_2].connects).toBe(1);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_2].errors).toBe(0);
|
||||
});
|
||||
|
||||
it("tracks circuit breaker state changes", () => {
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
metrics.emit("relay.circuit_breaker.open", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
let snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerState).toBe("open");
|
||||
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerOpens).toBe(1);
|
||||
|
||||
metrics.emit("relay.circuit_breaker.close", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerState).toBe("closed");
|
||||
expect(snapshot.relays[TEST_RELAY_URL_PRIMARY].circuitBreakerCloses).toBe(1);
|
||||
});
|
||||
|
||||
it("tracks all rejection reasons", () => {
|
||||
const metrics = createPlainMetrics();
|
||||
|
||||
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 = createPlainMetrics();
|
||||
|
||||
metrics.emit("relay.message.event", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.message.eose", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.message.closed", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.message.notice", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.message.ok", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.message.auth", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
const relay = snapshot.relays[TEST_RELAY_URL_PRIMARY];
|
||||
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 = createPlainMetrics();
|
||||
|
||||
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 = createPlainMetrics();
|
||||
|
||||
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 = createPlainMetrics();
|
||||
|
||||
metrics.emit("event.received");
|
||||
metrics.emit("event.processed");
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
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("ignores emitted metrics", () => {
|
||||
const metrics = createNoopMetrics();
|
||||
|
||||
expect(metrics.emit("event.received")).toBeUndefined();
|
||||
expect(metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY })).toBeUndefined();
|
||||
});
|
||||
|
||||
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, metrics } = createCollectingMetrics();
|
||||
|
||||
// Simulate 5 failures -> open
|
||||
for (let i = 0; i < 5; i++) {
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
}
|
||||
metrics.emit("relay.circuit_breaker.open", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
// Simulate recovery
|
||||
metrics.emit("relay.circuit_breaker.half_open", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
metrics.emit("relay.circuit_breaker.close", 1, { relay: TEST_RELAY_URL_PRIMARY });
|
||||
|
||||
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 = createPlainMetrics();
|
||||
|
||||
// Simulate mixed success/failure pattern
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_GOOD });
|
||||
metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_BAD });
|
||||
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
|
||||
metrics.emit("relay.error", 1, { relay: TEST_RELAY_URL_BAD });
|
||||
|
||||
const snapshot = metrics.getSnapshot();
|
||||
expect(snapshot.relays[TEST_RELAY_URL_GOOD].errors).toBe(0);
|
||||
expect(snapshot.relays[TEST_RELAY_URL_BAD].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 * 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
256
extensions/nostr/src/nostr-bus.test.ts
Normal file
256
extensions/nostr/src/nostr-bus.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// Nostr tests cover nostr bus plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validatePrivateKey,
|
||||
getPublicKeyFromPrivate,
|
||||
isValidPubkey,
|
||||
normalizePubkey,
|
||||
pubkeyToNpub,
|
||||
} from "./nostr-key-utils.js";
|
||||
import { TEST_HEX_PRIVATE_KEY, TEST_NSEC } from "./test-fixtures.js";
|
||||
|
||||
const UPPERCASE_HEX = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
const INVALID_HEX = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg";
|
||||
|
||||
function expectThrowsError(run: () => unknown): void {
|
||||
let error: unknown;
|
||||
try {
|
||||
run();
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
}
|
||||
|
||||
const uppercaseHexAcceptanceCases = [
|
||||
{
|
||||
name: "validatePrivateKey",
|
||||
assert: () => {
|
||||
const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY.toUpperCase());
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "isValidPubkey",
|
||||
assert: () => {
|
||||
expect(isValidPubkey(UPPERCASE_HEX)).toBe(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const invalidHexRejectionCases = [
|
||||
{
|
||||
name: "validatePrivateKey",
|
||||
assert: (input: string) => {
|
||||
expect(() => validatePrivateKey(input)).toThrow("Private key must be 64 hex characters");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "isValidPubkey",
|
||||
assert: (input: string) => {
|
||||
expect(isValidPubkey(input)).toBe(false);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const whitespaceNormalizationCases = [
|
||||
{
|
||||
name: "validatePrivateKey",
|
||||
assert: () => {
|
||||
const result = validatePrivateKey(` ${TEST_HEX_PRIVATE_KEY} `);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "normalizePubkey",
|
||||
assert: () => {
|
||||
expect(normalizePubkey(` ${TEST_HEX_PRIVATE_KEY} `)).toBe(TEST_HEX_PRIVATE_KEY);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("hex key helper contracts", () => {
|
||||
it.each(uppercaseHexAcceptanceCases)("$name accepts uppercase hex", ({ assert }) => {
|
||||
assert();
|
||||
});
|
||||
|
||||
it.each(invalidHexRejectionCases)("$name rejects non-hex characters", ({ assert }) => {
|
||||
assert(INVALID_HEX);
|
||||
});
|
||||
|
||||
it.each(invalidHexRejectionCases)("$name rejects empty string", ({ assert }) => {
|
||||
assert("");
|
||||
});
|
||||
|
||||
it.each(whitespaceNormalizationCases)("$name trims whitespace", ({ assert }) => {
|
||||
assert();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePrivateKey", () => {
|
||||
describe("validatePrivateKey hex format", () => {
|
||||
it("accepts valid 64-char hex key", () => {
|
||||
const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBe(32);
|
||||
});
|
||||
|
||||
it("accepts lowercase hex", () => {
|
||||
const result = validatePrivateKey(TEST_HEX_PRIVATE_KEY.toLowerCase());
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("accepts mixed case hex", () => {
|
||||
const mixed = "0123456789ABCdef0123456789abcDEF0123456789abcdef0123456789ABCDEF";
|
||||
const result = validatePrivateKey(mixed);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("trims newlines", () => {
|
||||
const result = validatePrivateKey(`${TEST_HEX_PRIVATE_KEY}\n`);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("rejects 63-char hex (too short)", () => {
|
||||
expect(() => validatePrivateKey(TEST_HEX_PRIVATE_KEY.slice(0, 63))).toThrow(
|
||||
"Private key must be 64 hex characters",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects 65-char hex (too long)", () => {
|
||||
expect(() => validatePrivateKey(TEST_HEX_PRIVATE_KEY + "0")).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_PRIVATE_KEY)).toThrow(
|
||||
"Private key must be 64 hex characters",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nsec format", () => {
|
||||
it("rejects invalid nsec (wrong checksum)", () => {
|
||||
const badNsec = "nsec1invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalid";
|
||||
expectThrowsError(() => validatePrivateKey(badNsec));
|
||||
});
|
||||
|
||||
it("rejects npub (wrong type)", () => {
|
||||
const npub = "npub1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8s5epk55";
|
||||
expectThrowsError(() => validatePrivateKey(npub));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidPubkey", () => {
|
||||
describe("isValidPubkey hex format", () => {
|
||||
it("accepts valid 64-char hex pubkey", () => {
|
||||
expect(isValidPubkey(TEST_HEX_PRIVATE_KEY)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects 63-char hex", () => {
|
||||
const shortHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde";
|
||||
expect(isValidPubkey(shortHex)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects 65-char hex", () => {
|
||||
const longHex = `${TEST_HEX_PRIVATE_KEY}0`;
|
||||
expect(isValidPubkey(longHex)).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("handles whitespace-padded input", () => {
|
||||
expect(isValidPubkey(` ${TEST_HEX_PRIVATE_KEY} `)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePubkey", () => {
|
||||
describe("normalizePubkey hex format", () => {
|
||||
it("lowercases hex pubkey", () => {
|
||||
const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
const result = normalizePubkey(upper);
|
||||
expect(result).toBe(upper.toLowerCase());
|
||||
});
|
||||
|
||||
it("rejects invalid hex", () => {
|
||||
expect(() => normalizePubkey("invalid")).toThrow("Pubkey must be 64 hex characters");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePubkey npub format", () => {
|
||||
// Regression: pre-fix this returned a 128-char garbage string because the
|
||||
// implementation treated nip19.decode(npub).data as a Uint8Array, but
|
||||
// nostr-tools >=2.0 returns it as the hex string directly. allowFrom
|
||||
// entries written as npubs therefore never matched any hex sender pubkey.
|
||||
const HEX = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
const NPUB = pubkeyToNpub(HEX);
|
||||
|
||||
it("decodes npub to the original 64-char hex pubkey", () => {
|
||||
const result = normalizePubkey(NPUB);
|
||||
expect(result).toBe(HEX);
|
||||
expect(result).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.length).toBe(64);
|
||||
});
|
||||
|
||||
it("survives a hex→npub→normalizePubkey roundtrip", () => {
|
||||
expect(normalizePubkey(pubkeyToNpub(HEX))).toBe(HEX);
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace before decoding", () => {
|
||||
expect(normalizePubkey(` ${NPUB} `)).toBe(HEX);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPublicKeyFromPrivate", () => {
|
||||
it("derives public key from hex private key", () => {
|
||||
const pubkey = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
|
||||
expect(pubkey).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(pubkey.length).toBe(64);
|
||||
});
|
||||
|
||||
it("derives consistent public key", () => {
|
||||
const pubkey1 = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
|
||||
const pubkey2 = getPublicKeyFromPrivate(TEST_HEX_PRIVATE_KEY);
|
||||
expect(pubkey1).toBe(pubkey2);
|
||||
});
|
||||
|
||||
it("throws for invalid private key", () => {
|
||||
expectThrowsError(() => getPublicKeyFromPrivate("invalid"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("pubkeyToNpub", () => {
|
||||
it("converts hex pubkey to npub format", () => {
|
||||
const npub = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
|
||||
expect(npub).toMatch(/^npub1[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
it("produces consistent output", () => {
|
||||
const npub1 = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
|
||||
const npub2 = pubkeyToNpub(TEST_HEX_PRIVATE_KEY);
|
||||
expect(npub1).toBe(npub2);
|
||||
});
|
||||
|
||||
it("normalizes uppercase hex first", () => {
|
||||
const upper = TEST_HEX_PRIVATE_KEY.toUpperCase();
|
||||
expect(pubkeyToNpub(TEST_HEX_PRIVATE_KEY)).toBe(pubkeyToNpub(upper));
|
||||
});
|
||||
});
|
||||
799
extensions/nostr/src/nostr-bus.ts
Normal file
799
extensions/nostr/src/nostr-bus.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
// Nostr plugin module implements nostr bus behavior.
|
||||
import { SimplePool, finalizeEvent, getPublicKey, verifyEvent, type Event } from "nostr-tools";
|
||||
import { decrypt, encrypt } from "nostr-tools/nip04";
|
||||
import {
|
||||
createDirectDmPreCryptoGuardPolicy,
|
||||
type DirectDmPreCryptoGuardPolicyOverrides,
|
||||
} from "openclaw/plugin-sdk/direct-dm-guard-policy";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import { DEFAULT_RELAYS } from "./default-relays.js";
|
||||
import {
|
||||
createMetrics,
|
||||
createNoopMetrics,
|
||||
type NostrMetrics,
|
||||
type MetricsSnapshot,
|
||||
type MetricEvent,
|
||||
} from "./metrics.js";
|
||||
import { validatePrivateKey } from "./nostr-key-utils.js";
|
||||
import { publishProfile as publishProfileFn, type ProfilePublishResult } from "./nostr-profile.js";
|
||||
import {
|
||||
readNostrBusState,
|
||||
writeNostrBusState,
|
||||
computeSinceTimestamp,
|
||||
readNostrProfileState,
|
||||
writeNostrProfileState,
|
||||
} from "./nostr-state-store.js";
|
||||
import { createSeenTracker, type SeenTracker } from "./seen-tracker.js";
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
const DEFAULT_INBOUND_GUARD_POLICY = createDirectDmPreCryptoGuardPolicy();
|
||||
|
||||
// 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
|
||||
// ============================================================================
|
||||
|
||||
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<void>,
|
||||
meta: { eventId: string; createdAt: number },
|
||||
) => Promise<void>;
|
||||
/** Called after signature verification and before decrypt to allow sender policy checks (optional) */
|
||||
authorizeSender?: (params: {
|
||||
senderPubkey: string;
|
||||
reply: (text: string) => Promise<void>;
|
||||
}) => Promise<"allow" | "block" | "pairing">;
|
||||
/** Override pre-crypto DM guardrails for tests or future channel tuning (optional) */
|
||||
guardPolicy?: DirectDmPreCryptoGuardPolicyOverrides;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
type FixedWindowRateLimiter = {
|
||||
isRateLimited: (key: string, nowMs?: number) => boolean;
|
||||
size: () => number;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
function createFixedWindowRateLimiter(params: {
|
||||
windowMs: number;
|
||||
maxRequests: number;
|
||||
maxTrackedKeys: number;
|
||||
}): FixedWindowRateLimiter {
|
||||
const windowMs = Math.max(1, Math.floor(params.windowMs));
|
||||
const maxRequests = Math.max(1, Math.floor(params.maxRequests));
|
||||
const maxTrackedKeys = Math.max(1, Math.floor(params.maxTrackedKeys));
|
||||
const state = new Map<string, { count: number; windowStartMs: number }>();
|
||||
|
||||
const touch = (key: string, value: { count: number; windowStartMs: number }) => {
|
||||
state.delete(key);
|
||||
state.set(key, value);
|
||||
};
|
||||
|
||||
const prune = (nowMs: number) => {
|
||||
for (const [key, entry] of state) {
|
||||
if (nowMs - entry.windowStartMs >= windowMs) {
|
||||
state.delete(key);
|
||||
}
|
||||
}
|
||||
while (state.size > maxTrackedKeys) {
|
||||
const oldest = state.keys().next().value;
|
||||
if (!oldest) {
|
||||
break;
|
||||
}
|
||||
state.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isRateLimited: (key: string, nowMs = Date.now()) => {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
prune(nowMs);
|
||||
const existing = state.get(key);
|
||||
if (!existing || nowMs - existing.windowStartMs >= windowMs) {
|
||||
touch(key, { count: 1, windowStartMs: nowMs });
|
||||
return false;
|
||||
}
|
||||
const nextCount = existing.count + 1;
|
||||
touch(key, { count: nextCount, windowStartMs: existing.windowStartMs });
|
||||
return nextCount > maxRequests;
|
||||
},
|
||||
size: () => state.size,
|
||||
clear: () => state.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface NostrBusHandle {
|
||||
/** Stop the bus and close relay connections */
|
||||
close: () => void;
|
||||
/** Get the bot's public key */
|
||||
publicKey: string;
|
||||
/** Send a DM to a pubkey */
|
||||
sendDm: (toPubkey: string, text: string) => Promise<void>;
|
||||
/** Get current metrics snapshot */
|
||||
getMetrics: () => MetricsSnapshot;
|
||||
/** Publish a profile (kind:0) to all relays */
|
||||
publishProfile: (profile: NostrProfile) => Promise<ProfilePublishResult>;
|
||||
/** Get the last profile publish state */
|
||||
getProfileState: () => Promise<{
|
||||
lastPublishedAt: number | null;
|
||||
lastPublishedEventId: string | null;
|
||||
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | 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<string, RelayHealthStats>();
|
||||
|
||||
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].toSorted((a, b) => this.getScore(b) - this.getScore(a));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Bus
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
|
||||
*/
|
||||
export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusHandle> {
|
||||
const {
|
||||
privateKey,
|
||||
relays = DEFAULT_RELAYS,
|
||||
onMessage,
|
||||
authorizeSender,
|
||||
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);
|
||||
const guardPolicy = createDirectDmPreCryptoGuardPolicy({
|
||||
...DEFAULT_INBOUND_GUARD_POLICY,
|
||||
...options.guardPolicy,
|
||||
rateLimit: {
|
||||
...DEFAULT_INBOUND_GUARD_POLICY.rateLimit,
|
||||
...options.guardPolicy?.rateLimit,
|
||||
},
|
||||
});
|
||||
|
||||
// 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<string, CircuitBreaker>();
|
||||
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<typeof setTimeout> | 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: unknown) => onError?.(err as Error, "persist state"));
|
||||
}, STATE_PERSIST_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
const inflight = new Set<string>();
|
||||
const perSenderRateLimiter = createFixedWindowRateLimiter({
|
||||
windowMs: guardPolicy.rateLimit.windowMs,
|
||||
maxRequests: guardPolicy.rateLimit.maxPerSenderPerWindow,
|
||||
maxTrackedKeys: guardPolicy.rateLimit.maxTrackedSenderKeys,
|
||||
});
|
||||
const globalRateLimiter = createFixedWindowRateLimiter({
|
||||
windowMs: guardPolicy.rateLimit.windowMs,
|
||||
maxRequests: guardPolicy.rateLimit.maxGlobalPerWindow,
|
||||
maxTrackedKeys: 1,
|
||||
});
|
||||
|
||||
const updateRateLimiterSizeMetric = () => {
|
||||
metrics.emit(
|
||||
"memory.rate_limiter_entries",
|
||||
perSenderRateLimiter.size() + globalRateLimiter.size(),
|
||||
);
|
||||
};
|
||||
|
||||
// Event handler
|
||||
async function handleEvent(event: Event): Promise<void> {
|
||||
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);
|
||||
|
||||
const markSeen = () => {
|
||||
seen.add(event.id);
|
||||
metrics.emit("memory.seen_tracker_size", seen.size());
|
||||
};
|
||||
const rejectAndMarkSeen = (metric: Parameters<typeof metrics.emit>[0]) => {
|
||||
markSeen();
|
||||
metrics.emit(metric);
|
||||
};
|
||||
|
||||
// Self-message loop prevention: skip our own messages
|
||||
if (event.pubkey === pk) {
|
||||
rejectAndMarkSeen("event.rejected.self_message");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip events older than our `since` (relay may ignore filter)
|
||||
if (event.created_at < since) {
|
||||
rejectAndMarkSeen("event.rejected.stale");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.created_at > Math.floor(Date.now() / 1000) + guardPolicy.maxFutureSkewSec) {
|
||||
metrics.emit("event.rejected.future");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!guardPolicy.allowedKinds.includes(event.kind)) {
|
||||
rejectAndMarkSeen("event.rejected.wrong_kind");
|
||||
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) {
|
||||
rejectAndMarkSeen("event.rejected.wrong_kind");
|
||||
return;
|
||||
}
|
||||
|
||||
const replyTo = async (text: string): Promise<void> => {
|
||||
await sendEncryptedDm(
|
||||
pool,
|
||||
sk,
|
||||
event.pubkey,
|
||||
text,
|
||||
relays,
|
||||
metrics,
|
||||
circuitBreakers,
|
||||
healthTracker,
|
||||
onError,
|
||||
);
|
||||
};
|
||||
|
||||
const rejectIfGlobalRateLimited = (): boolean => {
|
||||
updateRateLimiterSizeMetric();
|
||||
if (globalRateLimiter.isRateLimited("global")) {
|
||||
metrics.emit("rate_limit.global");
|
||||
metrics.emit("event.rejected.rate_limited");
|
||||
updateRateLimiterSizeMetric();
|
||||
return true;
|
||||
}
|
||||
updateRateLimiterSizeMetric();
|
||||
return false;
|
||||
};
|
||||
|
||||
const rejectIfVerifiedSenderRateLimited = (): boolean => {
|
||||
updateRateLimiterSizeMetric();
|
||||
if (perSenderRateLimiter.isRateLimited(event.pubkey)) {
|
||||
metrics.emit("rate_limit.per_sender");
|
||||
metrics.emit("event.rejected.rate_limited");
|
||||
updateRateLimiterSizeMetric();
|
||||
return true;
|
||||
}
|
||||
updateRateLimiterSizeMetric();
|
||||
return false;
|
||||
};
|
||||
|
||||
if (Buffer.byteLength(event.content, "utf8") > guardPolicy.maxCiphertextBytes) {
|
||||
if (rejectIfGlobalRateLimited()) {
|
||||
return;
|
||||
}
|
||||
rejectAndMarkSeen("event.rejected.oversized_ciphertext");
|
||||
return;
|
||||
}
|
||||
|
||||
if (rejectIfGlobalRateLimited()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify signature (must pass before we trust the event)
|
||||
if (!verifyEvent(event)) {
|
||||
rejectAndMarkSeen("event.rejected.invalid_signature");
|
||||
onError?.(new Error("Invalid signature"), `event ${event.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rejectIfVerifiedSenderRateLimited()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authorizeSender) {
|
||||
const decision = await authorizeSender({
|
||||
senderPubkey: event.pubkey,
|
||||
reply: replyTo,
|
||||
});
|
||||
if (decision !== "allow") {
|
||||
markSeen();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt the message
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = decrypt(sk, event.pubkey, event.content);
|
||||
metrics.emit("decrypt.success");
|
||||
} catch (err) {
|
||||
markSeen();
|
||||
metrics.emit("decrypt.failure");
|
||||
metrics.emit("event.rejected.decrypt_failed");
|
||||
onError?.(err as Error, `decrypt from ${event.pubkey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(plaintext, "utf8") > guardPolicy.maxPlaintextBytes) {
|
||||
markSeen();
|
||||
metrics.emit("event.rejected.oversized_plaintext");
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the message handler
|
||||
await onMessage(event.pubkey, plaintext, replyTo, {
|
||||
eventId: event.id,
|
||||
createdAt: event.created_at,
|
||||
});
|
||||
|
||||
// Only cache successful deliveries so handler failures can retry.
|
||||
markSeen();
|
||||
|
||||
// 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 dmFilter = { kinds: [4], "#p": [pk], since } satisfies Parameters<
|
||||
typeof pool.subscribeMany
|
||||
>[1];
|
||||
const relayAbort = new AbortController();
|
||||
const sub = pool.subscribeMany(relays, dmFilter, {
|
||||
onevent: (event) => {
|
||||
void handleEvent(event);
|
||||
},
|
||||
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.join(", ")}`), "subscription");
|
||||
},
|
||||
abort: relayAbort.signal,
|
||||
});
|
||||
|
||||
// Public sendDm function
|
||||
const sendDm = async (toPubkey: string, text: string): Promise<void> => {
|
||||
await sendEncryptedDm(
|
||||
pool,
|
||||
sk,
|
||||
toPubkey,
|
||||
text,
|
||||
relays,
|
||||
metrics,
|
||||
circuitBreakers,
|
||||
healthTracker,
|
||||
onError,
|
||||
);
|
||||
};
|
||||
|
||||
// Profile publishing function
|
||||
const publishProfile = async (profile: NostrProfile): Promise<ProfilePublishResult> => {
|
||||
// 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<string, "ok" | "failed" | "timeout"> = {};
|
||||
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 stateLocal = await readNostrProfileState({ accountId });
|
||||
return {
|
||||
lastPublishedAt: stateLocal?.lastPublishedAt ?? null,
|
||||
lastPublishedEventId: stateLocal?.lastPublishedEventId ?? null,
|
||||
lastPublishResults: stateLocal?.lastPublishResults ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
relayAbort.abort("closed by caller");
|
||||
void Promise.resolve(sub.close("closed by caller"))
|
||||
.catch((err: unknown) => onError?.(err as Error, "close subscription"))
|
||||
.finally(() => {
|
||||
pool.close(relays);
|
||||
});
|
||||
seen.stop();
|
||||
perSenderRateLimiter.clear();
|
||||
globalRateLimiter.clear();
|
||||
// Flush pending state write synchronously on close
|
||||
if (pendingWrite) {
|
||||
clearTimeout(pendingWrite);
|
||||
writeNostrBusState({
|
||||
accountId,
|
||||
lastProcessedAt,
|
||||
gatewayStartedAt,
|
||||
recentEventIds,
|
||||
}).catch((err: unknown) => 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<string, CircuitBreaker>,
|
||||
healthTracker: RelayHealthTracker,
|
||||
onError?: (error: Error, context: string) => void,
|
||||
): Promise<void> {
|
||||
const ciphertext = 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 {
|
||||
const publishPromises = pool.publish([relay], reply);
|
||||
if (publishPromises.length === 0) {
|
||||
throw new Error(`Failed to create publish promise for relay ${relay}`);
|
||||
}
|
||||
const publishPromise = publishPromises[0];
|
||||
await publishPromise;
|
||||
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}`);
|
||||
}
|
||||
93
extensions/nostr/src/nostr-key-utils.ts
Normal file
93
extensions/nostr/src/nostr-key-utils.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Nostr helper module supports nostr key utils behavior.
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
|
||||
/**
|
||||
* 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] = Number.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" || typeof decoded.data !== "string") {
|
||||
throw new Error("Invalid npub key");
|
||||
}
|
||||
// nip19.decode(npub).data is already the hex pubkey (string), not Uint8Array.
|
||||
return decoded.data.toLowerCase();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
135
extensions/nostr/src/nostr-profile-core.ts
Normal file
135
extensions/nostr/src/nostr-profile-core.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// Nostr plugin module implements nostr profile core behavior.
|
||||
import { type NostrProfile, NostrProfileSchema } from "./config-schema.js";
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
return {
|
||||
name: escapeHtml(profile.name),
|
||||
displayName: escapeHtml(profile.displayName),
|
||||
about: escapeHtml(profile.about),
|
||||
picture: profile.picture,
|
||||
banner: profile.banner,
|
||||
website: profile.website,
|
||||
nip05: escapeHtml(profile.nip05),
|
||||
lud16: escapeHtml(profile.lud16),
|
||||
};
|
||||
}
|
||||
7
extensions/nostr/src/nostr-profile-http-runtime.ts
Normal file
7
extensions/nostr/src/nostr-profile-http-runtime.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Nostr plugin module implements nostr profile http runtime behavior.
|
||||
export {
|
||||
readJsonBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
export { createFixedWindowRateLimiter } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export { getPluginRuntimeGatewayRequestScope } from "../runtime-api.js";
|
||||
632
extensions/nostr/src/nostr-profile-http.test.ts
Normal file
632
extensions/nostr/src/nostr-profile-http.test.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* Tests for Nostr Profile HTTP Handler
|
||||
*/
|
||||
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearNostrProfileRateLimitStateForTest,
|
||||
createNostrProfileHttpHandler,
|
||||
getNostrProfileRateLimitStateSizeForTest,
|
||||
isNostrProfileRateLimitedForTest,
|
||||
type NostrProfileHttpContext,
|
||||
} from "./nostr-profile-http.js";
|
||||
|
||||
const runtimeScopeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./nostr-profile-http-runtime.js", async () => {
|
||||
const webhookIngress = await import("openclaw/plugin-sdk/webhook-ingress");
|
||||
const requestGuards = await import("openclaw/plugin-sdk/webhook-request-guards");
|
||||
return {
|
||||
createFixedWindowRateLimiter: webhookIngress.createFixedWindowRateLimiter,
|
||||
readJsonBodyWithLimit: requestGuards.readJsonBodyWithLimit,
|
||||
requestBodyErrorToText: requestGuards.requestBodyErrorToText,
|
||||
getPluginRuntimeGatewayRequestScope: runtimeScopeMock,
|
||||
};
|
||||
});
|
||||
|
||||
// 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";
|
||||
import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
|
||||
|
||||
// ============================================================================
|
||||
// Test Helpers
|
||||
// ============================================================================
|
||||
|
||||
const TEST_PROFILE_RELAY_URL = TEST_SETUP_RELAY_URLS[0];
|
||||
|
||||
afterAll(() => {
|
||||
runtimeScopeMock.mockReset();
|
||||
});
|
||||
|
||||
function setGatewayRuntimeScopes(scopes: readonly string[] | undefined): void {
|
||||
if (!scopes) {
|
||||
runtimeScopeMock.mockReturnValue(undefined);
|
||||
return;
|
||||
}
|
||||
runtimeScopeMock.mockReturnValue({
|
||||
client: {
|
||||
connect: {
|
||||
scopes: [...scopes],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function responseChunkText(chunk: unknown): string {
|
||||
if (typeof chunk === "string") {
|
||||
return chunk;
|
||||
}
|
||||
if (Buffer.isBuffer(chunk)) {
|
||||
return chunk.toString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createMockRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown,
|
||||
opts?: { headers?: Record<string, string>; remoteAddress?: string },
|
||||
): IncomingMessage {
|
||||
const socket = new Socket();
|
||||
Object.defineProperty(socket, "remoteAddress", {
|
||||
value: opts?.remoteAddress ?? "127.0.0.1",
|
||||
configurable: true,
|
||||
});
|
||||
const req = new IncomingMessage(socket);
|
||||
req.method = method;
|
||||
req.url = url;
|
||||
req.headers = { host: "localhost:3000", ...opts?.headers };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
type MockResponse = {
|
||||
_getData: () => string;
|
||||
_getStatusCode: () => number;
|
||||
write: (chunk: unknown) => boolean;
|
||||
end: (chunk?: unknown) => MockResponse;
|
||||
statusCode: number;
|
||||
};
|
||||
|
||||
function createMockResponse(): MockResponse {
|
||||
let data = "";
|
||||
let statusCode = 200;
|
||||
const res = Object.assign(new ServerResponse({} as IncomingMessage), {
|
||||
_getData: () => data,
|
||||
_getStatusCode: () => statusCode,
|
||||
}) as MockResponse;
|
||||
|
||||
res.write = function (chunk: unknown) {
|
||||
data += responseChunkText(chunk);
|
||||
return true;
|
||||
};
|
||||
|
||||
res.end = function (chunk?: unknown) {
|
||||
if (chunk) {
|
||||
data += responseChunkText(chunk);
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
Object.defineProperty(res, "statusCode", {
|
||||
get: () => statusCode,
|
||||
set: (code: number) => {
|
||||
statusCode = code;
|
||||
},
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrProfileHttpContext {
|
||||
return {
|
||||
getConfigProfile: vi.fn().mockReturnValue(undefined),
|
||||
updateConfigProfile: vi.fn().mockResolvedValue(undefined),
|
||||
getAccountInfo: vi.fn().mockReturnValue({
|
||||
pubkey: TEST_HEX_PUBLIC_KEY,
|
||||
relays: [TEST_PROFILE_RELAY_URL],
|
||||
}),
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createProfileHttpHarness(
|
||||
method: string,
|
||||
url: string,
|
||||
options?: {
|
||||
body?: unknown;
|
||||
ctx?: Partial<NostrProfileHttpContext>;
|
||||
req?: Parameters<typeof createMockRequest>[3];
|
||||
},
|
||||
) {
|
||||
const ctx = createMockContext(options?.ctx);
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(method, url, options?.body, options?.req);
|
||||
const res = createMockResponse();
|
||||
|
||||
return {
|
||||
ctx,
|
||||
req,
|
||||
res,
|
||||
run: () => handler(req, res as unknown as ServerResponse),
|
||||
};
|
||||
}
|
||||
|
||||
function expectOkResponse(res: MockResponse) {
|
||||
expect(res["_getStatusCode"]()).toBe(200);
|
||||
const data = JSON.parse(res["_getData"]());
|
||||
expect(data.ok).toBe(true);
|
||||
return data;
|
||||
}
|
||||
|
||||
function mockSuccessfulProfileImport() {
|
||||
vi.mocked(importProfileFromRelays).mockResolvedValue({
|
||||
ok: true,
|
||||
profile: {
|
||||
name: "imported",
|
||||
displayName: "Imported User",
|
||||
},
|
||||
event: {
|
||||
id: "evt123",
|
||||
pubkey: TEST_HEX_PUBLIC_KEY,
|
||||
created_at: 1234567890,
|
||||
},
|
||||
relaysQueried: [TEST_PROFILE_RELAY_URL],
|
||||
sourceRelay: TEST_PROFILE_RELAY_URL,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectAdminScopeRejected(params: {
|
||||
scopes: readonly string[] | undefined;
|
||||
method: string;
|
||||
url: string;
|
||||
body: unknown;
|
||||
expectOperationNotCalled: () => void;
|
||||
}) {
|
||||
setGatewayRuntimeScopes(params.scopes);
|
||||
const { ctx, res, run } = createProfileHttpHarness(params.method, params.url, {
|
||||
body: params.body,
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
expect(res["_getStatusCode"]()).toBe(403);
|
||||
const data = JSON.parse(res["_getData"]());
|
||||
expect(data.error).toBe("missing scope: operator.admin");
|
||||
params.expectOperationNotCalled();
|
||||
expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe("nostr-profile-http", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearNostrProfileRateLimitStateForTest();
|
||||
setGatewayRuntimeScopes(["operator.admin"]);
|
||||
});
|
||||
|
||||
describe("route matching", () => {
|
||||
it("returns false for non-nostr paths", async () => {
|
||||
const { run } = createProfileHttpHarness("GET", "/api/channels/telegram/profile");
|
||||
const result = await run();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for paths without accountId", async () => {
|
||||
const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/");
|
||||
const result = await run();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("handles /api/channels/nostr/:accountId/profile", async () => {
|
||||
const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile");
|
||||
|
||||
vi.mocked(getNostrProfileState).mockResolvedValue(null);
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/channels/nostr/:accountId/profile", () => {
|
||||
it("returns profile and publish state", async () => {
|
||||
const { res, run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile", {
|
||||
ctx: {
|
||||
getConfigProfile: vi.fn().mockReturnValue({
|
||||
name: "testuser",
|
||||
displayName: "Test User",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(getNostrProfileState).mockResolvedValue({
|
||||
lastPublishedAt: 1234567890,
|
||||
lastPublishedEventId: "abc123",
|
||||
lastPublishResults: { [TEST_PROFILE_RELAY_URL]: "ok" },
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
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", () => {
|
||||
function mockPublishSuccess() {
|
||||
vi.mocked(publishNostrProfile).mockResolvedValue({
|
||||
eventId: "event123",
|
||||
createdAt: 1234567890,
|
||||
successes: [TEST_PROFILE_RELAY_URL],
|
||||
failures: [],
|
||||
});
|
||||
}
|
||||
|
||||
function expectBadRequestResponse(res: ReturnType<typeof createMockResponse>) {
|
||||
expect(res["_getStatusCode"]()).toBe(400);
|
||||
const data = JSON.parse(res["_getData"]());
|
||||
expect(data.ok).toBe(false);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function expectPrivatePictureRejected(pictureUrl: string) {
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: {
|
||||
name: "hacker",
|
||||
picture: pictureUrl,
|
||||
},
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
const data = expectBadRequestResponse(res);
|
||||
expect(data.error).toContain("private");
|
||||
}
|
||||
|
||||
it("validates profile and publishes", async () => {
|
||||
const { ctx, res, run } = createProfileHttpHarness(
|
||||
"PUT",
|
||||
"/api/channels/nostr/default/profile",
|
||||
{
|
||||
body: {
|
||||
name: "satoshi",
|
||||
displayName: "Satoshi Nakamoto",
|
||||
about: "Creator of Bitcoin",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockPublishSuccess();
|
||||
|
||||
await run();
|
||||
|
||||
const data = expectOkResponse(res);
|
||||
expect(data.eventId).toBe("event123");
|
||||
expect(data.successes).toContain(TEST_PROFILE_RELAY_URL);
|
||||
expect(data.persisted).toBe(true);
|
||||
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects profile mutation from non-loopback remote address", async () => {
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: { name: "attacker" },
|
||||
req: { remoteAddress: "198.51.100.10" },
|
||||
});
|
||||
|
||||
await run();
|
||||
expect(res["_getStatusCode"]()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects cross-origin profile mutation attempts", async () => {
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: { name: "attacker" },
|
||||
req: { headers: { origin: "https://evil.example" } },
|
||||
});
|
||||
|
||||
await run();
|
||||
expect(res["_getStatusCode"]()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: { name: "attacker" },
|
||||
req: { headers: { "sec-fetch-site": "cross-site" } },
|
||||
});
|
||||
|
||||
await run();
|
||||
expect(res["_getStatusCode"]()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects profile mutation when forwarded client ip is non-loopback", async () => {
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: { name: "attacker" },
|
||||
req: { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
|
||||
});
|
||||
|
||||
await run();
|
||||
expect(res["_getStatusCode"]()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects profile mutation when gateway caller is missing operator.admin", async () => {
|
||||
await expectAdminScopeRejected({
|
||||
scopes: ["operator.read"],
|
||||
method: "PUT",
|
||||
url: "/api/channels/nostr/default/profile",
|
||||
body: { name: "attacker" },
|
||||
expectOperationNotCalled: () => expect(publishNostrProfile).not.toHaveBeenCalled(),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects profile mutation when gateway scope context is missing", async () => {
|
||||
await expectAdminScopeRejected({
|
||||
scopes: undefined,
|
||||
method: "PUT",
|
||||
url: "/api/channels/nostr/default/profile",
|
||||
body: { name: "attacker" },
|
||||
expectOperationNotCalled: () => expect(publishNostrProfile).not.toHaveBeenCalled(),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects private IP in picture URL (SSRF protection)", async () => {
|
||||
await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
|
||||
});
|
||||
|
||||
it("rejects ISATAP-embedded private IPv4 in picture URL", async () => {
|
||||
await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg");
|
||||
});
|
||||
|
||||
it("rejects non-https URLs", async () => {
|
||||
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
||||
body: {
|
||||
name: "test",
|
||||
picture: "http://example.com/pic.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
const data = expectBadRequestResponse(res);
|
||||
// The schema validation catches non-https URLs before SSRF check
|
||||
expect(data.error).toBe("Validation failed");
|
||||
expect(Array.isArray(data.details)).toBe(true);
|
||||
expect(data.details).toEqual(["picture: URL must use https:// protocol"]);
|
||||
});
|
||||
|
||||
it("does not persist if all relays fail", async () => {
|
||||
const { ctx, res, run } = createProfileHttpHarness(
|
||||
"PUT",
|
||||
"/api/channels/nostr/default/profile",
|
||||
{
|
||||
body: {
|
||||
name: "test",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(publishNostrProfile).mockResolvedValue({
|
||||
eventId: "event123",
|
||||
createdAt: 1234567890,
|
||||
successes: [],
|
||||
failures: [{ relay: TEST_PROFILE_RELAY_URL, error: "timeout" }],
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
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 () => {
|
||||
mockPublishSuccess();
|
||||
|
||||
// Make 6 requests (limit is 5/min)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"PUT",
|
||||
"/api/channels/nostr/rate-test/profile",
|
||||
{
|
||||
body: {
|
||||
name: `user${i}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
await run();
|
||||
|
||||
if (i < 5) {
|
||||
expectOkResponse(res);
|
||||
} else {
|
||||
expect(res["_getStatusCode"]()).toBe(429);
|
||||
const data = JSON.parse(res["_getData"]());
|
||||
expect(data.error).toContain("Rate limit");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("caps tracked rate-limit keys to prevent unbounded growth", () => {
|
||||
const now = 1_000_000;
|
||||
for (let i = 0; i < 2_500; i += 1) {
|
||||
isNostrProfileRateLimitedForTest(`rate-cap-${i}`, now);
|
||||
}
|
||||
expect(getNostrProfileRateLimitStateSizeForTest()).toBeLessThanOrEqual(2_048);
|
||||
});
|
||||
|
||||
it("prunes stale rate-limit keys after the window elapses", () => {
|
||||
const now = 2_000_000;
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
isNostrProfileRateLimitedForTest(`rate-stale-${i}`, now);
|
||||
}
|
||||
expect(getNostrProfileRateLimitStateSizeForTest()).toBe(100);
|
||||
|
||||
isNostrProfileRateLimitedForTest("fresh", now + 60_001);
|
||||
expect(getNostrProfileRateLimitStateSizeForTest()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/channels/nostr/:accountId/profile/import", () => {
|
||||
function expectImportSuccessResponse(res: ReturnType<typeof createMockResponse>) {
|
||||
const data = expectOkResponse(res);
|
||||
expect(data.imported.name).toBe("imported");
|
||||
return data;
|
||||
}
|
||||
|
||||
it("imports profile from relays", async () => {
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{ body: {} },
|
||||
);
|
||||
|
||||
mockSuccessfulProfileImport();
|
||||
|
||||
await run();
|
||||
|
||||
const data = expectImportSuccessResponse(res);
|
||||
expect(data.saved).toBe(false); // autoMerge not requested
|
||||
});
|
||||
|
||||
it("rejects import mutation from non-loopback remote address", async () => {
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{
|
||||
body: {},
|
||||
req: { remoteAddress: "203.0.113.10" },
|
||||
},
|
||||
);
|
||||
|
||||
await run();
|
||||
expect(res["_getStatusCode"]()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects cross-origin import mutation attempts", async () => {
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{
|
||||
body: {},
|
||||
req: { headers: { origin: "https://evil.example" } },
|
||||
},
|
||||
);
|
||||
|
||||
await run();
|
||||
expect(res["_getStatusCode"]()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects import mutation when x-real-ip is non-loopback", async () => {
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{
|
||||
body: {},
|
||||
req: { headers: { "x-real-ip": "198.51.100.55" } },
|
||||
},
|
||||
);
|
||||
|
||||
await run();
|
||||
expect(res["_getStatusCode"]()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects profile import when gateway caller is missing operator.admin", async () => {
|
||||
await expectAdminScopeRejected({
|
||||
scopes: ["operator.read"],
|
||||
method: "POST",
|
||||
url: "/api/channels/nostr/default/profile/import",
|
||||
body: { autoMerge: true },
|
||||
expectOperationNotCalled: () => expect(importProfileFromRelays).not.toHaveBeenCalled(),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects profile import when gateway scope context is missing", async () => {
|
||||
await expectAdminScopeRejected({
|
||||
scopes: undefined,
|
||||
method: "POST",
|
||||
url: "/api/channels/nostr/default/profile/import",
|
||||
body: { autoMerge: true },
|
||||
expectOperationNotCalled: () => expect(importProfileFromRelays).not.toHaveBeenCalled(),
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-merges when requested", async () => {
|
||||
const { ctx, res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{
|
||||
body: { autoMerge: true },
|
||||
ctx: {
|
||||
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockSuccessfulProfileImport();
|
||||
|
||||
await run();
|
||||
|
||||
const data = expectImportSuccessResponse(res);
|
||||
expect(data.saved).toBe(true);
|
||||
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error when account not found", async () => {
|
||||
const { res, run } = createProfileHttpHarness(
|
||||
"POST",
|
||||
"/api/channels/nostr/unknown/profile/import",
|
||||
{
|
||||
body: {},
|
||||
ctx: {
|
||||
getAccountInfo: vi.fn().mockReturnValue(null),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await run();
|
||||
|
||||
expect(res["_getStatusCode"]()).toBe(404);
|
||||
const data = JSON.parse(res["_getData"]());
|
||||
expect(data.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
563
extensions/nostr/src/nostr-profile-http.ts
Normal file
563
extensions/nostr/src/nostr-profile-http.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* 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 { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
readStringValue,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { z } from "zod";
|
||||
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
||||
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
getPluginRuntimeGatewayRequestScope,
|
||||
readJsonBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "./nostr-profile-http-runtime.js";
|
||||
import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
|
||||
import { validateUrlSafety } from "./nostr-profile-url-safety.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<void>;
|
||||
/** 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
|
||||
// ============================================================================
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
|
||||
const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute
|
||||
const RATE_LIMIT_MAX_TRACKED_KEYS = 2_048;
|
||||
const profileRateLimiter = createFixedWindowRateLimiter({
|
||||
windowMs: RATE_LIMIT_WINDOW_MS,
|
||||
maxRequests: RATE_LIMIT_MAX_REQUESTS,
|
||||
maxTrackedKeys: RATE_LIMIT_MAX_TRACKED_KEYS,
|
||||
});
|
||||
|
||||
export function clearNostrProfileRateLimitStateForTest(): void {
|
||||
profileRateLimiter.clear();
|
||||
}
|
||||
|
||||
export function getNostrProfileRateLimitStateSizeForTest(): number {
|
||||
return profileRateLimiter.size();
|
||||
}
|
||||
|
||||
export function isNostrProfileRateLimitedForTest(accountId: string, nowMs: number): boolean {
|
||||
return profileRateLimiter.isRateLimited(accountId, nowMs);
|
||||
}
|
||||
|
||||
function checkRateLimit(accountId: string): boolean {
|
||||
return !profileRateLimiter.isRateLimited(accountId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mutex for Concurrent Publish Prevention
|
||||
// ============================================================================
|
||||
|
||||
const publishLocks = new KeyedAsyncQueue();
|
||||
|
||||
async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Promise<T> {
|
||||
return await publishLocks.enqueue(accountId, fn);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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,
|
||||
});
|
||||
|
||||
const PROFILE_MUTATION_SCOPE = "operator.admin";
|
||||
|
||||
// ============================================================================
|
||||
// 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,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<unknown> {
|
||||
const result = await readJsonBodyWithLimit(req, {
|
||||
maxBytes,
|
||||
timeoutMs,
|
||||
emptyObjectOnEmpty: true,
|
||||
});
|
||||
if (result.ok) {
|
||||
return result.value;
|
||||
}
|
||||
if (result.code === "PAYLOAD_TOO_LARGE") {
|
||||
throw new Error("Request body too large");
|
||||
}
|
||||
if (result.code === "REQUEST_BODY_TIMEOUT") {
|
||||
throw new Error(requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
|
||||
}
|
||||
if (result.code === "CONNECTION_CLOSED") {
|
||||
throw new Error(requestBodyErrorToText("CONNECTION_CLOSED"));
|
||||
}
|
||||
throw new Error(result.code === "INVALID_JSON" ? "Invalid JSON" : result.error);
|
||||
}
|
||||
|
||||
function parseAccountIdFromPath(pathname: string): string | null {
|
||||
// Match: /api/channels/nostr/:accountId/profile
|
||||
const match = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean {
|
||||
if (!remoteAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ipLower = normalizeLowercaseStringOrEmpty(remoteAddress).replace(/^\[|\]$/g, "");
|
||||
|
||||
// IPv6 loopback
|
||||
if (ipLower === "::1") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// IPv4 loopback (127.0.0.0/8)
|
||||
if (ipLower === "127.0.0.1" || ipLower.startsWith("127.")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6
|
||||
const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (v4Mapped) {
|
||||
return isLoopbackRemoteAddress(v4Mapped[1]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLoopbackOriginLike(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const hostname = normalizeLowercaseStringOrEmpty(url.hostname);
|
||||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return readStringValue(value);
|
||||
}
|
||||
|
||||
function normalizeIpCandidate(raw: string): string {
|
||||
const unquoted = raw.trim().replace(/^"|"$/g, "");
|
||||
const bracketedWithOptionalPort = unquoted.match(/^\[([^[\]]+)\](?::\d+)?$/);
|
||||
if (bracketedWithOptionalPort) {
|
||||
return bracketedWithOptionalPort[1] ?? "";
|
||||
}
|
||||
const ipv4WithPort = unquoted.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/);
|
||||
if (ipv4WithPort) {
|
||||
return ipv4WithPort[1] ?? "";
|
||||
}
|
||||
return unquoted;
|
||||
}
|
||||
|
||||
function hasNonLoopbackForwardedClient(req: IncomingMessage): boolean {
|
||||
const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]);
|
||||
if (forwardedFor) {
|
||||
for (const hop of forwardedFor.split(",")) {
|
||||
const candidate = normalizeIpCandidate(hop);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
if (!isLoopbackRemoteAddress(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const realIp = firstHeaderValue(req.headers["x-real-ip"]);
|
||||
if (realIp) {
|
||||
const candidate = normalizeIpCandidate(realIp);
|
||||
if (candidate && !isLoopbackRemoteAddress(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function enforceLoopbackMutationGuards(
|
||||
ctx: NostrProfileHttpContext,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): boolean {
|
||||
// Mutation endpoints are local-control-plane only.
|
||||
const remoteAddress = req.socket.remoteAddress;
|
||||
if (!isLoopbackRemoteAddress(remoteAddress)) {
|
||||
ctx.log?.warn?.(`Rejected mutation from non-loopback remoteAddress=${String(remoteAddress)}`);
|
||||
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
||||
return false;
|
||||
}
|
||||
|
||||
// If a proxy exposes client-origin headers showing a non-loopback client,
|
||||
// treat this as a remote request and deny mutation.
|
||||
if (hasNonLoopbackForwardedClient(req)) {
|
||||
ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers");
|
||||
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
||||
return false;
|
||||
}
|
||||
|
||||
const secFetchSite = normalizeOptionalLowercaseString(
|
||||
firstHeaderValue(req.headers["sec-fetch-site"]),
|
||||
);
|
||||
if (secFetchSite === "cross-site") {
|
||||
ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header");
|
||||
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
||||
return false;
|
||||
}
|
||||
|
||||
// CSRF guard: browsers send Origin/Referer on cross-site requests.
|
||||
const origin = firstHeaderValue(req.headers.origin);
|
||||
if (typeof origin === "string" && !isLoopbackOriginLike(origin)) {
|
||||
ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`);
|
||||
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
||||
return false;
|
||||
}
|
||||
|
||||
const referer = firstHeaderValue(req.headers.referer ?? req.headers.referrer);
|
||||
if (typeof referer === "string" && !isLoopbackOriginLike(referer)) {
|
||||
ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`);
|
||||
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function enforceGatewayMutationScope(
|
||||
ctx: NostrProfileHttpContext,
|
||||
accountId: string,
|
||||
res: ServerResponse,
|
||||
): boolean {
|
||||
const runtimeScopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes;
|
||||
const scopes = Array.isArray(runtimeScopes) ? runtimeScopes : [];
|
||||
if (scopes.includes(PROFILE_MUTATION_SCOPE)) {
|
||||
return true;
|
||||
}
|
||||
ctx.log?.warn?.(`[${accountId}] Rejected profile mutation missing ${PROFILE_MUTATION_SCOPE}`);
|
||||
sendJson(res, 403, { ok: false, error: `missing scope: ${PROFILE_MUTATION_SCOPE}` });
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Handler
|
||||
// ============================================================================
|
||||
|
||||
export function createNostrProfileHttpHandler(
|
||||
ctx: NostrProfileHttpContext,
|
||||
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
||||
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<true> {
|
||||
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<true> {
|
||||
if (!enforceGatewayMutationScope(ctx, accountId, res)) {
|
||||
return true;
|
||||
}
|
||||
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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<true> {
|
||||
if (!enforceGatewayMutationScope(ctx, accountId, res)) {
|
||||
return true;
|
||||
}
|
||||
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
196
extensions/nostr/src/nostr-profile-import.test.ts
Normal file
196
extensions/nostr/src/nostr-profile-import.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Tests for Nostr Profile Import
|
||||
*/
|
||||
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
subscribeMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("nostr-tools", () => {
|
||||
class MockSimplePool {
|
||||
subscribeMany(
|
||||
relays: string[],
|
||||
filters: unknown,
|
||||
handlers: {
|
||||
onevent: (event: Record<string, unknown>) => void;
|
||||
oneose?: () => void;
|
||||
onclose?: () => void;
|
||||
},
|
||||
) {
|
||||
mockState.subscribeMany(relays, filters, handlers);
|
||||
queueMicrotask(() => handlers.oneose?.());
|
||||
return {
|
||||
close: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
close = vi.fn();
|
||||
}
|
||||
|
||||
return {
|
||||
SimplePool: MockSimplePool,
|
||||
verifyEvent: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock SimplePool so importProfileFromRelays can assert the relay subscription shape.
|
||||
|
||||
describe("nostr-profile-import", () => {
|
||||
beforeEach(() => {
|
||||
mockState.subscribeMany.mockClear();
|
||||
});
|
||||
|
||||
describe("importProfileFromRelays", () => {
|
||||
it("subscribes to profiles with a single Nostr filter object", async () => {
|
||||
const pubkey = "a".repeat(64);
|
||||
|
||||
await importProfileFromRelays({
|
||||
pubkey,
|
||||
relays: ["wss://relay.example"],
|
||||
timeoutMs: 1,
|
||||
});
|
||||
|
||||
expect(mockState.subscribeMany).toHaveBeenCalledTimes(1);
|
||||
const filters = mockState.subscribeMany.mock.calls[0]?.[1];
|
||||
expect(Array.isArray(filters)).toBe(false);
|
||||
expect(filters).toMatchObject({
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
limit: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("caps oversized relay timeouts and clears pending timeout handles", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const clearSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
|
||||
await importProfileFromRelays({
|
||||
pubkey: "a".repeat(64),
|
||||
relays: ["wss://relay.example"],
|
||||
timeoutMs: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
expect(timeoutSpy).toHaveBeenCalledTimes(2);
|
||||
expect(clearSpy).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeProfiles", () => {
|
||||
it("returns empty object when both are undefined", () => {
|
||||
const result = mergeProfiles(undefined, undefined);
|
||||
expect(result).toStrictEqual({});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
273
extensions/nostr/src/nostr-profile-import.ts
Normal file
273
extensions/nostr/src/nostr-profile-import.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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 { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import { validateUrlSafety } from "./nostr-profile-url-safety.js";
|
||||
import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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<ProfileImportResult> {
|
||||
const { pubkey, relays } = opts;
|
||||
const timeoutMs = resolveTimerTimeoutMs(opts.timeoutMs, DEFAULT_TIMEOUT_MS);
|
||||
|
||||
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[] = [];
|
||||
const timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||
const scheduleTimeout = (callback: () => void) => {
|
||||
const timer = setTimeout(callback, timeoutMs);
|
||||
timer.unref?.();
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
};
|
||||
|
||||
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<void>((resolve) => {
|
||||
scheduleTimeout(resolve);
|
||||
});
|
||||
|
||||
// Create subscription promise
|
||||
const subscriptionPromise = new Promise<void>((resolve) => {
|
||||
let completed = 0;
|
||||
|
||||
for (const relay of relays) {
|
||||
relaysQueried.push(relay);
|
||||
|
||||
const profileFilter = {
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
limit: 1,
|
||||
} satisfies Parameters<typeof pool.subscribeMany>[1];
|
||||
|
||||
const sub = pool.subscribeMany([relay], profileFilter, {
|
||||
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
|
||||
scheduleTimeout(() => {
|
||||
sub.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either all relays to respond or timeout
|
||||
await Promise.race([subscriptionPromise, timeoutPromise]);
|
||||
for (const timer of timers.splice(0)) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
22
extensions/nostr/src/nostr-profile-url-safety.ts
Normal file
22
extensions/nostr/src/nostr-profile-url-safety.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Nostr plugin module implements nostr profile url safety behavior.
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
export 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.trim().toLowerCase();
|
||||
|
||||
if (isBlockedHostnameOrIp(hostname)) {
|
||||
return { ok: false, error: "URL must not point to private/internal addresses" };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid URL format" };
|
||||
}
|
||||
}
|
||||
431
extensions/nostr/src/nostr-profile.fuzz.test.ts
Normal file
431
extensions/nostr/src/nostr-profile.fuzz.test.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
// Nostr tests cover nostr profile.fuzz plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import {
|
||||
profileToContent,
|
||||
sanitizeProfileForDisplay,
|
||||
validateProfile,
|
||||
} from "./nostr-profile-core.js";
|
||||
|
||||
const max256ProfileFieldCases = [
|
||||
{ field: "name", char: "a" },
|
||||
{ field: "displayName", char: "b" },
|
||||
] as const;
|
||||
|
||||
// ============================================================================
|
||||
// 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);
|
||||
if (!result.profile) {
|
||||
throw new Error("expected validated profile");
|
||||
}
|
||||
expect(result).toEqual({
|
||||
valid: true,
|
||||
profile: { name: "\u202Eevil\u202C" },
|
||||
});
|
||||
|
||||
// UI should escape or handle this
|
||||
const sanitized = sanitizeProfileForDisplay(result.profile);
|
||||
expect(sanitized.name).toBe("\u202Eevil\u202C");
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
// Keep the source small (faster transforms) while still exercising
|
||||
// "lots of combining marks" behavior.
|
||||
const marks = "\u0301\u0300\u0336\u034f\u035c\u0360";
|
||||
const zalgo = `t${marks.repeat(256)}e${marks.repeat(256)}s${marks.repeat(256)}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: '<script>alert("xss")</script>',
|
||||
};
|
||||
const sanitized = sanitizeProfileForDisplay(profile);
|
||||
expect(sanitized.name).not.toContain("<script>");
|
||||
expect(sanitized.name).toContain("<script>");
|
||||
});
|
||||
|
||||
it("escapes nested script tags", () => {
|
||||
const profile: NostrProfile = {
|
||||
about: '<<script>script>alert("xss")<</script>/script>',
|
||||
};
|
||||
const sanitized = sanitizeProfileForDisplay(profile);
|
||||
expect(sanitized.about).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("event handler injection", () => {
|
||||
it("escapes img onerror", () => {
|
||||
const profile: NostrProfile = {
|
||||
about: '<img src="x" onerror="alert(1)">',
|
||||
};
|
||||
const sanitized = sanitizeProfileForDisplay(profile);
|
||||
expect(sanitized.about).toContain("<img");
|
||||
expect(sanitized.about).not.toContain('onerror="alert');
|
||||
});
|
||||
|
||||
it("escapes svg onload", () => {
|
||||
const profile: NostrProfile = {
|
||||
about: '<svg onload="alert(1)">',
|
||||
};
|
||||
const sanitized = sanitizeProfileForDisplay(profile);
|
||||
expect(sanitized.about).toContain("<svg");
|
||||
});
|
||||
|
||||
it("escapes body onload", () => {
|
||||
const profile: NostrProfile = {
|
||||
about: '<body onload="alert(1)">',
|
||||
};
|
||||
const sanitized = sanitizeProfileForDisplay(profile);
|
||||
expect(sanitized.about).toContain("<body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL-based attacks", () => {
|
||||
it("rejects javascript: URL in picture", () => {
|
||||
const profile = {
|
||||
picture: "javascript:alert('xss')",
|
||||
};
|
||||
const result = validateProfile(profile);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects javascript: URL with encoding", () => {
|
||||
const profile = {
|
||||
picture: "javascript:alert('xss')",
|
||||
};
|
||||
const result = validateProfile(profile);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects data: URL", () => {
|
||||
const profile = {
|
||||
picture: "data:text/html,<script>alert('xss')</script>",
|
||||
};
|
||||
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: '<style>body{background:url("javascript:alert(1)")}</style>',
|
||||
};
|
||||
const sanitized = sanitizeProfileForDisplay(profile);
|
||||
expect(sanitized.about).toContain("<style>");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Length Boundary Tests
|
||||
// ============================================================================
|
||||
|
||||
describe("profile length boundaries", () => {
|
||||
describe("short text fields (max 256)", () => {
|
||||
it.each(max256ProfileFieldCases)(
|
||||
"accepts exactly 256 characters for $field",
|
||||
({ char, field }) => {
|
||||
const result = validateProfile({ [field]: char.repeat(256) });
|
||||
expect(result.valid).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(max256ProfileFieldCases)("rejects 257 characters for $field", ({ char, field }) => {
|
||||
const result = validateProfile({ [field]: char.repeat(257) });
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("name field (max 256)", () => {
|
||||
it("accepts empty string", () => {
|
||||
const result = validateProfile({ name: "" });
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
validateProfile(malicious);
|
||||
// Should not pollute Object.prototype
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
488
extensions/nostr/src/nostr-profile.test.ts
Normal file
488
extensions/nostr/src/nostr-profile.test.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
// Nostr tests cover nostr profile plugin behavior.
|
||||
import { verifyEvent, getPublicKey, type SimplePool } from "nostr-tools";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import {
|
||||
createProfileEvent,
|
||||
profileToContent,
|
||||
contentToProfile,
|
||||
validateProfile,
|
||||
sanitizeProfileForDisplay,
|
||||
publishProfile,
|
||||
type ProfileContent,
|
||||
} from "./nostr-profile.js";
|
||||
import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js";
|
||||
|
||||
const TEST_PUBKEY = getPublicKey(TEST_HEX_PRIVATE_KEY_BYTES);
|
||||
|
||||
function createTestProfileEvent(profile: NostrProfile, lastPublishedAt?: number) {
|
||||
return createProfileEvent(TEST_HEX_PRIVATE_KEY_BYTES, profile, lastPublishedAt);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("creates a valid kind:0 event", () => {
|
||||
const profile: NostrProfile = {
|
||||
name: "testbot",
|
||||
about: "A test bot",
|
||||
};
|
||||
|
||||
const event = createTestProfileEvent(profile);
|
||||
|
||||
expect(event.kind).toBe(0);
|
||||
expect(event.pubkey).toBe(TEST_PUBKEY);
|
||||
expect(event.tags).toStrictEqual([]);
|
||||
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 = createTestProfileEvent(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 = createTestProfileEvent(profile);
|
||||
|
||||
expect(verifyEvent(event)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses current timestamp when no lastPublishedAt provided", () => {
|
||||
const profile: NostrProfile = { name: "timestamptest" };
|
||||
const event = createTestProfileEvent(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 = createTestProfileEvent(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 = createTestProfileEvent(profile, pastTimestamp);
|
||||
|
||||
const expectedTimestamp = Math.floor(Date.now() / 1000);
|
||||
expect(event.created_at).toBe(expectedTimestamp);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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?.name).toBe("validuser");
|
||||
expect(result.profile?.about).toBe("A valid user");
|
||||
expect(result.profile?.picture).toBe("https://example.com/pic.png");
|
||||
expect(result).not.toHaveProperty("errors");
|
||||
});
|
||||
|
||||
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).toEqual(["picture: URL must use https:// protocol"]);
|
||||
});
|
||||
|
||||
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).toEqual(["name: Too big: expected string to have <=256 characters"]);
|
||||
});
|
||||
|
||||
it("rejects about exceeding 2000 characters", () => {
|
||||
const profile = {
|
||||
about: "a".repeat(2001),
|
||||
};
|
||||
|
||||
const result = validateProfile(profile);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toEqual(["about: Too big: expected string to have <=2000 characters"]);
|
||||
});
|
||||
|
||||
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: "<script>alert('xss')</script>",
|
||||
};
|
||||
|
||||
const sanitized = sanitizeProfileForDisplay(profile);
|
||||
|
||||
expect(sanitized.name).toBe("<script>alert('xss')</script>");
|
||||
});
|
||||
|
||||
it("escapes HTML in about field", () => {
|
||||
const profile: NostrProfile = {
|
||||
about: 'Check out <img src="x" onerror="alert(1)">',
|
||||
};
|
||||
|
||||
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 = createTestProfileEvent(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 = createTestProfileEvent(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 = createTestProfileEvent(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 = createTestProfileEvent(profile);
|
||||
expect(verifyEvent(event)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Profile Publishing Tests
|
||||
// ============================================================================
|
||||
|
||||
describe("publishProfile", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function createFakePool(publishResult: unknown): SimplePool {
|
||||
return {
|
||||
publish: vi.fn(() => [publishResult]),
|
||||
} as unknown as SimplePool;
|
||||
}
|
||||
|
||||
it("clears the per-relay timeout timer after a successful publish", async () => {
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
const profile: NostrProfile = { name: "test" };
|
||||
const pool = createFakePool(Promise.resolve());
|
||||
|
||||
const result = await publishProfile(
|
||||
pool,
|
||||
TEST_HEX_PRIVATE_KEY_BYTES,
|
||||
["wss://relay.example"],
|
||||
profile,
|
||||
);
|
||||
|
||||
expect(result.successes).toEqual(["wss://relay.example"]);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the per-relay timeout timer after a publish timeout", async () => {
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
const profile: NostrProfile = { name: "test" };
|
||||
const pool = createFakePool(new Promise(() => {}));
|
||||
|
||||
const promise = publishProfile(
|
||||
pool,
|
||||
TEST_HEX_PRIVATE_KEY_BYTES,
|
||||
["wss://relay.example"],
|
||||
profile,
|
||||
);
|
||||
vi.advanceTimersByTime(6_000);
|
||||
const result = await promise;
|
||||
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]?.error).toContain("timeout");
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not add dangling timers when publishing to multiple relays", async () => {
|
||||
vi.spyOn(globalThis, "setTimeout").mockClear();
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
const profile: NostrProfile = { name: "test" };
|
||||
const pool = createFakePool(Promise.resolve());
|
||||
|
||||
await publishProfile(
|
||||
pool,
|
||||
TEST_HEX_PRIVATE_KEY_BYTES,
|
||||
["wss://relay.a", "wss://relay.b"],
|
||||
profile,
|
||||
);
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
149
extensions/nostr/src/nostr-profile.ts
Normal file
149
extensions/nostr/src/nostr-profile.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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 { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import { profileToContent } from "./nostr-profile-core.js";
|
||||
export {
|
||||
contentToProfile,
|
||||
profileToContent,
|
||||
sanitizeProfileForDisplay,
|
||||
validateProfile,
|
||||
type ProfileContent,
|
||||
} from "./nostr-profile-core.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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
*/
|
||||
async function publishProfileEvent(
|
||||
pool: SimplePool,
|
||||
relays: string[],
|
||||
event: Event,
|
||||
): Promise<ProfilePublishResult> {
|
||||
const successes: string[] = [];
|
||||
const failures: Array<{ relay: string; error: string }> = [];
|
||||
|
||||
// Publish to each relay in parallel with timeout
|
||||
const publishPromises = relays.map(async (relay) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
await Promise.race([...pool.publish([relay], event), timeoutPromise]);
|
||||
|
||||
successes.push(relay);
|
||||
} catch (err) {
|
||||
const errorMessage = formatErrorMessage(err);
|
||||
failures.push({ relay, error: errorMessage });
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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<ProfilePublishResult> {
|
||||
const event = createProfileEvent(sk, profile, lastPublishedAt);
|
||||
return publishProfileEvent(pool, relays, event);
|
||||
}
|
||||
187
extensions/nostr/src/nostr-state-store.test.ts
Normal file
187
extensions/nostr/src/nostr-state-store.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// Nostr tests cover nostr state store plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import {
|
||||
readNostrBusState,
|
||||
readNostrProfileState,
|
||||
writeNostrBusState,
|
||||
writeNostrProfileState,
|
||||
computeSinceTimestamp,
|
||||
} from "./nostr-state-store.js";
|
||||
import { setNostrRuntime } from "./runtime.js";
|
||||
|
||||
async function withTempStateDir<T>(fn: (dir: string) => Promise<T>) {
|
||||
const previous = process.env.OPENCLAW_STATE_DIR;
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-nostr-"));
|
||||
process.env.OPENCLAW_STATE_DIR = dir;
|
||||
resetPluginStateStoreForTests();
|
||||
setNostrRuntime({
|
||||
state: {
|
||||
openKeyedStore: (options: OpenKeyedStoreOptions) =>
|
||||
createPluginStateKeyedStoreForTests("nostr", {
|
||||
...options,
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: dir },
|
||||
}),
|
||||
resolveStateDir: (env, homedir) => {
|
||||
const stateEnv = env ?? process.env;
|
||||
const override = stateEnv.OPENCLAW_STATE_DIR?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const resolveHome = homedir ?? os.homedir;
|
||||
return path.join(resolveHome(), ".openclaw");
|
||||
},
|
||||
},
|
||||
} as PluginRuntime);
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_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);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legacy account key bytes for state lookup", async () => {
|
||||
await withTempStateDir(async () => {
|
||||
await writeNostrBusState({
|
||||
accountId: " Team.A ",
|
||||
lastProcessedAt: 1234,
|
||||
gatewayStartedAt: 1200,
|
||||
});
|
||||
|
||||
await expect(readNostrBusState({ accountId: "Team.A" })).resolves.toMatchObject({
|
||||
lastProcessedAt: 1234,
|
||||
});
|
||||
await expect(readNostrBusState({ accountId: "team-a" })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("nostr profile state store", () => {
|
||||
it("persists and reloads profile publish state", async () => {
|
||||
await withTempStateDir(async () => {
|
||||
await writeNostrProfileState({
|
||||
accountId: "test-bot",
|
||||
lastPublishedAt: 1700000000,
|
||||
lastPublishedEventId: "evt-1",
|
||||
lastPublishResults: {
|
||||
"wss://relay.example": "ok",
|
||||
},
|
||||
});
|
||||
|
||||
const state = await readNostrProfileState({ accountId: "test-bot" });
|
||||
expect(state).toEqual({
|
||||
version: 1,
|
||||
lastPublishedAt: 1700000000,
|
||||
lastPublishedEventId: "evt-1",
|
||||
lastPublishResults: {
|
||||
"wss://relay.example": "ok",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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: Parameters<typeof computeSinceTimestamp>[0] = {
|
||||
version: 2,
|
||||
lastProcessedAt: 1699999000,
|
||||
gatewayStartedAt: null,
|
||||
recentEventIds: [],
|
||||
};
|
||||
expect(computeSinceTimestamp(state, 1700000000)).toBe(1699999000);
|
||||
});
|
||||
|
||||
it("uses gatewayStartedAt when lastProcessedAt is null", () => {
|
||||
const state: Parameters<typeof computeSinceTimestamp>[0] = {
|
||||
version: 2,
|
||||
lastProcessedAt: null,
|
||||
gatewayStartedAt: 1699998000,
|
||||
recentEventIds: [],
|
||||
};
|
||||
expect(computeSinceTimestamp(state, 1700000000)).toBe(1699998000);
|
||||
});
|
||||
|
||||
it("uses the max of both timestamps", () => {
|
||||
const state: Parameters<typeof computeSinceTimestamp>[0] = {
|
||||
version: 2,
|
||||
lastProcessedAt: 1699999000,
|
||||
gatewayStartedAt: 1699998000,
|
||||
recentEventIds: [],
|
||||
};
|
||||
expect(computeSinceTimestamp(state, 1700000000)).toBe(1699999000);
|
||||
});
|
||||
|
||||
it("falls back to now if both are null", () => {
|
||||
const state: Parameters<typeof computeSinceTimestamp>[0] = {
|
||||
version: 2,
|
||||
lastProcessedAt: null,
|
||||
gatewayStartedAt: null,
|
||||
recentEventIds: [],
|
||||
};
|
||||
expect(computeSinceTimestamp(state, 1700000000)).toBe(1700000000);
|
||||
});
|
||||
});
|
||||
131
extensions/nostr/src/nostr-state-store.ts
Normal file
131
extensions/nostr/src/nostr-state-store.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// Nostr plugin module implements nostr state store behavior.
|
||||
import { getNostrRuntime } from "./runtime.js";
|
||||
import { normalizeNostrStateAccountId } from "./state-account-id.js";
|
||||
|
||||
const STORE_VERSION = 2;
|
||||
const PROFILE_STATE_VERSION = 1;
|
||||
|
||||
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) */
|
||||
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<string, "ok" | "failed" | "timeout"> | null;
|
||||
};
|
||||
|
||||
function openNostrBusStateStore(env?: NodeJS.ProcessEnv) {
|
||||
return getNostrRuntime().state.openKeyedStore<NostrBusState>({
|
||||
namespace: "bus-state",
|
||||
maxEntries: 256,
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function openNostrProfileStateStore(env?: NodeJS.ProcessEnv) {
|
||||
return getNostrRuntime().state.openKeyedStore<NostrProfileState>({
|
||||
namespace: "profile-state",
|
||||
maxEntries: 256,
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function readNostrBusState(params: {
|
||||
accountId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<NostrBusState | null> {
|
||||
return (
|
||||
(await openNostrBusStateStore(params.env).lookup(
|
||||
normalizeNostrStateAccountId(params.accountId),
|
||||
)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeNostrBusState(params: {
|
||||
accountId?: string;
|
||||
lastProcessedAt: number;
|
||||
gatewayStartedAt: number;
|
||||
recentEventIds?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const payload: NostrBusState = {
|
||||
version: STORE_VERSION,
|
||||
lastProcessedAt: params.lastProcessedAt,
|
||||
gatewayStartedAt: params.gatewayStartedAt,
|
||||
recentEventIds: (params.recentEventIds ?? []).filter((x): x is string => typeof x === "string"),
|
||||
};
|
||||
await openNostrBusStateStore(params.env).register(
|
||||
normalizeNostrStateAccountId(params.accountId),
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the `since` timestamp for subscription.
|
||||
* Returns the later of: lastProcessedAt or gatewayStartedAt (both from state),
|
||||
* 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
|
||||
// ============================================================================
|
||||
|
||||
export async function readNostrProfileState(params: {
|
||||
accountId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<NostrProfileState | null> {
|
||||
return (
|
||||
(await openNostrProfileStateStore(params.env).lookup(
|
||||
normalizeNostrStateAccountId(params.accountId),
|
||||
)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeNostrProfileState(params: {
|
||||
accountId?: string;
|
||||
lastPublishedAt: number;
|
||||
lastPublishedEventId: string;
|
||||
lastPublishResults: Record<string, "ok" | "failed" | "timeout">;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const payload: NostrProfileState = {
|
||||
version: PROFILE_STATE_VERSION,
|
||||
lastPublishedAt: params.lastPublishedAt,
|
||||
lastPublishedEventId: params.lastPublishedEventId,
|
||||
lastPublishResults: params.lastPublishResults,
|
||||
};
|
||||
await openNostrProfileStateStore(params.env).register(
|
||||
normalizeNostrStateAccountId(params.accountId),
|
||||
payload,
|
||||
);
|
||||
}
|
||||
10
extensions/nostr/src/runtime.ts
Normal file
10
extensions/nostr/src/runtime.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Nostr plugin module implements runtime behavior.
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "nostr",
|
||||
errorMessage: "Nostr runtime not initialized",
|
||||
});
|
||||
export { getNostrRuntime, setNostrRuntime };
|
||||
294
extensions/nostr/src/seen-tracker.ts
Normal file
294
extensions/nostr/src/seen-tracker.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* LRU-based seen event tracker with TTL support.
|
||||
* Prevents unbounded memory growth under high load or abuse.
|
||||
*/
|
||||
|
||||
import {
|
||||
resolveIntegerOption,
|
||||
resolvePositiveTimerTimeoutMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
|
||||
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 = resolveIntegerOption(options?.maxEntries, 100_000, { min: 1 });
|
||||
const ttlMs = resolvePositiveTimerTimeoutMs(options?.ttlMs, 60 * 60 * 1000);
|
||||
const pruneIntervalMs = resolvePositiveTimerTimeoutMs(options?.pruneIntervalMs, 10 * 60 * 1000);
|
||||
|
||||
// Main storage
|
||||
const entries = new Map<string, Entry>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
function insertAtFront(id: string, seenAt: number): void {
|
||||
const newEntry: Entry = {
|
||||
seenAt,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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<typeof setInterval> | 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();
|
||||
}
|
||||
|
||||
insertAtFront(id, now);
|
||||
}
|
||||
|
||||
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) {
|
||||
insertAtFront(id, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
has,
|
||||
add,
|
||||
peek,
|
||||
delete: deleteEntry,
|
||||
clear,
|
||||
size,
|
||||
stop,
|
||||
seed,
|
||||
};
|
||||
}
|
||||
26
extensions/nostr/src/session-route.ts
Normal file
26
extensions/nostr/src/session-route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Nostr plugin module implements session route behavior.
|
||||
import {
|
||||
buildChannelOutboundSessionRoute,
|
||||
stripChannelTargetPrefix,
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
|
||||
export function resolveNostrOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
||||
const target = stripChannelTargetPrefix(params.target, "nostr");
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "nostr",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: "direct",
|
||||
id: target,
|
||||
},
|
||||
chatType: "direct",
|
||||
from: `nostr:${target}`,
|
||||
to: `nostr:${target}`,
|
||||
});
|
||||
}
|
||||
86
extensions/nostr/src/setup-adapter.ts
Normal file
86
extensions/nostr/src/setup-adapter.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// Nostr plugin module implements setup adapter behavior.
|
||||
import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
||||
import { patchTopLevelChannelConfigSection, splitSetupEntries } from "openclaw/plugin-sdk/setup";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const channel = "nostr" as const;
|
||||
|
||||
export function buildNostrSetupPatch(accountId: string, patch: Record<string, unknown>) {
|
||||
return {
|
||||
...(accountId !== DEFAULT_ACCOUNT_ID ? { defaultAccount: accountId } : {}),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRelayUrls(raw: string): { relays: string[]; error?: string } {
|
||||
const relays: string[] = [];
|
||||
for (const entry of splitSetupEntries(raw)) {
|
||||
try {
|
||||
const parsed = new URL(entry);
|
||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
||||
return { relays: [], error: `Relay must use ws:// or wss:// (${entry})` };
|
||||
}
|
||||
} catch {
|
||||
return { relays: [], error: `Invalid relay URL: ${entry}` };
|
||||
}
|
||||
relays.push(entry);
|
||||
}
|
||||
return { relays: uniqueStrings(relays) };
|
||||
}
|
||||
|
||||
export function createNostrSetupAdapter(params: {
|
||||
resolveAccountId: (cfg: OpenClawConfig, accountId?: string | null) => string;
|
||||
validatePrivateKey: (privateKey: string) => boolean;
|
||||
}): ChannelSetupAdapter {
|
||||
return {
|
||||
resolveAccountId: ({ cfg, accountId }) => params.resolveAccountId(cfg, accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
patch: buildNostrSetupPatch(accountId, name?.trim() ? { name: name.trim() } : {}),
|
||||
}),
|
||||
validateInput: ({ input }) => {
|
||||
const typedInput = input as {
|
||||
useEnv?: boolean;
|
||||
privateKey?: string;
|
||||
relayUrls?: string;
|
||||
};
|
||||
if (!typedInput.useEnv) {
|
||||
const privateKey = typedInput.privateKey?.trim();
|
||||
if (!privateKey) {
|
||||
return "Nostr requires --private-key or --use-env.";
|
||||
}
|
||||
if (!params.validatePrivateKey(privateKey)) {
|
||||
return "Nostr private key must be valid nsec or 64-character hex.";
|
||||
}
|
||||
}
|
||||
if (typedInput.relayUrls?.trim()) {
|
||||
return parseRelayUrls(typedInput.relayUrls).error ?? null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const typedInput = input as {
|
||||
useEnv?: boolean;
|
||||
privateKey?: string;
|
||||
relayUrls?: string;
|
||||
};
|
||||
const relayResult = typedInput.relayUrls?.trim()
|
||||
? parseRelayUrls(typedInput.relayUrls)
|
||||
: { relays: [] };
|
||||
return patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
enabled: true,
|
||||
clearFields: typedInput.useEnv ? ["privateKey"] : undefined,
|
||||
patch: buildNostrSetupPatch(accountId, {
|
||||
...(typedInput.useEnv ? {} : { privateKey: typedInput.privateKey?.trim() }),
|
||||
...(relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
204
extensions/nostr/src/setup-surface.ts
Normal file
204
extensions/nostr/src/setup-surface.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// Nostr plugin module implements setup surface behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
||||
// Nostr plugin module implements setup surface behavior.
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import type { ChannelSetupDmPolicy, ChannelSetupWizard, DmPolicy } from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
createSetupTranslator,
|
||||
createStandardChannelSetupStatus,
|
||||
createTopLevelChannelDmPolicy,
|
||||
createTopLevelChannelParsedAllowFromPrompt,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
parseSetupEntriesWithParser,
|
||||
patchTopLevelChannelConfigSection,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { DEFAULT_RELAYS } from "./default-relays.js";
|
||||
import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-key-utils.js";
|
||||
import { buildNostrSetupPatch, createNostrSetupAdapter, parseRelayUrls } from "./setup-adapter.js";
|
||||
import { resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "nostr" as const;
|
||||
const NOSTR_SETUP_HELP_LINES = [
|
||||
t("wizard.nostr.helpPrivateKeyFormat"),
|
||||
t("wizard.nostr.helpRelaysOptional"),
|
||||
t("wizard.nostr.helpEnvVars"),
|
||||
`Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`,
|
||||
];
|
||||
|
||||
const NOSTR_ALLOW_FROM_HELP_LINES = [
|
||||
t("wizard.nostr.allowlistIntro"),
|
||||
t("wizard.nostr.examples"),
|
||||
"- npub1...",
|
||||
"- nostr:npub1...",
|
||||
"- 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
t("wizard.nostr.multipleEntries"),
|
||||
`Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`,
|
||||
];
|
||||
|
||||
function parseNostrAllowFrom(raw: string): { entries: string[]; error?: string } {
|
||||
return parseSetupEntriesWithParser(raw, (entry) => {
|
||||
const cleaned = entry.replace(/^nostr:/i, "").trim();
|
||||
try {
|
||||
return { value: normalizePubkey(cleaned) };
|
||||
} catch {
|
||||
return { error: `Invalid Nostr pubkey: ${entry}` };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const promptNostrAllowFrom = createTopLevelChannelParsedAllowFromPrompt({
|
||||
channel,
|
||||
defaultAccountId: resolveDefaultNostrAccountId,
|
||||
noteTitle: t("wizard.nostr.allowlistTitle"),
|
||||
noteLines: NOSTR_ALLOW_FROM_HELP_LINES,
|
||||
message: t("wizard.nostr.allowFromPrompt"),
|
||||
placeholder: "npub1..., 0123abcd...",
|
||||
parseEntries: parseNostrAllowFrom,
|
||||
mergeEntries: ({ existing, parsed }) => mergeAllowFromEntries(existing, parsed),
|
||||
});
|
||||
|
||||
const nostrDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
|
||||
label: "Nostr",
|
||||
channel,
|
||||
policyKey: "channels.nostr.dmPolicy",
|
||||
allowFromKey: "channels.nostr.allowFrom",
|
||||
getCurrent: (cfg) => (cfg.channels?.nostr?.dmPolicy as DmPolicy | undefined) ?? "pairing",
|
||||
promptAllowFrom: promptNostrAllowFrom,
|
||||
});
|
||||
|
||||
export const nostrSetupAdapter = createNostrSetupAdapter({
|
||||
resolveAccountId: (cfg, accountId) => accountId?.trim() || resolveDefaultNostrAccountId(cfg),
|
||||
validatePrivateKey: (privateKey) => {
|
||||
try {
|
||||
getPublicKeyFromPrivate(privateKey);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const nostrSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId }) =>
|
||||
accountOverride?.trim() || defaultAccountId,
|
||||
resolveShouldPromptAccountIds: () => false,
|
||||
status: createStandardChannelSetupStatus({
|
||||
channelLabel: "Nostr",
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsPrivateKey"),
|
||||
configuredHint: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredHint: t("wizard.channels.statusNeedsPrivateKey"),
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 0,
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg }) => resolveNostrAccount({ cfg }).configured,
|
||||
resolveExtraStatusLines: ({ cfg }) => {
|
||||
const account = resolveNostrAccount({ cfg });
|
||||
return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`];
|
||||
},
|
||||
}),
|
||||
introNote: {
|
||||
title: t("wizard.nostr.setupTitle"),
|
||||
lines: NOSTR_SETUP_HELP_LINES,
|
||||
},
|
||||
envShortcut: {
|
||||
prompt: t("wizard.nostr.privateKeyEnvPrompt"),
|
||||
preferredEnvVar: "NOSTR_PRIVATE_KEY",
|
||||
isAvailable: ({ cfg, accountId }) =>
|
||||
accountId === DEFAULT_ACCOUNT_ID &&
|
||||
Boolean(process.env.NOSTR_PRIVATE_KEY?.trim()) &&
|
||||
!hasConfiguredSecretInput(resolveNostrAccount({ cfg, accountId }).config.privateKey),
|
||||
apply: async ({ cfg, accountId }) =>
|
||||
patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
enabled: true,
|
||||
clearFields: ["privateKey"],
|
||||
patch: buildNostrSetupPatch(accountId, {}),
|
||||
}),
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
inputKey: "privateKey",
|
||||
providerHint: channel,
|
||||
credentialLabel: "private key",
|
||||
preferredEnvVar: "NOSTR_PRIVATE_KEY",
|
||||
helpTitle: t("wizard.nostr.privateKeyTitle"),
|
||||
helpLines: NOSTR_SETUP_HELP_LINES,
|
||||
envPrompt: t("wizard.nostr.privateKeyEnvPrompt"),
|
||||
keepPrompt: t("wizard.nostr.privateKeyKeep"),
|
||||
inputPrompt: t("wizard.nostr.privateKeyInput"),
|
||||
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
|
||||
inspect: ({ cfg, accountId }) => {
|
||||
const account = resolveNostrAccount({ cfg, accountId });
|
||||
return {
|
||||
accountConfigured: account.configured,
|
||||
hasConfiguredValue: hasConfiguredSecretInput(account.config.privateKey),
|
||||
resolvedValue: normalizeSecretInputString(account.config.privateKey),
|
||||
envValue: process.env.NOSTR_PRIVATE_KEY?.trim(),
|
||||
};
|
||||
},
|
||||
applyUseEnv: async ({ cfg, accountId }) =>
|
||||
patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
enabled: true,
|
||||
clearFields: ["privateKey"],
|
||||
patch: buildNostrSetupPatch(accountId, {}),
|
||||
}),
|
||||
applySet: async ({ cfg, accountId, resolvedValue }) =>
|
||||
patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
enabled: true,
|
||||
patch: buildNostrSetupPatch(accountId, { privateKey: resolvedValue }),
|
||||
}),
|
||||
},
|
||||
],
|
||||
textInputs: [
|
||||
{
|
||||
inputKey: "relayUrls",
|
||||
message: t("wizard.nostr.relayUrlsPrompt"),
|
||||
placeholder: DEFAULT_RELAYS.join(", "),
|
||||
required: false,
|
||||
applyEmptyValue: true,
|
||||
helpTitle: t("wizard.nostr.relaysTitle"),
|
||||
helpLines: [t("wizard.nostr.relaysWsOnly"), t("wizard.nostr.helpRelaysOptional")],
|
||||
currentValue: ({ cfg, accountId }) => {
|
||||
const account = resolveNostrAccount({ cfg, accountId });
|
||||
const configuredRelays = cfg.channels?.nostr?.relays as string[] | undefined;
|
||||
const relays = configuredRelays && configuredRelays.length > 0 ? account.relays : [];
|
||||
return relays.join(", ");
|
||||
},
|
||||
keepPrompt: (value) => t("wizard.nostr.relayUrlsKeep", { value }),
|
||||
validate: ({ value }) => parseRelayUrls(value).error,
|
||||
applySet: async ({ cfg, accountId, value }) => {
|
||||
const relayResult = parseRelayUrls(value);
|
||||
return patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
enabled: true,
|
||||
clearFields: relayResult.relays.length > 0 ? undefined : ["relays"],
|
||||
patch: buildNostrSetupPatch(
|
||||
accountId,
|
||||
relayResult.relays.length > 0 ? { relays: relayResult.relays } : {},
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
dmPolicy: nostrDmPolicy,
|
||||
disable: (cfg) =>
|
||||
patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
patch: { enabled: false },
|
||||
}),
|
||||
};
|
||||
8
extensions/nostr/src/state-account-id.ts
Normal file
8
extensions/nostr/src/state-account-id.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Nostr state stores keep legacy account key bytes; do not use the newer SDK normalizer here.
|
||||
export function normalizeNostrStateAccountId(accountId?: string): string {
|
||||
const trimmed = accountId?.trim();
|
||||
if (!trimmed) {
|
||||
return "default";
|
||||
}
|
||||
return trimmed.replace(/[^a-z0-9._-]+/gi, "_");
|
||||
}
|
||||
46
extensions/nostr/src/test-fixtures.ts
Normal file
46
extensions/nostr/src/test-fixtures.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Nostr plugin module implements test fixtures behavior.
|
||||
import type { ResolvedNostrAccount } from "./types.js";
|
||||
|
||||
export const TEST_HEX_PRIVATE_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
export const TEST_HEX_PUBLIC_KEY =
|
||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
|
||||
export const TEST_NSEC = "nsec1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
|
||||
|
||||
export const TEST_RELAY_URL = "wss://relay.example.com";
|
||||
export const TEST_SETUP_RELAY_URLS = ["wss://relay.damus.io", "wss://relay.primal.net"];
|
||||
export const TEST_RESOLVED_PRIVATE_KEY = "resolved-nostr-private-key";
|
||||
|
||||
export const TEST_HEX_PRIVATE_KEY_BYTES = new Uint8Array(
|
||||
TEST_HEX_PRIVATE_KEY.match(/.{2}/g)!.map((byte) => Number.parseInt(byte, 16)),
|
||||
);
|
||||
|
||||
export function createConfiguredNostrCfg(overrides: Record<string, unknown> = {}): {
|
||||
channels: { nostr: Record<string, unknown> };
|
||||
} {
|
||||
return {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildResolvedNostrAccount(
|
||||
overrides: Partial<ResolvedNostrAccount> = {},
|
||||
): ResolvedNostrAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
privateKey: TEST_HEX_PRIVATE_KEY,
|
||||
publicKey: TEST_HEX_PUBLIC_KEY,
|
||||
relays: [TEST_RELAY_URL],
|
||||
config: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
118
extensions/nostr/src/types.ts
Normal file
118
extensions/nostr/src/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// Nostr type declarations define plugin contracts.
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
listCombinedAccountIds,
|
||||
resolveListedDefaultAccountId,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { normalizeSecretInputString, type SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import { DEFAULT_RELAYS } from "./default-relays.js";
|
||||
import { getPublicKeyFromPrivate } from "./nostr-key-utils.js";
|
||||
|
||||
interface NostrAccountConfig {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
defaultAccount?: string;
|
||||
privateKey?: SecretInput;
|
||||
relays?: string[];
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
profile?: NostrProfile;
|
||||
}
|
||||
|
||||
export interface ResolvedNostrAccount {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
relays: string[];
|
||||
profile?: NostrProfile;
|
||||
config: NostrAccountConfig;
|
||||
}
|
||||
|
||||
function resolveConfiguredDefaultNostrAccountId(cfg: OpenClawConfig): string | undefined {
|
||||
const nostrCfg = (cfg.channels as Record<string, unknown> | undefined)?.nostr as
|
||||
| NostrAccountConfig
|
||||
| undefined;
|
||||
return normalizeOptionalAccountId(nostrCfg?.defaultAccount);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configured Nostr account IDs
|
||||
*/
|
||||
export function listNostrAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const nostrCfg = (cfg.channels as Record<string, unknown> | undefined)?.nostr as
|
||||
| NostrAccountConfig
|
||||
| undefined;
|
||||
const privateKey = normalizeSecretInputString(nostrCfg?.privateKey);
|
||||
return listCombinedAccountIds({
|
||||
configuredAccountIds: [],
|
||||
implicitAccountId: privateKey
|
||||
? (resolveConfiguredDefaultNostrAccountId(cfg) ?? DEFAULT_ACCOUNT_ID)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default account ID
|
||||
*/
|
||||
export function resolveDefaultNostrAccountId(cfg: OpenClawConfig): string {
|
||||
return resolveListedDefaultAccountId({
|
||||
accountIds: listNostrAccountIds(cfg),
|
||||
configuredDefaultAccountId: resolveConfiguredDefaultNostrAccountId(cfg),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Nostr account from config
|
||||
*/
|
||||
export function resolveNostrAccount(opts: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedNostrAccount {
|
||||
const accountId = normalizeAccountId(opts.accountId ?? resolveDefaultNostrAccountId(opts.cfg));
|
||||
const nostrCfg = (opts.cfg.channels as Record<string, unknown> | undefined)?.nostr as
|
||||
| NostrAccountConfig
|
||||
| undefined;
|
||||
|
||||
const baseEnabled = nostrCfg?.enabled !== false;
|
||||
const privateKey = normalizeSecretInputString(nostrCfg?.privateKey) ?? "";
|
||||
const configured = Boolean(privateKey);
|
||||
|
||||
let publicKey = "";
|
||||
if (privateKey) {
|
||||
try {
|
||||
publicKey = getPublicKeyFromPrivate(privateKey);
|
||||
} catch {
|
||||
// Invalid key - leave publicKey empty, configured will indicate issues
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: normalizeOptionalString(nostrCfg?.name),
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
2
extensions/nostr/test-api.ts
Normal file
2
extensions/nostr/test-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Nostr API module exposes the plugin public contract.
|
||||
export { nostrPlugin } from "./src/channel.js";
|
||||
5
extensions/nostr/test/setup.ts
Normal file
5
extensions/nostr/test/setup.ts
Normal file
@@ -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(() => {});
|
||||
16
extensions/nostr/tsconfig.json
Normal file
16
extensions/nostr/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user