openclaw-nostr 2026.7.30
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 +183 -0
- package/README.md +845 -0
- package/api.ts +6 -0
- package/index.ts +69 -0
- package/node_modules/cascadia-ts/cascadia.ts +1187 -0
- package/node_modules/cascadia-ts/dist/cascadia.d.ts +607 -0
- package/node_modules/cascadia-ts/dist/cascadia.js +705 -0
- package/node_modules/cascadia-ts/package.json +21 -0
- package/openclaw.plugin.json +153 -0
- package/package.json +81 -0
- package/runtime-api.ts +12 -0
- package/setup-api.ts +1 -0
- package/setup-entry.ts +4 -0
- package/src/channel-actions.test.ts +304 -0
- package/src/channel-actions.ts +199 -0
- package/src/channel.dm-access.test.ts +387 -0
- package/src/channel.inbound.test.ts +476 -0
- package/src/channel.lifecycle.test.ts +768 -0
- package/src/channel.outbound.test.ts +1143 -0
- package/src/channel.test.ts +175 -0
- package/src/channel.ts +2827 -0
- package/src/config-schema.test.ts +486 -0
- package/src/config-schema.ts +356 -0
- package/src/default-relays.ts +1 -0
- package/src/fleet-agent.test.ts +423 -0
- package/src/fleet-agent.ts +549 -0
- package/src/metrics.ts +466 -0
- package/src/nip46-cutover.test.ts +324 -0
- package/src/nip46-cutover.ts +461 -0
- package/src/nip46-doctor.test.ts +191 -0
- package/src/nip46-doctor.ts +329 -0
- package/src/nip46-enroll.test.ts +346 -0
- package/src/nip46-enroll.ts +567 -0
- package/src/nip46-signer.test.ts +809 -0
- package/src/nip46-signer.ts +1263 -0
- package/src/nip46-status.test.ts +186 -0
- package/src/nip46-status.ts +250 -0
- package/src/nip51-lists.test.ts +404 -0
- package/src/nip51-lists.ts +777 -0
- package/src/nostr-access.test.ts +175 -0
- package/src/nostr-access.ts +134 -0
- package/src/nostr-bus-nip29.ts +998 -0
- package/src/nostr-bus-relay.ts +477 -0
- package/src/nostr-bus-types.ts +392 -0
- package/src/nostr-bus.fuzz.test.ts +533 -0
- package/src/nostr-bus.integration.test.ts +505 -0
- package/src/nostr-bus.protocol.test.ts +2057 -0
- package/src/nostr-bus.test.ts +235 -0
- package/src/nostr-bus.ts +1868 -0
- package/src/nostr-capabilities-extended.test.ts +612 -0
- package/src/nostr-capabilities.ts +719 -0
- package/src/nostr-discovery.test.ts +568 -0
- package/src/nostr-discovery.ts +336 -0
- package/src/nostr-extras.test.ts +88 -0
- package/src/nostr-extras.ts +136 -0
- package/src/nostr-profile-http.test.ts +662 -0
- package/src/nostr-profile-http.ts +836 -0
- package/src/nostr-profile-import.test.ts +274 -0
- package/src/nostr-profile-import.ts +295 -0
- package/src/nostr-profile.fuzz.test.ts +480 -0
- package/src/nostr-profile.test.ts +410 -0
- package/src/nostr-profile.ts +320 -0
- package/src/nostr-pubkey.ts +56 -0
- package/src/nostr-state-store.test.ts +790 -0
- package/src/nostr-state-store.ts +1284 -0
- package/src/runtime.ts +9 -0
- package/src/seen-tracker.test.ts +25 -0
- package/src/seen-tracker.ts +292 -0
- package/src/session-route.ts +25 -0
- package/src/setup-surface.ts +320 -0
- package/src/types.test.ts +783 -0
- package/src/types.ts +508 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Nostr Discovery — NIP-05, NIP-11, NIP-27
|
|
3
|
+
*
|
|
4
|
+
* NIP-05: identity resolution with caching
|
|
5
|
+
* NIP-11: relay info with caching
|
|
6
|
+
* NIP-27: content block parsing
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Mocks
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
// Mock nostr-tools/nip05
|
|
16
|
+
const mockQueryProfile = vi.fn();
|
|
17
|
+
const mockSearchDomain = vi.fn();
|
|
18
|
+
const mockIsValid = vi.fn();
|
|
19
|
+
const mockIsNip05 = vi.fn();
|
|
20
|
+
|
|
21
|
+
vi.mock("nostr-tools/nip05", () => ({
|
|
22
|
+
queryProfile: (...args: unknown[]) => mockQueryProfile(...args),
|
|
23
|
+
searchDomain: (...args: unknown[]) => mockSearchDomain(...args),
|
|
24
|
+
isValid: (...args: unknown[]) => mockIsValid(...args),
|
|
25
|
+
isNip05: (...args: unknown[]) => mockIsNip05(...args),
|
|
26
|
+
NIP05_REGEX: /^(?:[a-z0-9._-]+@)?[a-z0-9.-]+\.[a-z]{2,}$/i,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Mock nostr-tools/nip11
|
|
30
|
+
const mockFetchRelayInformation = vi.fn();
|
|
31
|
+
|
|
32
|
+
vi.mock("nostr-tools/nip11", () => ({
|
|
33
|
+
fetchRelayInformation: (...args: unknown[]) => mockFetchRelayInformation(...args),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Tests
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
describe("NIP-05: Identity Resolution", () => {
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
// Clear cache between tests
|
|
44
|
+
const { clearNip05CacheForTest } = await import("./nostr-discovery.js");
|
|
45
|
+
clearNip05CacheForTest();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("resolveNip05", () => {
|
|
53
|
+
it("resolves a valid NIP-05 address to pubkey and relays", async () => {
|
|
54
|
+
const { resolveNip05 } = await import("./nostr-discovery.js");
|
|
55
|
+
|
|
56
|
+
mockQueryProfile.mockResolvedValueOnce({
|
|
57
|
+
pubkey: "aabbccdd".repeat(8),
|
|
58
|
+
relays: ["wss://relay1.example", "wss://relay2.example"],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await resolveNip05("alice@example.com");
|
|
62
|
+
|
|
63
|
+
expect(result).not.toBeNull();
|
|
64
|
+
expect(result!.nip05).toBe("alice@example.com");
|
|
65
|
+
expect(result!.pubkey).toBe("aabbccdd".repeat(8));
|
|
66
|
+
expect(result!.relays).toEqual(["wss://relay1.example", "wss://relay2.example"]);
|
|
67
|
+
expect(mockQueryProfile).toHaveBeenCalledWith("alice@example.com");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("normalizes the address (trim + lowercase)", async () => {
|
|
71
|
+
const { resolveNip05 } = await import("./nostr-discovery.js");
|
|
72
|
+
|
|
73
|
+
mockQueryProfile.mockResolvedValueOnce({
|
|
74
|
+
pubkey: "aabbccdd".repeat(8),
|
|
75
|
+
relays: [],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const result = await resolveNip05(" Alice@Example.COM ");
|
|
79
|
+
|
|
80
|
+
expect(result).not.toBeNull();
|
|
81
|
+
expect(result!.nip05).toBe("alice@example.com");
|
|
82
|
+
expect(mockQueryProfile).toHaveBeenCalledWith("alice@example.com");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns null for empty string", async () => {
|
|
86
|
+
const { resolveNip05 } = await import("./nostr-discovery.js");
|
|
87
|
+
const result = await resolveNip05("");
|
|
88
|
+
expect(result).toBeNull();
|
|
89
|
+
expect(mockQueryProfile).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns null for whitespace-only string", async () => {
|
|
93
|
+
const { resolveNip05 } = await import("./nostr-discovery.js");
|
|
94
|
+
const result = await resolveNip05(" ");
|
|
95
|
+
expect(result).toBeNull();
|
|
96
|
+
expect(mockQueryProfile).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns null when profile has no pubkey", async () => {
|
|
100
|
+
const { resolveNip05 } = await import("./nostr-discovery.js");
|
|
101
|
+
mockQueryProfile.mockResolvedValueOnce({ pubkey: null, relays: [] });
|
|
102
|
+
|
|
103
|
+
const result = await resolveNip05("nobody@example.com");
|
|
104
|
+
expect(result).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns null when queryProfile returns null", async () => {
|
|
108
|
+
const { resolveNip05 } = await import("./nostr-discovery.js");
|
|
109
|
+
mockQueryProfile.mockResolvedValueOnce(null);
|
|
110
|
+
|
|
111
|
+
const result = await resolveNip05("unknown@example.com");
|
|
112
|
+
expect(result).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("handles empty relays array", async () => {
|
|
116
|
+
const { resolveNip05 } = await import("./nostr-discovery.js");
|
|
117
|
+
mockQueryProfile.mockResolvedValueOnce({
|
|
118
|
+
pubkey: "aabbccdd".repeat(8),
|
|
119
|
+
relays: undefined,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const result = await resolveNip05("alice@example.com");
|
|
123
|
+
expect(result).not.toBeNull();
|
|
124
|
+
expect(result!.relays).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns null on network error without caching the failure", async () => {
|
|
128
|
+
const { resolveNip05 } = await import("./nostr-discovery.js");
|
|
129
|
+
mockQueryProfile.mockRejectedValueOnce(new Error("Network error"));
|
|
130
|
+
|
|
131
|
+
const result1 = await resolveNip05("alice@example.com");
|
|
132
|
+
expect(result1).toBeNull();
|
|
133
|
+
|
|
134
|
+
// Second call should retry (not use cached failure)
|
|
135
|
+
mockQueryProfile.mockResolvedValueOnce({
|
|
136
|
+
pubkey: "aabbccdd".repeat(8),
|
|
137
|
+
relays: [],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const result2 = await resolveNip05("alice@example.com");
|
|
141
|
+
expect(result2).not.toBeNull();
|
|
142
|
+
expect(result2!.pubkey).toBe("aabbccdd".repeat(8));
|
|
143
|
+
expect(mockQueryProfile).toHaveBeenCalledTimes(2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("caches successful results", async () => {
|
|
147
|
+
const { resolveNip05 } = await import("./nostr-discovery.js");
|
|
148
|
+
mockQueryProfile.mockResolvedValueOnce({
|
|
149
|
+
pubkey: "aabbccdd".repeat(8),
|
|
150
|
+
relays: ["wss://relay.example"],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result1 = await resolveNip05("alice@example.com");
|
|
154
|
+
const result2 = await resolveNip05("alice@example.com");
|
|
155
|
+
|
|
156
|
+
expect(result1).toEqual(result2);
|
|
157
|
+
// Should only call the network once
|
|
158
|
+
expect(mockQueryProfile).toHaveBeenCalledTimes(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("caches null results (not found)", async () => {
|
|
162
|
+
const { resolveNip05 } = await import("./nostr-discovery.js");
|
|
163
|
+
mockQueryProfile.mockResolvedValueOnce(null);
|
|
164
|
+
|
|
165
|
+
const result1 = await resolveNip05("nobody@example.com");
|
|
166
|
+
const result2 = await resolveNip05("nobody@example.com");
|
|
167
|
+
|
|
168
|
+
expect(result1).toBeNull();
|
|
169
|
+
expect(result2).toBeNull();
|
|
170
|
+
expect(mockQueryProfile).toHaveBeenCalledTimes(1);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("searchNip05Domain", () => {
|
|
175
|
+
it("returns name→pubkey map from a domain", async () => {
|
|
176
|
+
const { searchNip05Domain } = await import("./nostr-discovery.js");
|
|
177
|
+
mockSearchDomain.mockResolvedValueOnce({
|
|
178
|
+
alice: "aabbccdd".repeat(8),
|
|
179
|
+
bob: "11223344".repeat(8),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const result = await searchNip05Domain("example.com", "a");
|
|
183
|
+
expect(result).toEqual({
|
|
184
|
+
alice: "aabbccdd".repeat(8),
|
|
185
|
+
bob: "11223344".repeat(8),
|
|
186
|
+
});
|
|
187
|
+
expect(mockSearchDomain).toHaveBeenCalledWith("example.com", "a");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("returns empty object on error", async () => {
|
|
191
|
+
const { searchNip05Domain } = await import("./nostr-discovery.js");
|
|
192
|
+
mockSearchDomain.mockRejectedValueOnce(new Error("DNS failure"));
|
|
193
|
+
|
|
194
|
+
const result = await searchNip05Domain("example.com");
|
|
195
|
+
expect(result).toEqual({});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("defaults to empty query string", async () => {
|
|
199
|
+
const { searchNip05Domain } = await import("./nostr-discovery.js");
|
|
200
|
+
mockSearchDomain.mockResolvedValueOnce({});
|
|
201
|
+
|
|
202
|
+
await searchNip05Domain("example.com");
|
|
203
|
+
expect(mockSearchDomain).toHaveBeenCalledWith("example.com", "");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("validateNip05", () => {
|
|
208
|
+
it("returns true when pubkey matches address", async () => {
|
|
209
|
+
const { validateNip05 } = await import("./nostr-discovery.js");
|
|
210
|
+
const pubkey = "aabbccdd".repeat(8);
|
|
211
|
+
mockIsValid.mockResolvedValueOnce(true);
|
|
212
|
+
|
|
213
|
+
const result = await validateNip05(pubkey, "alice@example.com");
|
|
214
|
+
expect(result).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns false when validation fails", async () => {
|
|
218
|
+
const { validateNip05 } = await import("./nostr-discovery.js");
|
|
219
|
+
mockIsValid.mockResolvedValueOnce(false);
|
|
220
|
+
|
|
221
|
+
const result = await validateNip05("wrongpubkey".repeat(6), "alice@example.com");
|
|
222
|
+
expect(result).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns false on error", async () => {
|
|
226
|
+
const { validateNip05 } = await import("./nostr-discovery.js");
|
|
227
|
+
mockIsValid.mockRejectedValueOnce(new Error("Network error"));
|
|
228
|
+
|
|
229
|
+
const result = await validateNip05("aabbccdd".repeat(8), "alice@example.com");
|
|
230
|
+
expect(result).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("isNip05Address", () => {
|
|
235
|
+
it("delegates to nostr-tools isNip05", async () => {
|
|
236
|
+
const { isNip05Address } = await import("./nostr-discovery.js");
|
|
237
|
+
mockIsNip05.mockReturnValueOnce(true);
|
|
238
|
+
|
|
239
|
+
const result = isNip05Address("alice@example.com");
|
|
240
|
+
expect(result).toBe(true);
|
|
241
|
+
expect(mockIsNip05).toHaveBeenCalledWith("alice@example.com");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("returns false for non-NIP-05 strings", async () => {
|
|
245
|
+
const { isNip05Address } = await import("./nostr-discovery.js");
|
|
246
|
+
mockIsNip05.mockReturnValueOnce(false);
|
|
247
|
+
|
|
248
|
+
const result = isNip05Address("not-a-nip05");
|
|
249
|
+
expect(result).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("NIP-11: Relay Information", () => {
|
|
255
|
+
beforeEach(async () => {
|
|
256
|
+
vi.clearAllMocks();
|
|
257
|
+
const { clearNip11CacheForTest } = await import("./nostr-discovery.js");
|
|
258
|
+
clearNip11CacheForTest();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
afterEach(() => {
|
|
262
|
+
vi.restoreAllMocks();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const sampleRelayInfo = {
|
|
266
|
+
name: "Test Relay",
|
|
267
|
+
description: "A test relay",
|
|
268
|
+
pubkey: "aabbccdd".repeat(8),
|
|
269
|
+
contact: "admin@example.com",
|
|
270
|
+
supported_nips: [1, 4, 11, 17, 42],
|
|
271
|
+
software: "test-relay",
|
|
272
|
+
version: "1.0.0",
|
|
273
|
+
limitation: {
|
|
274
|
+
auth_required: false,
|
|
275
|
+
payment_required: false,
|
|
276
|
+
max_message_length: 16384,
|
|
277
|
+
max_content_length: 8196,
|
|
278
|
+
min_pow_difficulty: 0,
|
|
279
|
+
restricted_writes: false,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
describe("fetchRelayInfo", () => {
|
|
284
|
+
it("fetches and returns relay information", async () => {
|
|
285
|
+
const { fetchRelayInfo } = await import("./nostr-discovery.js");
|
|
286
|
+
mockFetchRelayInformation.mockResolvedValueOnce(sampleRelayInfo);
|
|
287
|
+
|
|
288
|
+
const result = await fetchRelayInfo("wss://relay.example");
|
|
289
|
+
expect(result).toEqual(sampleRelayInfo);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("normalizes relay URL (trailing slash)", async () => {
|
|
293
|
+
const { fetchRelayInfo } = await import("./nostr-discovery.js");
|
|
294
|
+
mockFetchRelayInformation.mockResolvedValueOnce(sampleRelayInfo);
|
|
295
|
+
|
|
296
|
+
await fetchRelayInfo("wss://relay.example");
|
|
297
|
+
expect(mockFetchRelayInformation).toHaveBeenCalledWith("wss://relay.example/");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("handles URL already having trailing slash", async () => {
|
|
301
|
+
const { fetchRelayInfo } = await import("./nostr-discovery.js");
|
|
302
|
+
mockFetchRelayInformation.mockResolvedValueOnce(sampleRelayInfo);
|
|
303
|
+
|
|
304
|
+
await fetchRelayInfo("wss://relay.example/");
|
|
305
|
+
expect(mockFetchRelayInformation).toHaveBeenCalledWith("wss://relay.example/");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("returns null on network error", async () => {
|
|
309
|
+
const { fetchRelayInfo } = await import("./nostr-discovery.js");
|
|
310
|
+
mockFetchRelayInformation.mockRejectedValueOnce(new Error("Connection refused"));
|
|
311
|
+
|
|
312
|
+
const result = await fetchRelayInfo("wss://offline.example");
|
|
313
|
+
expect(result).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("caches successful results", async () => {
|
|
317
|
+
const { fetchRelayInfo } = await import("./nostr-discovery.js");
|
|
318
|
+
mockFetchRelayInformation.mockResolvedValueOnce(sampleRelayInfo);
|
|
319
|
+
|
|
320
|
+
const result1 = await fetchRelayInfo("wss://relay.example");
|
|
321
|
+
const result2 = await fetchRelayInfo("wss://relay.example");
|
|
322
|
+
|
|
323
|
+
expect(result1).toEqual(result2);
|
|
324
|
+
expect(mockFetchRelayInformation).toHaveBeenCalledTimes(1);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("caches with normalized URL (with/without trailing slash hit same entry)", async () => {
|
|
328
|
+
const { fetchRelayInfo } = await import("./nostr-discovery.js");
|
|
329
|
+
mockFetchRelayInformation.mockResolvedValueOnce(sampleRelayInfo);
|
|
330
|
+
|
|
331
|
+
await fetchRelayInfo("wss://relay.example");
|
|
332
|
+
await fetchRelayInfo("wss://relay.example/");
|
|
333
|
+
|
|
334
|
+
// Both should normalize to the same cache key
|
|
335
|
+
expect(mockFetchRelayInformation).toHaveBeenCalledTimes(1);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe("getRelayCapabilities", () => {
|
|
340
|
+
it("distills full relay info into capability summary", async () => {
|
|
341
|
+
const { getRelayCapabilities } = await import("./nostr-discovery.js");
|
|
342
|
+
mockFetchRelayInformation.mockResolvedValueOnce(sampleRelayInfo);
|
|
343
|
+
|
|
344
|
+
const result = await getRelayCapabilities("wss://relay.example");
|
|
345
|
+
expect(result).not.toBeNull();
|
|
346
|
+
expect(result!.url).toBe("wss://relay.example");
|
|
347
|
+
expect(result!.name).toBe("Test Relay");
|
|
348
|
+
expect(result!.description).toBe("A test relay");
|
|
349
|
+
expect(result!.supportedNips).toEqual([1, 4, 11, 17, 42]);
|
|
350
|
+
expect(result!.authRequired).toBe(false);
|
|
351
|
+
expect(result!.paymentRequired).toBe(false);
|
|
352
|
+
expect(result!.maxMessageLength).toBe(16384);
|
|
353
|
+
expect(result!.maxContentLength).toBe(8196);
|
|
354
|
+
expect(result!.minPowDifficulty).toBe(0);
|
|
355
|
+
expect(result!.restrictedWrites).toBe(false);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("returns null when relay info fetch fails", async () => {
|
|
359
|
+
const { getRelayCapabilities } = await import("./nostr-discovery.js");
|
|
360
|
+
mockFetchRelayInformation.mockRejectedValueOnce(new Error("Fail"));
|
|
361
|
+
|
|
362
|
+
const result = await getRelayCapabilities("wss://offline.example");
|
|
363
|
+
expect(result).toBeNull();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("handles relay info without limitation field", async () => {
|
|
367
|
+
const { getRelayCapabilities } = await import("./nostr-discovery.js");
|
|
368
|
+
mockFetchRelayInformation.mockResolvedValueOnce({
|
|
369
|
+
name: "Basic Relay",
|
|
370
|
+
supported_nips: [1],
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const result = await getRelayCapabilities("wss://basic.example");
|
|
374
|
+
expect(result).not.toBeNull();
|
|
375
|
+
expect(result!.authRequired).toBe(false);
|
|
376
|
+
expect(result!.paymentRequired).toBe(false);
|
|
377
|
+
expect(result!.restrictedWrites).toBe(false);
|
|
378
|
+
expect(result!.maxMessageLength).toBeUndefined();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("handles relay info with auth required", async () => {
|
|
382
|
+
const { getRelayCapabilities } = await import("./nostr-discovery.js");
|
|
383
|
+
mockFetchRelayInformation.mockResolvedValueOnce({
|
|
384
|
+
name: "Auth Relay",
|
|
385
|
+
supported_nips: [1, 42],
|
|
386
|
+
limitation: {
|
|
387
|
+
auth_required: true,
|
|
388
|
+
payment_required: false,
|
|
389
|
+
restricted_writes: true,
|
|
390
|
+
min_pow_difficulty: 16,
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const result = await getRelayCapabilities("wss://auth.example");
|
|
395
|
+
expect(result!.authRequired).toBe(true);
|
|
396
|
+
expect(result!.restrictedWrites).toBe(true);
|
|
397
|
+
expect(result!.minPowDifficulty).toBe(16);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("relaySupportsNip", () => {
|
|
402
|
+
it("returns true when relay supports the NIP", async () => {
|
|
403
|
+
const { relaySupportsNip } = await import("./nostr-discovery.js");
|
|
404
|
+
mockFetchRelayInformation.mockResolvedValueOnce(sampleRelayInfo);
|
|
405
|
+
|
|
406
|
+
expect(await relaySupportsNip("wss://relay.example", 42)).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("returns false when relay does not support the NIP", async () => {
|
|
410
|
+
const { relaySupportsNip } = await import("./nostr-discovery.js");
|
|
411
|
+
mockFetchRelayInformation.mockResolvedValueOnce(sampleRelayInfo);
|
|
412
|
+
|
|
413
|
+
expect(await relaySupportsNip("wss://relay.example", 99)).toBe(false);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("returns false when relay info unavailable", async () => {
|
|
417
|
+
const { relaySupportsNip } = await import("./nostr-discovery.js");
|
|
418
|
+
mockFetchRelayInformation.mockRejectedValueOnce(new Error("Fail"));
|
|
419
|
+
|
|
420
|
+
expect(await relaySupportsNip("wss://offline.example", 1)).toBe(false);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe("filterRelaysByNip", () => {
|
|
425
|
+
it("returns only relays that support the specified NIP", async () => {
|
|
426
|
+
const { filterRelaysByNip } = await import("./nostr-discovery.js");
|
|
427
|
+
|
|
428
|
+
// Relay 1: supports NIP-42
|
|
429
|
+
mockFetchRelayInformation.mockResolvedValueOnce({
|
|
430
|
+
supported_nips: [1, 42],
|
|
431
|
+
});
|
|
432
|
+
// Relay 2: does not support NIP-42
|
|
433
|
+
mockFetchRelayInformation.mockResolvedValueOnce({
|
|
434
|
+
supported_nips: [1, 4],
|
|
435
|
+
});
|
|
436
|
+
// Relay 3: supports NIP-42
|
|
437
|
+
mockFetchRelayInformation.mockResolvedValueOnce({
|
|
438
|
+
supported_nips: [1, 11, 42],
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const result = await filterRelaysByNip(
|
|
442
|
+
["wss://relay1.example", "wss://relay2.example", "wss://relay3.example"],
|
|
443
|
+
42,
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
expect(result).toEqual(["wss://relay1.example", "wss://relay3.example"]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("returns empty array when no relays support the NIP", async () => {
|
|
450
|
+
const { filterRelaysByNip } = await import("./nostr-discovery.js");
|
|
451
|
+
|
|
452
|
+
mockFetchRelayInformation.mockResolvedValueOnce({ supported_nips: [1] });
|
|
453
|
+
mockFetchRelayInformation.mockResolvedValueOnce({ supported_nips: [1] });
|
|
454
|
+
|
|
455
|
+
const result = await filterRelaysByNip(
|
|
456
|
+
["wss://relay1.example", "wss://relay2.example"],
|
|
457
|
+
99,
|
|
458
|
+
);
|
|
459
|
+
expect(result).toEqual([]);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("handles failed relay lookups gracefully", async () => {
|
|
463
|
+
const { filterRelaysByNip } = await import("./nostr-discovery.js");
|
|
464
|
+
|
|
465
|
+
mockFetchRelayInformation.mockResolvedValueOnce({ supported_nips: [1, 42] });
|
|
466
|
+
mockFetchRelayInformation.mockRejectedValueOnce(new Error("Offline"));
|
|
467
|
+
|
|
468
|
+
const result = await filterRelaysByNip(
|
|
469
|
+
["wss://relay1.example", "wss://offline.example"],
|
|
470
|
+
42,
|
|
471
|
+
);
|
|
472
|
+
expect(result).toEqual(["wss://relay1.example"]);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("returns empty array for empty input", async () => {
|
|
476
|
+
const { filterRelaysByNip } = await import("./nostr-discovery.js");
|
|
477
|
+
const result = await filterRelaysByNip([], 42);
|
|
478
|
+
expect(result).toEqual([]);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe("NIP-27: Content Parsing", () => {
|
|
484
|
+
// NIP-27 tests use the real nostr-tools parser (no mocks needed)
|
|
485
|
+
|
|
486
|
+
describe("parseContentBlocks", () => {
|
|
487
|
+
it("parses plain text", async () => {
|
|
488
|
+
const { parseContentBlocks } = await import("./nostr-discovery.js");
|
|
489
|
+
const blocks = parseContentBlocks("Hello, world!");
|
|
490
|
+
|
|
491
|
+
expect(blocks.length).toBeGreaterThan(0);
|
|
492
|
+
expect(blocks[0].type).toBe("text");
|
|
493
|
+
if (blocks[0].type === "text") {
|
|
494
|
+
// nostr-tools nip27 uses .text for text blocks
|
|
495
|
+
expect((blocks[0] as any).text).toBe("Hello, world!");
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("parses URLs in content", async () => {
|
|
500
|
+
const { parseContentBlocks } = await import("./nostr-discovery.js");
|
|
501
|
+
const blocks = parseContentBlocks("Check out https://example.com for more");
|
|
502
|
+
|
|
503
|
+
const urlBlocks = blocks.filter((b) => b.type === "url");
|
|
504
|
+
expect(urlBlocks.length).toBe(1);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("parses nostr: references", async () => {
|
|
508
|
+
const { parseContentBlocks } = await import("./nostr-discovery.js");
|
|
509
|
+
const npub = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3x9j7s";
|
|
510
|
+
const blocks = parseContentBlocks(`Hello nostr:${npub}`);
|
|
511
|
+
|
|
512
|
+
// nostr-tools v2.23.3 nip27 parser may inline references into text blocks
|
|
513
|
+
// rather than emitting separate "reference" blocks. Verify the content
|
|
514
|
+
// round-trips through the parser without loss.
|
|
515
|
+
const allText = blocks.map((b: any) => b.text ?? b.decoded?.value ?? "").join("");
|
|
516
|
+
expect(allText).toContain(npub);
|
|
517
|
+
expect(blocks.length).toBeGreaterThan(0);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("parses hashtags in content", async () => {
|
|
521
|
+
const { parseContentBlocks } = await import("./nostr-discovery.js");
|
|
522
|
+
const blocks = parseContentBlocks("Hello #nostr #bitcoin world");
|
|
523
|
+
|
|
524
|
+
const hashtagBlocks = blocks.filter((b) => b.type === "hashtag");
|
|
525
|
+
expect(hashtagBlocks.length).toBe(2);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("handles empty string", async () => {
|
|
529
|
+
const { parseContentBlocks } = await import("./nostr-discovery.js");
|
|
530
|
+
const blocks = parseContentBlocks("");
|
|
531
|
+
// Empty string should produce either no blocks or a single empty text block
|
|
532
|
+
expect(blocks.length).toBeLessThanOrEqual(1);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("parses mixed content with text, URLs, and references", async () => {
|
|
536
|
+
const { parseContentBlocks } = await import("./nostr-discovery.js");
|
|
537
|
+
const content = "Hello https://example.com #nostr";
|
|
538
|
+
const blocks = parseContentBlocks(content);
|
|
539
|
+
|
|
540
|
+
// Should have at least text + url + hashtag
|
|
541
|
+
const types = blocks.map((b) => b.type);
|
|
542
|
+
expect(types).toContain("text");
|
|
543
|
+
expect(types).toContain("url");
|
|
544
|
+
expect(types).toContain("hashtag");
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("parses image URLs", async () => {
|
|
548
|
+
const { parseContentBlocks } = await import("./nostr-discovery.js");
|
|
549
|
+
const blocks = parseContentBlocks("Look at this https://example.com/photo.jpg");
|
|
550
|
+
|
|
551
|
+
// Image URLs should be parsed as image or url type
|
|
552
|
+
const mediaBlocks = blocks.filter(
|
|
553
|
+
(b) => b.type === "image" || b.type === "url",
|
|
554
|
+
);
|
|
555
|
+
expect(mediaBlocks.length).toBeGreaterThan(0);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("parseContent generator yields same blocks as parseContentBlocks", async () => {
|
|
559
|
+
const { parseContent, parseContentBlocks } = await import("./nostr-discovery.js");
|
|
560
|
+
const content = "Hello https://example.com #test";
|
|
561
|
+
|
|
562
|
+
const blocksFromArray = parseContentBlocks(content);
|
|
563
|
+
const blocksFromGenerator = [...parseContent(content)];
|
|
564
|
+
|
|
565
|
+
expect(blocksFromArray).toEqual(blocksFromGenerator);
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
});
|