@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 +12 -0
- package/index.ts +1 -1
- package/package.json +1 -1
- package/src/channel.ts +2 -2
- package/src/metrics.ts +20 -40
- package/src/nostr-profile-http.test.ts +36 -28
- package/src/nostr-profile-http.ts +6 -79
- package/src/nostr-state-store.test.ts +9 -7
- package/src/seen-tracker.ts +23 -37
package/CHANGELOG.md
CHANGED
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
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/seen-tracker.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|