@openclaw/nostr 2026.2.25 → 2026.3.1
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/package.json +1 -1
- package/src/config-schema.ts +3 -0
- package/src/nostr-profile-http.test.ts +23 -0
- package/src/nostr-profile-http.ts +18 -18
- package/src/types.test.ts +18 -0
- package/src/types.ts +18 -3
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/config-schema.ts
CHANGED
|
@@ -60,6 +60,9 @@ export const NostrConfigSchema = z.object({
|
|
|
60
60
|
/** Account name (optional display name) */
|
|
61
61
|
name: z.string().optional(),
|
|
62
62
|
|
|
63
|
+
/** Optional default account id for routing/account selection. */
|
|
64
|
+
defaultAccount: z.string().optional(),
|
|
65
|
+
|
|
63
66
|
/** Whether this channel is enabled */
|
|
64
67
|
enabled: z.boolean().optional(),
|
|
65
68
|
|
|
@@ -6,7 +6,10 @@ import { IncomingMessage, ServerResponse } from "node:http";
|
|
|
6
6
|
import { Socket } from "node:net";
|
|
7
7
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
8
8
|
import {
|
|
9
|
+
clearNostrProfileRateLimitStateForTest,
|
|
9
10
|
createNostrProfileHttpHandler,
|
|
11
|
+
getNostrProfileRateLimitStateSizeForTest,
|
|
12
|
+
isNostrProfileRateLimitedForTest,
|
|
10
13
|
type NostrProfileHttpContext,
|
|
11
14
|
} from "./nostr-profile-http.js";
|
|
12
15
|
|
|
@@ -136,6 +139,7 @@ function mockSuccessfulProfileImport() {
|
|
|
136
139
|
describe("nostr-profile-http", () => {
|
|
137
140
|
beforeEach(() => {
|
|
138
141
|
vi.clearAllMocks();
|
|
142
|
+
clearNostrProfileRateLimitStateForTest();
|
|
139
143
|
});
|
|
140
144
|
|
|
141
145
|
describe("route matching", () => {
|
|
@@ -358,6 +362,25 @@ describe("nostr-profile-http", () => {
|
|
|
358
362
|
}
|
|
359
363
|
}
|
|
360
364
|
});
|
|
365
|
+
|
|
366
|
+
it("caps tracked rate-limit keys to prevent unbounded growth", () => {
|
|
367
|
+
const now = 1_000_000;
|
|
368
|
+
for (let i = 0; i < 2_500; i += 1) {
|
|
369
|
+
isNostrProfileRateLimitedForTest(`rate-cap-${i}`, now);
|
|
370
|
+
}
|
|
371
|
+
expect(getNostrProfileRateLimitStateSizeForTest()).toBeLessThanOrEqual(2_048);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("prunes stale rate-limit keys after the window elapses", () => {
|
|
375
|
+
const now = 2_000_000;
|
|
376
|
+
for (let i = 0; i < 100; i += 1) {
|
|
377
|
+
isNostrProfileRateLimitedForTest(`rate-stale-${i}`, now);
|
|
378
|
+
}
|
|
379
|
+
expect(getNostrProfileRateLimitStateSizeForTest()).toBe(100);
|
|
380
|
+
|
|
381
|
+
isNostrProfileRateLimitedForTest("fresh", now + 60_001);
|
|
382
|
+
expect(getNostrProfileRateLimitStateSizeForTest()).toBe(1);
|
|
383
|
+
});
|
|
361
384
|
});
|
|
362
385
|
|
|
363
386
|
describe("POST /api/channels/nostr/:accountId/profile/import", () => {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
11
11
|
import {
|
|
12
|
+
createFixedWindowRateLimiter,
|
|
12
13
|
isBlockedHostnameOrIp,
|
|
13
14
|
readJsonBodyWithLimit,
|
|
14
15
|
requestBodyErrorToText,
|
|
@@ -41,30 +42,29 @@ export interface NostrProfileHttpContext {
|
|
|
41
42
|
// Rate Limiting
|
|
42
43
|
// ============================================================================
|
|
43
44
|
|
|
44
|
-
interface RateLimitEntry {
|
|
45
|
-
count: number;
|
|
46
|
-
windowStart: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const rateLimitMap = new Map<string, RateLimitEntry>();
|
|
50
45
|
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
|
|
51
46
|
const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute
|
|
47
|
+
const RATE_LIMIT_MAX_TRACKED_KEYS = 2_048;
|
|
48
|
+
const profileRateLimiter = createFixedWindowRateLimiter({
|
|
49
|
+
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
50
|
+
maxRequests: RATE_LIMIT_MAX_REQUESTS,
|
|
51
|
+
maxTrackedKeys: RATE_LIMIT_MAX_TRACKED_KEYS,
|
|
52
|
+
});
|
|
52
53
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
export function clearNostrProfileRateLimitStateForTest(): void {
|
|
55
|
+
profileRateLimiter.clear();
|
|
56
|
+
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
58
|
+
export function getNostrProfileRateLimitStateSizeForTest(): number {
|
|
59
|
+
return profileRateLimiter.size();
|
|
60
|
+
}
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
export function isNostrProfileRateLimitedForTest(accountId: string, nowMs: number): boolean {
|
|
63
|
+
return profileRateLimiter.isRateLimited(accountId, nowMs);
|
|
64
|
+
}
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
return
|
|
66
|
+
function checkRateLimit(accountId: string): boolean {
|
|
67
|
+
return !profileRateLimiter.isRateLimited(accountId);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
// ============================================================================
|
package/src/types.test.ts
CHANGED
|
@@ -22,6 +22,15 @@ describe("listNostrAccountIds", () => {
|
|
|
22
22
|
};
|
|
23
23
|
expect(listNostrAccountIds(cfg)).toEqual(["default"]);
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
it("returns configured defaultAccount when privateKey is configured", () => {
|
|
27
|
+
const cfg = {
|
|
28
|
+
channels: {
|
|
29
|
+
nostr: { privateKey: TEST_PRIVATE_KEY, defaultAccount: "work" },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
expect(listNostrAccountIds(cfg)).toEqual(["work"]);
|
|
33
|
+
});
|
|
25
34
|
});
|
|
26
35
|
|
|
27
36
|
describe("resolveDefaultNostrAccountId", () => {
|
|
@@ -38,6 +47,15 @@ describe("resolveDefaultNostrAccountId", () => {
|
|
|
38
47
|
const cfg = { channels: {} };
|
|
39
48
|
expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
|
|
40
49
|
});
|
|
50
|
+
|
|
51
|
+
it("prefers configured defaultAccount when present", () => {
|
|
52
|
+
const cfg = {
|
|
53
|
+
channels: {
|
|
54
|
+
nostr: { privateKey: TEST_PRIVATE_KEY, defaultAccount: "work" },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
expect(resolveDefaultNostrAccountId(cfg)).toBe("work");
|
|
58
|
+
});
|
|
41
59
|
});
|
|
42
60
|
|
|
43
61
|
describe("resolveNostrAccount", () => {
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
normalizeAccountId,
|
|
5
|
+
normalizeOptionalAccountId,
|
|
6
|
+
} from "openclaw/plugin-sdk/account-id";
|
|
2
7
|
import type { NostrProfile } from "./config-schema.js";
|
|
3
8
|
import { getPublicKeyFromPrivate } from "./nostr-bus.js";
|
|
4
9
|
import { DEFAULT_RELAYS } from "./nostr-bus.js";
|
|
@@ -6,6 +11,7 @@ import { DEFAULT_RELAYS } from "./nostr-bus.js";
|
|
|
6
11
|
export interface NostrAccountConfig {
|
|
7
12
|
enabled?: boolean;
|
|
8
13
|
name?: string;
|
|
14
|
+
defaultAccount?: string;
|
|
9
15
|
privateKey?: string;
|
|
10
16
|
relays?: string[];
|
|
11
17
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
@@ -25,7 +31,12 @@ export interface ResolvedNostrAccount {
|
|
|
25
31
|
config: NostrAccountConfig;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
function resolveConfiguredDefaultNostrAccountId(cfg: OpenClawConfig): string | undefined {
|
|
35
|
+
const nostrCfg = (cfg.channels as Record<string, unknown> | undefined)?.nostr as
|
|
36
|
+
| NostrAccountConfig
|
|
37
|
+
| undefined;
|
|
38
|
+
return normalizeOptionalAccountId(nostrCfg?.defaultAccount);
|
|
39
|
+
}
|
|
29
40
|
|
|
30
41
|
/**
|
|
31
42
|
* List all configured Nostr account IDs
|
|
@@ -37,7 +48,7 @@ export function listNostrAccountIds(cfg: OpenClawConfig): string[] {
|
|
|
37
48
|
|
|
38
49
|
// If privateKey is configured at top level, we have a default account
|
|
39
50
|
if (nostrCfg?.privateKey) {
|
|
40
|
-
return [DEFAULT_ACCOUNT_ID];
|
|
51
|
+
return [resolveConfiguredDefaultNostrAccountId(cfg) ?? DEFAULT_ACCOUNT_ID];
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
return [];
|
|
@@ -47,6 +58,10 @@ export function listNostrAccountIds(cfg: OpenClawConfig): string[] {
|
|
|
47
58
|
* Get the default account ID
|
|
48
59
|
*/
|
|
49
60
|
export function resolveDefaultNostrAccountId(cfg: OpenClawConfig): string {
|
|
61
|
+
const preferred = resolveConfiguredDefaultNostrAccountId(cfg);
|
|
62
|
+
if (preferred) {
|
|
63
|
+
return preferred;
|
|
64
|
+
}
|
|
50
65
|
const ids = listNostrAccountIds(cfg);
|
|
51
66
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
|
52
67
|
return DEFAULT_ACCOUNT_ID;
|
|
@@ -61,7 +76,7 @@ export function resolveNostrAccount(opts: {
|
|
|
61
76
|
cfg: OpenClawConfig;
|
|
62
77
|
accountId?: string | null;
|
|
63
78
|
}): ResolvedNostrAccount {
|
|
64
|
-
const accountId = opts.accountId ??
|
|
79
|
+
const accountId = normalizeAccountId(opts.accountId ?? resolveDefaultNostrAccountId(opts.cfg));
|
|
65
80
|
const nostrCfg = (opts.cfg.channels as Record<string, unknown> | undefined)?.nostr as
|
|
66
81
|
| NostrAccountConfig
|
|
67
82
|
| undefined;
|