@actagent/nostr 2026.6.2

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.
Files changed (53) hide show
  1. package/README.md +142 -0
  2. package/actagent.plugin.json +17 -0
  3. package/api.ts +11 -0
  4. package/channel-plugin-api.ts +2 -0
  5. package/doctor-contract-api.test.ts +105 -0
  6. package/doctor-contract-api.ts +297 -0
  7. package/index.ts +96 -0
  8. package/npm-shrinkwrap.json +137 -0
  9. package/package.json +67 -0
  10. package/runtime-api.ts +6 -0
  11. package/setup-api.ts +2 -0
  12. package/setup-entry.ts +10 -0
  13. package/setup-plugin-api.ts +3 -0
  14. package/src/channel-api.ts +12 -0
  15. package/src/channel.inbound.test.ts +203 -0
  16. package/src/channel.lifecycle.test.ts +97 -0
  17. package/src/channel.outbound.test.ts +175 -0
  18. package/src/channel.setup.ts +161 -0
  19. package/src/channel.test.ts +527 -0
  20. package/src/channel.ts +215 -0
  21. package/src/config-schema.ts +99 -0
  22. package/src/default-relays.ts +2 -0
  23. package/src/gateway.ts +338 -0
  24. package/src/inbound-direct-dm-runtime.ts +2 -0
  25. package/src/metrics.ts +454 -0
  26. package/src/nostr-bus.fuzz.test.ts +383 -0
  27. package/src/nostr-bus.inbound.test.ts +598 -0
  28. package/src/nostr-bus.integration.test.ts +491 -0
  29. package/src/nostr-bus.test.ts +256 -0
  30. package/src/nostr-bus.ts +799 -0
  31. package/src/nostr-key-utils.ts +93 -0
  32. package/src/nostr-profile-core.ts +135 -0
  33. package/src/nostr-profile-http-runtime.ts +7 -0
  34. package/src/nostr-profile-http.test.ts +632 -0
  35. package/src/nostr-profile-http.ts +583 -0
  36. package/src/nostr-profile-import.test.ts +196 -0
  37. package/src/nostr-profile-import.ts +273 -0
  38. package/src/nostr-profile-url-safety.ts +22 -0
  39. package/src/nostr-profile.fuzz.test.ts +431 -0
  40. package/src/nostr-profile.test.ts +416 -0
  41. package/src/nostr-profile.ts +144 -0
  42. package/src/nostr-state-store.test.ts +172 -0
  43. package/src/nostr-state-store.ts +132 -0
  44. package/src/runtime.ts +10 -0
  45. package/src/seen-tracker.ts +291 -0
  46. package/src/session-route.ts +26 -0
  47. package/src/setup-adapter.ts +86 -0
  48. package/src/setup-surface.ts +204 -0
  49. package/src/test-fixtures.ts +46 -0
  50. package/src/types.ts +118 -0
  51. package/test/setup.ts +5 -0
  52. package/test-api.ts +2 -0
  53. package/tsconfig.json +16 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Tests for Nostr Profile Import
3
+ */
4
+
5
+ import { MAX_TIMER_TIMEOUT_MS } from "actagent/plugin-sdk/number-runtime";
6
+ import { describe, it, expect, beforeEach, vi } from "vitest";
7
+ import type { NostrProfile } from "./config-schema.js";
8
+ import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
9
+
10
+ const mockState = vi.hoisted(() => ({
11
+ subscribeMany: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("nostr-tools", () => {
15
+ class MockSimplePool {
16
+ subscribeMany(
17
+ relays: string[],
18
+ filters: unknown,
19
+ handlers: {
20
+ onevent: (event: Record<string, unknown>) => void;
21
+ oneose?: () => void;
22
+ onclose?: () => void;
23
+ },
24
+ ) {
25
+ mockState.subscribeMany(relays, filters, handlers);
26
+ queueMicrotask(() => handlers.oneose?.());
27
+ return {
28
+ close: vi.fn(),
29
+ };
30
+ }
31
+
32
+ close = vi.fn();
33
+ }
34
+
35
+ return {
36
+ SimplePool: MockSimplePool,
37
+ verifyEvent: vi.fn(() => true),
38
+ };
39
+ });
40
+
41
+ // Mock SimplePool so importProfileFromRelays can assert the relay subscription shape.
42
+
43
+ describe("nostr-profile-import", () => {
44
+ beforeEach(() => {
45
+ mockState.subscribeMany.mockClear();
46
+ });
47
+
48
+ describe("importProfileFromRelays", () => {
49
+ it("subscribes to profiles with a single Nostr filter object", async () => {
50
+ const pubkey = "a".repeat(64);
51
+
52
+ await importProfileFromRelays({
53
+ pubkey,
54
+ relays: ["wss://relay.example"],
55
+ timeoutMs: 1,
56
+ });
57
+
58
+ expect(mockState.subscribeMany).toHaveBeenCalledTimes(1);
59
+ const filters = mockState.subscribeMany.mock.calls[0]?.[1];
60
+ expect(Array.isArray(filters)).toBe(false);
61
+ expect(filters).toMatchObject({
62
+ kinds: [0],
63
+ authors: [pubkey],
64
+ limit: 1,
65
+ });
66
+ });
67
+
68
+ it("caps oversized relay timeouts and clears pending timeout handles", async () => {
69
+ vi.useFakeTimers();
70
+ try {
71
+ const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
72
+ const clearSpy = vi.spyOn(globalThis, "clearTimeout");
73
+
74
+ await importProfileFromRelays({
75
+ pubkey: "a".repeat(64),
76
+ relays: ["wss://relay.example"],
77
+ timeoutMs: Number.MAX_SAFE_INTEGER,
78
+ });
79
+
80
+ expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
81
+ expect(timeoutSpy).toHaveBeenCalledTimes(2);
82
+ expect(clearSpy).toHaveBeenCalledTimes(2);
83
+ } finally {
84
+ vi.useRealTimers();
85
+ vi.restoreAllMocks();
86
+ }
87
+ });
88
+ });
89
+
90
+ describe("mergeProfiles", () => {
91
+ it("returns empty object when both are undefined", () => {
92
+ const result = mergeProfiles(undefined, undefined);
93
+ expect(result).toStrictEqual({});
94
+ });
95
+
96
+ it("returns imported when local is undefined", () => {
97
+ const imported: NostrProfile = {
98
+ name: "imported",
99
+ displayName: "Imported User",
100
+ about: "Bio from relay",
101
+ };
102
+ const result = mergeProfiles(undefined, imported);
103
+ expect(result).toEqual(imported);
104
+ });
105
+
106
+ it("returns local when imported is undefined", () => {
107
+ const local: NostrProfile = {
108
+ name: "local",
109
+ displayName: "Local User",
110
+ };
111
+ const result = mergeProfiles(local, undefined);
112
+ expect(result).toEqual(local);
113
+ });
114
+
115
+ it("prefers local values over imported", () => {
116
+ const local: NostrProfile = {
117
+ name: "localname",
118
+ about: "Local bio",
119
+ };
120
+ const imported: NostrProfile = {
121
+ name: "importedname",
122
+ displayName: "Imported Display",
123
+ about: "Imported bio",
124
+ picture: "https://example.com/pic.jpg",
125
+ };
126
+
127
+ const result = mergeProfiles(local, imported);
128
+
129
+ expect(result.name).toBe("localname"); // local wins
130
+ expect(result.displayName).toBe("Imported Display"); // imported fills gap
131
+ expect(result.about).toBe("Local bio"); // local wins
132
+ expect(result.picture).toBe("https://example.com/pic.jpg"); // imported fills gap
133
+ });
134
+
135
+ it("fills all missing fields from imported", () => {
136
+ const local: NostrProfile = {
137
+ name: "myname",
138
+ };
139
+ const imported: NostrProfile = {
140
+ name: "theirname",
141
+ displayName: "Their Name",
142
+ about: "Their bio",
143
+ picture: "https://example.com/pic.jpg",
144
+ banner: "https://example.com/banner.jpg",
145
+ website: "https://example.com",
146
+ nip05: "user@example.com",
147
+ lud16: "user@getalby.com",
148
+ };
149
+
150
+ const result = mergeProfiles(local, imported);
151
+
152
+ expect(result.name).toBe("myname");
153
+ expect(result.displayName).toBe("Their Name");
154
+ expect(result.about).toBe("Their bio");
155
+ expect(result.picture).toBe("https://example.com/pic.jpg");
156
+ expect(result.banner).toBe("https://example.com/banner.jpg");
157
+ expect(result.website).toBe("https://example.com");
158
+ expect(result.nip05).toBe("user@example.com");
159
+ expect(result.lud16).toBe("user@getalby.com");
160
+ });
161
+
162
+ it("handles empty strings as falsy (prefers imported)", () => {
163
+ const local: NostrProfile = {
164
+ name: "",
165
+ displayName: "",
166
+ };
167
+ const imported: NostrProfile = {
168
+ name: "imported",
169
+ displayName: "Imported",
170
+ };
171
+
172
+ const result = mergeProfiles(local, imported);
173
+
174
+ // Empty strings are still strings, so they "win" over imported
175
+ // This is JavaScript nullish coalescing behavior
176
+ expect(result.name).toBe("");
177
+ expect(result.displayName).toBe("");
178
+ });
179
+
180
+ it("handles null values in local (prefers imported)", () => {
181
+ const local: NostrProfile = {
182
+ name: undefined,
183
+ displayName: undefined,
184
+ };
185
+ const imported: NostrProfile = {
186
+ name: "imported",
187
+ displayName: "Imported",
188
+ };
189
+
190
+ const result = mergeProfiles(local, imported);
191
+
192
+ expect(result.name).toBe("imported");
193
+ expect(result.displayName).toBe("Imported");
194
+ });
195
+ });
196
+ });
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Nostr Profile Import
3
+ *
4
+ * Fetches and verifies kind:0 profile events from relays.
5
+ * Used to import existing profiles before editing.
6
+ */
7
+
8
+ import { SimplePool, verifyEvent, type Event } from "nostr-tools";
9
+ import { resolveTimerTimeoutMs } from "actagent/plugin-sdk/number-runtime";
10
+ import type { NostrProfile } from "./config-schema.js";
11
+ import { validateUrlSafety } from "./nostr-profile-url-safety.js";
12
+ import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
13
+
14
+ // ============================================================================
15
+ // Types
16
+ // ============================================================================
17
+
18
+ interface ProfileImportResult {
19
+ /** Whether the import was successful */
20
+ ok: boolean;
21
+ /** The imported profile (if found and valid) */
22
+ profile?: NostrProfile;
23
+ /** The raw event (for advanced users) */
24
+ event?: {
25
+ id: string;
26
+ pubkey: string;
27
+ created_at: number;
28
+ };
29
+ /** Error message if import failed */
30
+ error?: string;
31
+ /** Which relays responded */
32
+ relaysQueried: string[];
33
+ /** Which relay provided the winning event */
34
+ sourceRelay?: string;
35
+ }
36
+
37
+ interface ProfileImportOptions {
38
+ /** The public key to fetch profile for */
39
+ pubkey: string;
40
+ /** Relay URLs to query */
41
+ relays: string[];
42
+ /** Timeout per relay in milliseconds (default: 5000) */
43
+ timeoutMs?: number;
44
+ }
45
+
46
+ // ============================================================================
47
+ // Constants
48
+ // ============================================================================
49
+
50
+ const DEFAULT_TIMEOUT_MS = 5000;
51
+
52
+ // ============================================================================
53
+ // Profile Import
54
+ // ============================================================================
55
+
56
+ /**
57
+ * Sanitize URLs in an imported profile to prevent SSRF attacks.
58
+ * Removes any URLs that don't pass SSRF validation.
59
+ */
60
+ function sanitizeProfileUrls(profile: NostrProfile): NostrProfile {
61
+ const result = { ...profile };
62
+ const urlFields = ["picture", "banner", "website"] as const;
63
+
64
+ for (const field of urlFields) {
65
+ const value = result[field];
66
+ if (value && typeof value === "string") {
67
+ const validation = validateUrlSafety(value);
68
+ if (!validation.ok) {
69
+ // Remove unsafe URL
70
+ delete result[field];
71
+ }
72
+ }
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Fetch the latest kind:0 profile event for a pubkey from relays.
80
+ *
81
+ * - Queries all relays in parallel
82
+ * - Takes the event with the highest created_at
83
+ * - Verifies the event signature
84
+ * - Parses and returns the profile
85
+ */
86
+ export async function importProfileFromRelays(
87
+ opts: ProfileImportOptions,
88
+ ): Promise<ProfileImportResult> {
89
+ const { pubkey, relays } = opts;
90
+ const timeoutMs = resolveTimerTimeoutMs(opts.timeoutMs, DEFAULT_TIMEOUT_MS);
91
+
92
+ if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
93
+ return {
94
+ ok: false,
95
+ error: "Invalid pubkey format (must be 64 hex characters)",
96
+ relaysQueried: [],
97
+ };
98
+ }
99
+
100
+ if (relays.length === 0) {
101
+ return {
102
+ ok: false,
103
+ error: "No relays configured",
104
+ relaysQueried: [],
105
+ };
106
+ }
107
+
108
+ const pool = new SimplePool();
109
+ const relaysQueried: string[] = [];
110
+ const timers: Array<ReturnType<typeof setTimeout>> = [];
111
+ const scheduleTimeout = (callback: () => void) => {
112
+ const timer = setTimeout(callback, timeoutMs);
113
+ timer.unref?.();
114
+ timers.push(timer);
115
+ return timer;
116
+ };
117
+
118
+ try {
119
+ // Query all relays for kind:0 events from this pubkey
120
+ const events: Array<{ event: Event; relay: string }> = [];
121
+
122
+ // Create timeout promise
123
+ const timeoutPromise = new Promise<void>((resolve) => {
124
+ scheduleTimeout(resolve);
125
+ });
126
+
127
+ // Create subscription promise
128
+ const subscriptionPromise = new Promise<void>((resolve) => {
129
+ let completed = 0;
130
+
131
+ for (const relay of relays) {
132
+ relaysQueried.push(relay);
133
+
134
+ const profileFilter = {
135
+ kinds: [0],
136
+ authors: [pubkey],
137
+ limit: 1,
138
+ } satisfies Parameters<typeof pool.subscribeMany>[1];
139
+
140
+ const sub = pool.subscribeMany([relay], profileFilter, {
141
+ onevent(event) {
142
+ events.push({ event, relay });
143
+ },
144
+ oneose() {
145
+ completed++;
146
+ if (completed >= relays.length) {
147
+ resolve();
148
+ }
149
+ },
150
+ onclose() {
151
+ completed++;
152
+ if (completed >= relays.length) {
153
+ resolve();
154
+ }
155
+ },
156
+ });
157
+
158
+ // Clean up subscription after timeout
159
+ scheduleTimeout(() => {
160
+ sub.close();
161
+ });
162
+ }
163
+ });
164
+
165
+ // Wait for either all relays to respond or timeout
166
+ await Promise.race([subscriptionPromise, timeoutPromise]);
167
+ for (const timer of timers.splice(0)) {
168
+ clearTimeout(timer);
169
+ }
170
+
171
+ // No events found
172
+ if (events.length === 0) {
173
+ return {
174
+ ok: false,
175
+ error: "No profile found on any relay",
176
+ relaysQueried,
177
+ };
178
+ }
179
+
180
+ // Find the event with the highest created_at (newest wins for replaceable events)
181
+ let bestEvent: { event: Event; relay: string } | null = null;
182
+ for (const item of events) {
183
+ if (!bestEvent || item.event.created_at > bestEvent.event.created_at) {
184
+ bestEvent = item;
185
+ }
186
+ }
187
+
188
+ if (!bestEvent) {
189
+ return {
190
+ ok: false,
191
+ error: "No valid profile event found",
192
+ relaysQueried,
193
+ };
194
+ }
195
+
196
+ // Verify the event signature
197
+ const isValid = verifyEvent(bestEvent.event);
198
+ if (!isValid) {
199
+ return {
200
+ ok: false,
201
+ error: "Profile event has invalid signature",
202
+ relaysQueried,
203
+ sourceRelay: bestEvent.relay,
204
+ };
205
+ }
206
+
207
+ // Parse the profile content
208
+ let content: ProfileContent;
209
+ try {
210
+ content = JSON.parse(bestEvent.event.content) as ProfileContent;
211
+ } catch {
212
+ return {
213
+ ok: false,
214
+ error: "Profile event has invalid JSON content",
215
+ relaysQueried,
216
+ sourceRelay: bestEvent.relay,
217
+ };
218
+ }
219
+
220
+ // Convert to our profile format
221
+ const profile = contentToProfile(content);
222
+
223
+ // Sanitize URLs from imported profile to prevent SSRF when auto-merging
224
+ const sanitizedProfile = sanitizeProfileUrls(profile);
225
+
226
+ return {
227
+ ok: true,
228
+ profile: sanitizedProfile,
229
+ event: {
230
+ id: bestEvent.event.id,
231
+ pubkey: bestEvent.event.pubkey,
232
+ created_at: bestEvent.event.created_at,
233
+ },
234
+ relaysQueried,
235
+ sourceRelay: bestEvent.relay,
236
+ };
237
+ } finally {
238
+ for (const timer of timers) {
239
+ clearTimeout(timer);
240
+ }
241
+ pool.close(relays);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Merge imported profile with local profile.
247
+ *
248
+ * Strategy:
249
+ * - For each field, prefer local if set, otherwise use imported
250
+ * - This preserves user customizations while filling in missing data
251
+ */
252
+ export function mergeProfiles(
253
+ local: NostrProfile | undefined,
254
+ imported: NostrProfile | undefined,
255
+ ): NostrProfile {
256
+ if (!imported) {
257
+ return local ?? {};
258
+ }
259
+ if (!local) {
260
+ return imported;
261
+ }
262
+
263
+ return {
264
+ name: local.name ?? imported.name,
265
+ displayName: local.displayName ?? imported.displayName,
266
+ about: local.about ?? imported.about,
267
+ picture: local.picture ?? imported.picture,
268
+ banner: local.banner ?? imported.banner,
269
+ website: local.website ?? imported.website,
270
+ nip05: local.nip05 ?? imported.nip05,
271
+ lud16: local.lud16 ?? imported.lud16,
272
+ };
273
+ }
@@ -0,0 +1,22 @@
1
+ // Nostr plugin module implements nostr profile url safety behavior.
2
+ import { isBlockedHostnameOrIp } from "actagent/plugin-sdk/ssrf-runtime";
3
+
4
+ export function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
5
+ try {
6
+ const url = new URL(urlStr);
7
+
8
+ if (url.protocol !== "https:") {
9
+ return { ok: false, error: "URL must use https:// protocol" };
10
+ }
11
+
12
+ const hostname = url.hostname.trim().toLowerCase();
13
+
14
+ if (isBlockedHostnameOrIp(hostname)) {
15
+ return { ok: false, error: "URL must not point to private/internal addresses" };
16
+ }
17
+
18
+ return { ok: true };
19
+ } catch {
20
+ return { ok: false, error: "Invalid URL format" };
21
+ }
22
+ }