@openclaw/nostr 2026.2.15 → 2026.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.19
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.16
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.2.15
4
16
 
5
17
  ### Changes
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
- import type { NostrProfile } from "./src/config-schema.js";
4
3
  import { nostrPlugin } from "./src/channel.js";
4
+ import type { NostrProfile } from "./src/config-schema.js";
5
5
  import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
6
6
  import { setNostrRuntime, getNostrRuntime } from "./src/runtime.js";
7
7
  import { resolveNostrAccount } from "./src/types.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/nostr",
3
- "version": "2026.2.15",
3
+ "version": "2026.2.19",
4
4
  "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/channel.ts CHANGED
@@ -7,10 +7,10 @@ import {
7
7
  type ChannelPlugin,
8
8
  } from "openclaw/plugin-sdk";
9
9
  import type { NostrProfile } from "./config-schema.js";
10
- import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
11
- import type { ProfilePublishResult } from "./nostr-profile.js";
12
10
  import { NostrConfigSchema } from "./config-schema.js";
11
+ import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
13
12
  import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
13
+ import type { ProfilePublishResult } from "./nostr-profile.js";
14
14
  import { getNostrRuntime } from "./runtime.js";
15
15
  import {
16
16
  listNostrAccountIds,
package/src/metrics.ts CHANGED
@@ -50,6 +50,24 @@ export type MetricName =
50
50
  | DecryptMetricName
51
51
  | MemoryMetricName;
52
52
 
53
+ type RelayMetrics = {
54
+ connects: number;
55
+ disconnects: number;
56
+ reconnects: number;
57
+ errors: number;
58
+ messagesReceived: {
59
+ event: number;
60
+ eose: number;
61
+ closed: number;
62
+ notice: number;
63
+ ok: number;
64
+ auth: number;
65
+ };
66
+ circuitBreakerState: "closed" | "open" | "half_open";
67
+ circuitBreakerOpens: number;
68
+ circuitBreakerCloses: number;
69
+ };
70
+
53
71
  // ============================================================================
54
72
  // Metric Event
55
73
  // ============================================================================
@@ -93,26 +111,7 @@ export interface MetricsSnapshot {
93
111
  };
94
112
 
95
113
  /** Relay stats by URL */
96
- relays: Record<
97
- string,
98
- {
99
- connects: number;
100
- disconnects: number;
101
- reconnects: number;
102
- errors: number;
103
- messagesReceived: {
104
- event: number;
105
- eose: number;
106
- closed: number;
107
- notice: number;
108
- ok: number;
109
- auth: number;
110
- };
111
- circuitBreakerState: "closed" | "open" | "half_open";
112
- circuitBreakerOpens: number;
113
- circuitBreakerCloses: number;
114
- }
115
- >;
114
+ relays: Record<string, RelayMetrics>;
116
115
 
117
116
  /** Rate limiting stats */
118
117
  rateLimiting: {
@@ -174,26 +173,7 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
174
173
  };
175
174
 
176
175
  // Per-relay stats
177
- const relays = new Map<
178
- string,
179
- {
180
- connects: number;
181
- disconnects: number;
182
- reconnects: number;
183
- errors: number;
184
- messagesReceived: {
185
- event: number;
186
- eose: number;
187
- closed: number;
188
- notice: number;
189
- ok: number;
190
- auth: number;
191
- };
192
- circuitBreakerState: "closed" | "open" | "half_open";
193
- circuitBreakerOpens: number;
194
- circuitBreakerCloses: number;
195
- }
196
- >();
176
+ const relays = new Map<string, RelayMetrics>();
197
177
 
198
178
  // Rate limiting stats
199
179
  const rateLimiting = {
@@ -112,6 +112,23 @@ function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrP
112
112
  };
113
113
  }
114
114
 
115
+ function mockSuccessfulProfileImport() {
116
+ vi.mocked(importProfileFromRelays).mockResolvedValue({
117
+ ok: true,
118
+ profile: {
119
+ name: "imported",
120
+ displayName: "Imported User",
121
+ },
122
+ event: {
123
+ id: "evt123",
124
+ pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
125
+ created_at: 1234567890,
126
+ },
127
+ relaysQueried: ["wss://relay.damus.io"],
128
+ sourceRelay: "wss://relay.damus.io",
129
+ });
130
+ }
131
+
115
132
  // ============================================================================
116
133
  // Tests
117
134
  // ============================================================================
@@ -262,6 +279,23 @@ describe("nostr-profile-http", () => {
262
279
  expect(data.error).toContain("private");
263
280
  });
264
281
 
282
+ it("rejects ISATAP-embedded private IPv4 in picture URL", async () => {
283
+ const ctx = createMockContext();
284
+ const handler = createNostrProfileHttpHandler(ctx);
285
+ const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
286
+ name: "hacker",
287
+ picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg",
288
+ });
289
+ const res = createMockResponse();
290
+
291
+ await handler(req, res);
292
+
293
+ expect(res._getStatusCode()).toBe(400);
294
+ const data = JSON.parse(res._getData());
295
+ expect(data.ok).toBe(false);
296
+ expect(data.error).toContain("private");
297
+ });
298
+
265
299
  it("rejects non-https URLs", async () => {
266
300
  const ctx = createMockContext();
267
301
  const handler = createNostrProfileHttpHandler(ctx);
@@ -342,20 +376,7 @@ describe("nostr-profile-http", () => {
342
376
  const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
343
377
  const res = createMockResponse();
344
378
 
345
- vi.mocked(importProfileFromRelays).mockResolvedValue({
346
- ok: true,
347
- profile: {
348
- name: "imported",
349
- displayName: "Imported User",
350
- },
351
- event: {
352
- id: "evt123",
353
- pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
354
- created_at: 1234567890,
355
- },
356
- relaysQueried: ["wss://relay.damus.io"],
357
- sourceRelay: "wss://relay.damus.io",
358
- });
379
+ mockSuccessfulProfileImport();
359
380
 
360
381
  await handler(req, res);
361
382
 
@@ -406,20 +427,7 @@ describe("nostr-profile-http", () => {
406
427
  });
407
428
  const res = createMockResponse();
408
429
 
409
- vi.mocked(importProfileFromRelays).mockResolvedValue({
410
- ok: true,
411
- profile: {
412
- name: "imported",
413
- displayName: "Imported User",
414
- },
415
- event: {
416
- id: "evt123",
417
- pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
418
- created_at: 1234567890,
419
- },
420
- relaysQueried: ["wss://relay.damus.io"],
421
- sourceRelay: "wss://relay.damus.io",
422
- });
430
+ mockSuccessfulProfileImport();
423
431
 
424
432
  await handler(req, res);
425
433
 
@@ -8,7 +8,11 @@
8
8
  */
9
9
 
10
10
  import type { IncomingMessage, ServerResponse } from "node:http";
11
- import { readJsonBodyWithLimit, requestBodyErrorToText } from "openclaw/plugin-sdk";
11
+ import {
12
+ isBlockedHostnameOrIp,
13
+ readJsonBodyWithLimit,
14
+ requestBodyErrorToText,
15
+ } from "openclaw/plugin-sdk";
12
16
  import { z } from "zod";
13
17
  import { publishNostrProfile, getNostrProfileState } from "./channel.js";
14
18
  import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
@@ -98,72 +102,6 @@ async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Prom
98
102
  // SSRF Protection
99
103
  // ============================================================================
100
104
 
101
- // Block common private/internal hostnames (quick string check)
102
- const BLOCKED_HOSTNAMES = new Set([
103
- "localhost",
104
- "localhost.localdomain",
105
- "127.0.0.1",
106
- "::1",
107
- "[::1]",
108
- "0.0.0.0",
109
- ]);
110
-
111
- // Check if an IP address (resolved) is in a private range
112
- function isPrivateIp(ip: string): boolean {
113
- // Handle IPv4
114
- const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
115
- if (ipv4Match) {
116
- const [, a, b] = ipv4Match.map(Number);
117
- // 127.0.0.0/8 (loopback)
118
- if (a === 127) {
119
- return true;
120
- }
121
- // 10.0.0.0/8 (private)
122
- if (a === 10) {
123
- return true;
124
- }
125
- // 172.16.0.0/12 (private)
126
- if (a === 172 && b >= 16 && b <= 31) {
127
- return true;
128
- }
129
- // 192.168.0.0/16 (private)
130
- if (a === 192 && b === 168) {
131
- return true;
132
- }
133
- // 169.254.0.0/16 (link-local)
134
- if (a === 169 && b === 254) {
135
- return true;
136
- }
137
- // 0.0.0.0/8
138
- if (a === 0) {
139
- return true;
140
- }
141
- return false;
142
- }
143
-
144
- // Handle IPv6
145
- const ipLower = ip.toLowerCase().replace(/^\[|\]$/g, "");
146
- // ::1 (loopback)
147
- if (ipLower === "::1") {
148
- return true;
149
- }
150
- // fe80::/10 (link-local)
151
- if (ipLower.startsWith("fe80:")) {
152
- return true;
153
- }
154
- // fc00::/7 (unique local)
155
- if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) {
156
- return true;
157
- }
158
- // ::ffff:x.x.x.x (IPv4-mapped IPv6) - extract and check IPv4
159
- const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
160
- if (v4Mapped) {
161
- return isPrivateIp(v4Mapped[1]);
162
- }
163
-
164
- return false;
165
- }
166
-
167
105
  function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
168
106
  try {
169
107
  const url = new URL(urlStr);
@@ -174,18 +112,7 @@ function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: s
174
112
 
175
113
  const hostname = url.hostname.toLowerCase();
176
114
 
177
- // Quick hostname block check
178
- if (BLOCKED_HOSTNAMES.has(hostname)) {
179
- return { ok: false, error: "URL must not point to private/internal addresses" };
180
- }
181
-
182
- // Check if hostname is an IP address directly
183
- if (isPrivateIp(hostname)) {
184
- return { ok: false, error: "URL must not point to private/internal addresses" };
185
- }
186
-
187
- // Block suspicious TLDs that resolve to localhost
188
- if (hostname.endsWith(".localhost") || hostname.endsWith(".local")) {
115
+ if (isBlockedHostnameOrIp(hostname)) {
189
116
  return { ok: false, error: "URL must not point to private/internal addresses" };
190
117
  }
191
118
 
@@ -1,7 +1,7 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
2
1
  import fs from "node:fs/promises";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
4
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
5
5
  import { describe, expect, it } from "vitest";
6
6
  import {
7
7
  readNostrBusState,
@@ -17,11 +17,13 @@ async function withTempStateDir<T>(fn: (dir: string) => Promise<T>) {
17
17
  setNostrRuntime({
18
18
  state: {
19
19
  resolveStateDir: (env, homedir) => {
20
- const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
20
+ const stateEnv = env ?? process.env;
21
+ const override = stateEnv.OPENCLAW_STATE_DIR?.trim() || stateEnv.CLAWDBOT_STATE_DIR?.trim();
21
22
  if (override) {
22
23
  return override;
23
24
  }
24
- return path.join(homedir(), ".openclaw");
25
+ const resolveHome = homedir ?? os.homedir;
26
+ return path.join(resolveHome(), ".openclaw");
25
27
  },
26
28
  },
27
29
  } as PluginRuntime);
@@ -90,7 +92,7 @@ describe("computeSinceTimestamp", () => {
90
92
  });
91
93
 
92
94
  it("uses lastProcessedAt when available", () => {
93
- const state = {
95
+ const state: Parameters<typeof computeSinceTimestamp>[0] = {
94
96
  version: 2,
95
97
  lastProcessedAt: 1699999000,
96
98
  gatewayStartedAt: null,
@@ -100,7 +102,7 @@ describe("computeSinceTimestamp", () => {
100
102
  });
101
103
 
102
104
  it("uses gatewayStartedAt when lastProcessedAt is null", () => {
103
- const state = {
105
+ const state: Parameters<typeof computeSinceTimestamp>[0] = {
104
106
  version: 2,
105
107
  lastProcessedAt: null,
106
108
  gatewayStartedAt: 1699998000,
@@ -110,7 +112,7 @@ describe("computeSinceTimestamp", () => {
110
112
  });
111
113
 
112
114
  it("uses the max of both timestamps", () => {
113
- const state = {
115
+ const state: Parameters<typeof computeSinceTimestamp>[0] = {
114
116
  version: 2,
115
117
  lastProcessedAt: 1699999000,
116
118
  gatewayStartedAt: 1699998000,
@@ -120,7 +122,7 @@ describe("computeSinceTimestamp", () => {
120
122
  });
121
123
 
122
124
  it("falls back to now if both are null", () => {
123
- const state = {
125
+ const state: Parameters<typeof computeSinceTimestamp>[0] = {
124
126
  version: 2,
125
127
  lastProcessedAt: null,
126
128
  gatewayStartedAt: null,
@@ -137,6 +137,27 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
137
137
  entries.delete(idToEvict);
138
138
  }
139
139
 
140
+ function insertAtFront(id: string, seenAt: number): void {
141
+ const newEntry: Entry = {
142
+ seenAt,
143
+ prev: null,
144
+ next: head,
145
+ };
146
+
147
+ if (head) {
148
+ const headEntry = entries.get(head);
149
+ if (headEntry) {
150
+ headEntry.prev = id;
151
+ }
152
+ }
153
+
154
+ entries.set(id, newEntry);
155
+ head = id;
156
+ if (!tail) {
157
+ tail = id;
158
+ }
159
+ }
160
+
140
161
  // Prune expired entries
141
162
  function pruneExpired(): void {
142
163
  const now = Date.now();
@@ -180,25 +201,7 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
180
201
  evictLRU();
181
202
  }
182
203
 
183
- // Add new entry at front
184
- const newEntry: Entry = {
185
- seenAt: now,
186
- prev: null,
187
- next: head,
188
- };
189
-
190
- if (head) {
191
- const headEntry = entries.get(head);
192
- if (headEntry) {
193
- headEntry.prev = id;
194
- }
195
- }
196
-
197
- entries.set(id, newEntry);
198
- head = id;
199
- if (!tail) {
200
- tail = id;
201
- }
204
+ insertAtFront(id, now);
202
205
  }
203
206
 
204
207
  function has(id: string): boolean {
@@ -268,24 +271,7 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
268
271
  for (let i = ids.length - 1; i >= 0; i--) {
269
272
  const id = ids[i];
270
273
  if (!entries.has(id) && entries.size < maxEntries) {
271
- const newEntry: Entry = {
272
- seenAt: now,
273
- prev: null,
274
- next: head,
275
- };
276
-
277
- if (head) {
278
- const headEntry = entries.get(head);
279
- if (headEntry) {
280
- headEntry.prev = id;
281
- }
282
- }
283
-
284
- entries.set(id, newEntry);
285
- head = id;
286
- if (!tail) {
287
- tail = id;
288
- }
274
+ insertAtFront(id, now);
289
275
  }
290
276
  }
291
277
  }