@gakr-gakr/nostr 0.1.0

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.
@@ -0,0 +1,262 @@
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 type { NostrProfile } from "./config-schema.js";
10
+ import { validateUrlSafety } from "./nostr-profile-url-safety.js";
11
+ import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ interface ProfileImportResult {
18
+ /** Whether the import was successful */
19
+ ok: boolean;
20
+ /** The imported profile (if found and valid) */
21
+ profile?: NostrProfile;
22
+ /** The raw event (for advanced users) */
23
+ event?: {
24
+ id: string;
25
+ pubkey: string;
26
+ created_at: number;
27
+ };
28
+ /** Error message if import failed */
29
+ error?: string;
30
+ /** Which relays responded */
31
+ relaysQueried: string[];
32
+ /** Which relay provided the winning event */
33
+ sourceRelay?: string;
34
+ }
35
+
36
+ interface ProfileImportOptions {
37
+ /** The public key to fetch profile for */
38
+ pubkey: string;
39
+ /** Relay URLs to query */
40
+ relays: string[];
41
+ /** Timeout per relay in milliseconds (default: 5000) */
42
+ timeoutMs?: number;
43
+ }
44
+
45
+ // ============================================================================
46
+ // Constants
47
+ // ============================================================================
48
+
49
+ const DEFAULT_TIMEOUT_MS = 5000;
50
+
51
+ // ============================================================================
52
+ // Profile Import
53
+ // ============================================================================
54
+
55
+ /**
56
+ * Sanitize URLs in an imported profile to prevent SSRF attacks.
57
+ * Removes any URLs that don't pass SSRF validation.
58
+ */
59
+ function sanitizeProfileUrls(profile: NostrProfile): NostrProfile {
60
+ const result = { ...profile };
61
+ const urlFields = ["picture", "banner", "website"] as const;
62
+
63
+ for (const field of urlFields) {
64
+ const value = result[field];
65
+ if (value && typeof value === "string") {
66
+ const validation = validateUrlSafety(value);
67
+ if (!validation.ok) {
68
+ // Remove unsafe URL
69
+ delete result[field];
70
+ }
71
+ }
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * Fetch the latest kind:0 profile event for a pubkey from relays.
79
+ *
80
+ * - Queries all relays in parallel
81
+ * - Takes the event with the highest created_at
82
+ * - Verifies the event signature
83
+ * - Parses and returns the profile
84
+ */
85
+ export async function importProfileFromRelays(
86
+ opts: ProfileImportOptions,
87
+ ): Promise<ProfileImportResult> {
88
+ const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
89
+
90
+ if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
91
+ return {
92
+ ok: false,
93
+ error: "Invalid pubkey format (must be 64 hex characters)",
94
+ relaysQueried: [],
95
+ };
96
+ }
97
+
98
+ if (relays.length === 0) {
99
+ return {
100
+ ok: false,
101
+ error: "No relays configured",
102
+ relaysQueried: [],
103
+ };
104
+ }
105
+
106
+ const pool = new SimplePool();
107
+ const relaysQueried: string[] = [];
108
+
109
+ try {
110
+ // Query all relays for kind:0 events from this pubkey
111
+ const events: Array<{ event: Event; relay: string }> = [];
112
+
113
+ // Create timeout promise
114
+ const timeoutPromise = new Promise<void>((resolve) => {
115
+ setTimeout(resolve, timeoutMs);
116
+ });
117
+
118
+ // Create subscription promise
119
+ const subscriptionPromise = new Promise<void>((resolve) => {
120
+ let completed = 0;
121
+
122
+ for (const relay of relays) {
123
+ relaysQueried.push(relay);
124
+
125
+ const sub = pool.subscribeMany(
126
+ [relay],
127
+ [
128
+ {
129
+ kinds: [0],
130
+ authors: [pubkey],
131
+ limit: 1,
132
+ },
133
+ ] as unknown as Parameters<typeof pool.subscribeMany>[1],
134
+ {
135
+ onevent(event) {
136
+ events.push({ event, relay });
137
+ },
138
+ oneose() {
139
+ completed++;
140
+ if (completed >= relays.length) {
141
+ resolve();
142
+ }
143
+ },
144
+ onclose() {
145
+ completed++;
146
+ if (completed >= relays.length) {
147
+ resolve();
148
+ }
149
+ },
150
+ },
151
+ );
152
+
153
+ // Clean up subscription after timeout
154
+ setTimeout(() => {
155
+ sub.close();
156
+ }, timeoutMs);
157
+ }
158
+ });
159
+
160
+ // Wait for either all relays to respond or timeout
161
+ await Promise.race([subscriptionPromise, timeoutPromise]);
162
+
163
+ // No events found
164
+ if (events.length === 0) {
165
+ return {
166
+ ok: false,
167
+ error: "No profile found on any relay",
168
+ relaysQueried,
169
+ };
170
+ }
171
+
172
+ // Find the event with the highest created_at (newest wins for replaceable events)
173
+ let bestEvent: { event: Event; relay: string } | null = null;
174
+ for (const item of events) {
175
+ if (!bestEvent || item.event.created_at > bestEvent.event.created_at) {
176
+ bestEvent = item;
177
+ }
178
+ }
179
+
180
+ if (!bestEvent) {
181
+ return {
182
+ ok: false,
183
+ error: "No valid profile event found",
184
+ relaysQueried,
185
+ };
186
+ }
187
+
188
+ // Verify the event signature
189
+ const isValid = verifyEvent(bestEvent.event);
190
+ if (!isValid) {
191
+ return {
192
+ ok: false,
193
+ error: "Profile event has invalid signature",
194
+ relaysQueried,
195
+ sourceRelay: bestEvent.relay,
196
+ };
197
+ }
198
+
199
+ // Parse the profile content
200
+ let content: ProfileContent;
201
+ try {
202
+ content = JSON.parse(bestEvent.event.content) as ProfileContent;
203
+ } catch {
204
+ return {
205
+ ok: false,
206
+ error: "Profile event has invalid JSON content",
207
+ relaysQueried,
208
+ sourceRelay: bestEvent.relay,
209
+ };
210
+ }
211
+
212
+ // Convert to our profile format
213
+ const profile = contentToProfile(content);
214
+
215
+ // Sanitize URLs from imported profile to prevent SSRF when auto-merging
216
+ const sanitizedProfile = sanitizeProfileUrls(profile);
217
+
218
+ return {
219
+ ok: true,
220
+ profile: sanitizedProfile,
221
+ event: {
222
+ id: bestEvent.event.id,
223
+ pubkey: bestEvent.event.pubkey,
224
+ created_at: bestEvent.event.created_at,
225
+ },
226
+ relaysQueried,
227
+ sourceRelay: bestEvent.relay,
228
+ };
229
+ } finally {
230
+ pool.close(relays);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Merge imported profile with local profile.
236
+ *
237
+ * Strategy:
238
+ * - For each field, prefer local if set, otherwise use imported
239
+ * - This preserves user customizations while filling in missing data
240
+ */
241
+ export function mergeProfiles(
242
+ local: NostrProfile | undefined,
243
+ imported: NostrProfile | undefined,
244
+ ): NostrProfile {
245
+ if (!imported) {
246
+ return local ?? {};
247
+ }
248
+ if (!local) {
249
+ return imported;
250
+ }
251
+
252
+ return {
253
+ name: local.name ?? imported.name,
254
+ displayName: local.displayName ?? imported.displayName,
255
+ about: local.about ?? imported.about,
256
+ picture: local.picture ?? imported.picture,
257
+ banner: local.banner ?? imported.banner,
258
+ website: local.website ?? imported.website,
259
+ nip05: local.nip05 ?? imported.nip05,
260
+ lud16: local.lud16 ?? imported.lud16,
261
+ };
262
+ }
@@ -0,0 +1,21 @@
1
+ import { isBlockedHostnameOrIp } from "autobot/plugin-sdk/ssrf-runtime";
2
+
3
+ export function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
4
+ try {
5
+ const url = new URL(urlStr);
6
+
7
+ if (url.protocol !== "https:") {
8
+ return { ok: false, error: "URL must use https:// protocol" };
9
+ }
10
+
11
+ const hostname = url.hostname.trim().toLowerCase();
12
+
13
+ if (isBlockedHostnameOrIp(hostname)) {
14
+ return { ok: false, error: "URL must not point to private/internal addresses" };
15
+ }
16
+
17
+ return { ok: true };
18
+ } catch {
19
+ return { ok: false, error: "Invalid URL format" };
20
+ }
21
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Nostr Profile Management (NIP-01 kind:0)
3
+ *
4
+ * Profile events are "replaceable" - the latest created_at wins.
5
+ * This module handles profile event creation and publishing.
6
+ */
7
+
8
+ import { finalizeEvent, SimplePool, type Event } from "nostr-tools";
9
+ import { formatErrorMessage } from "autobot/plugin-sdk/error-runtime";
10
+ import type { NostrProfile } from "./config-schema.js";
11
+ import { profileToContent } from "./nostr-profile-core.js";
12
+ export {
13
+ contentToProfile,
14
+ profileToContent,
15
+ sanitizeProfileForDisplay,
16
+ validateProfile,
17
+ type ProfileContent,
18
+ } from "./nostr-profile-core.js";
19
+
20
+ // ============================================================================
21
+ // Types
22
+ // ============================================================================
23
+
24
+ /** Result of a profile publish attempt */
25
+ export interface ProfilePublishResult {
26
+ /** Event ID of the published profile */
27
+ eventId: string;
28
+ /** Relays that successfully received the event */
29
+ successes: string[];
30
+ /** Relays that failed with their error messages */
31
+ failures: Array<{ relay: string; error: string }>;
32
+ /** Unix timestamp when the event was created */
33
+ createdAt: number;
34
+ }
35
+
36
+ // ============================================================================
37
+ // Event Creation
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Create a signed kind:0 profile event.
42
+ *
43
+ * @param sk - Private key as Uint8Array (32 bytes)
44
+ * @param profile - Profile data to include
45
+ * @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee)
46
+ * @returns Signed Nostr event
47
+ */
48
+ export function createProfileEvent(
49
+ sk: Uint8Array,
50
+ profile: NostrProfile,
51
+ lastPublishedAt?: number,
52
+ ): Event {
53
+ const content = profileToContent(profile);
54
+ const contentJson = JSON.stringify(content);
55
+
56
+ // Ensure monotonic timestamp (new event > previous)
57
+ const now = Math.floor(Date.now() / 1000);
58
+ const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now;
59
+
60
+ const event = finalizeEvent(
61
+ {
62
+ kind: 0,
63
+ content: contentJson,
64
+ tags: [],
65
+ created_at: createdAt,
66
+ },
67
+ sk,
68
+ );
69
+
70
+ return event;
71
+ }
72
+
73
+ // ============================================================================
74
+ // Profile Publishing
75
+ // ============================================================================
76
+
77
+ /** Per-relay publish timeout (ms) */
78
+ const RELAY_PUBLISH_TIMEOUT_MS = 5000;
79
+
80
+ /**
81
+ * Publish a profile event to multiple relays.
82
+ *
83
+ * Best-effort: publishes to all relays in parallel, reports per-relay results.
84
+ * Does NOT retry automatically - caller should handle retries if needed.
85
+ *
86
+ * @param pool - SimplePool instance for relay connections
87
+ * @param relays - Array of relay WebSocket URLs
88
+ * @param event - Signed profile event (kind:0)
89
+ * @returns Publish results with successes and failures
90
+ */
91
+ async function publishProfileEvent(
92
+ pool: SimplePool,
93
+ relays: string[],
94
+ event: Event,
95
+ ): Promise<ProfilePublishResult> {
96
+ const successes: string[] = [];
97
+ const failures: Array<{ relay: string; error: string }> = [];
98
+
99
+ // Publish to each relay in parallel with timeout
100
+ const publishPromises = relays.map(async (relay) => {
101
+ try {
102
+ const timeoutPromise = new Promise<never>((_, reject) => {
103
+ setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
104
+ });
105
+
106
+ await Promise.race([...pool.publish([relay], event), timeoutPromise]);
107
+
108
+ successes.push(relay);
109
+ } catch (err) {
110
+ const errorMessage = formatErrorMessage(err);
111
+ failures.push({ relay, error: errorMessage });
112
+ }
113
+ });
114
+
115
+ await Promise.all(publishPromises);
116
+
117
+ return {
118
+ eventId: event.id,
119
+ successes,
120
+ failures,
121
+ createdAt: event.created_at,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Create and publish a profile event in one call.
127
+ *
128
+ * @param pool - SimplePool instance
129
+ * @param sk - Private key as Uint8Array
130
+ * @param relays - Array of relay URLs
131
+ * @param profile - Profile data
132
+ * @param lastPublishedAt - Previous timestamp for monotonic ordering
133
+ * @returns Publish results
134
+ */
135
+ export async function publishProfile(
136
+ pool: SimplePool,
137
+ sk: Uint8Array,
138
+ relays: string[],
139
+ profile: NostrProfile,
140
+ lastPublishedAt?: number,
141
+ ): Promise<ProfilePublishResult> {
142
+ const event = createProfileEvent(sk, profile, lastPublishedAt);
143
+ return publishProfileEvent(pool, relays, event);
144
+ }
@@ -0,0 +1,206 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { safeParseJsonWithSchema } from "autobot/plugin-sdk/extension-shared";
4
+ import { privateFileStore } from "autobot/plugin-sdk/security-runtime";
5
+ import { z } from "zod";
6
+ import { getNostrRuntime } from "./runtime.js";
7
+
8
+ const STORE_VERSION = 2;
9
+ const PROFILE_STATE_VERSION = 1;
10
+
11
+ type NostrBusState = {
12
+ version: 2;
13
+ /** Unix timestamp (seconds) of the last processed event */
14
+ lastProcessedAt: number | null;
15
+ /** Gateway startup timestamp (seconds) - events before this are old */
16
+ gatewayStartedAt: number | null;
17
+ /** Recent processed event IDs for overlap dedupe across restarts */
18
+ recentEventIds: string[];
19
+ };
20
+
21
+ /** Profile publish state (separate from bus state) */
22
+ type NostrProfileState = {
23
+ version: 1;
24
+ /** Unix timestamp (seconds) of last successful profile publish */
25
+ lastPublishedAt: number | null;
26
+ /** Event ID of the last published profile */
27
+ lastPublishedEventId: string | null;
28
+ /** Per-relay publish results from last attempt */
29
+ lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
30
+ };
31
+
32
+ const NullableFiniteNumberSchema = z.number().finite().nullable().catch(null);
33
+ const NostrBusStateV1Schema = z.object({
34
+ version: z.literal(1),
35
+ lastProcessedAt: NullableFiniteNumberSchema,
36
+ gatewayStartedAt: NullableFiniteNumberSchema,
37
+ });
38
+
39
+ const NostrBusStateSchema = z.object({
40
+ version: z.literal(2),
41
+ lastProcessedAt: NullableFiniteNumberSchema,
42
+ gatewayStartedAt: NullableFiniteNumberSchema,
43
+ recentEventIds: z
44
+ .array(z.unknown())
45
+ .catch([])
46
+ .transform((ids) => ids.filter((id): id is string => typeof id === "string")),
47
+ });
48
+
49
+ const NostrProfileStateSchema = z.object({
50
+ version: z.literal(1),
51
+ lastPublishedAt: NullableFiniteNumberSchema,
52
+ lastPublishedEventId: z.string().nullable().catch(null),
53
+ lastPublishResults: z
54
+ .record(z.string(), z.enum(["ok", "failed", "timeout"]))
55
+ .nullable()
56
+ .catch(null),
57
+ });
58
+
59
+ function normalizeAccountId(accountId?: string): string {
60
+ const trimmed = accountId?.trim();
61
+ if (!trimmed) {
62
+ return "default";
63
+ }
64
+ return trimmed.replace(/[^a-z0-9._-]+/gi, "_");
65
+ }
66
+
67
+ function resolveNostrStatePath(accountId?: string, env: NodeJS.ProcessEnv = process.env): string {
68
+ const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
69
+ const normalized = normalizeAccountId(accountId);
70
+ return path.join(stateDir, "nostr", `bus-state-${normalized}.json`);
71
+ }
72
+
73
+ function resolveNostrProfileStatePath(
74
+ accountId?: string,
75
+ env: NodeJS.ProcessEnv = process.env,
76
+ ): string {
77
+ const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
78
+ const normalized = normalizeAccountId(accountId);
79
+ return path.join(stateDir, "nostr", `profile-state-${normalized}.json`);
80
+ }
81
+
82
+ function safeParseState(raw: string): NostrBusState | null {
83
+ const parsedV2 = safeParseJsonWithSchema(NostrBusStateSchema, raw);
84
+ if (parsedV2) {
85
+ return parsedV2;
86
+ }
87
+
88
+ const parsedV1 = safeParseJsonWithSchema(NostrBusStateV1Schema, raw);
89
+ if (!parsedV1) {
90
+ return null;
91
+ }
92
+
93
+ // Back-compat: v1 state files
94
+ return {
95
+ version: 2,
96
+ lastProcessedAt: parsedV1.lastProcessedAt,
97
+ gatewayStartedAt: parsedV1.gatewayStartedAt,
98
+ recentEventIds: [],
99
+ };
100
+ }
101
+
102
+ export async function readNostrBusState(params: {
103
+ accountId?: string;
104
+ env?: NodeJS.ProcessEnv;
105
+ }): Promise<NostrBusState | null> {
106
+ const filePath = resolveNostrStatePath(params.accountId, params.env);
107
+ try {
108
+ const raw = await privateFileStore(path.dirname(filePath)).readTextIfExists(
109
+ path.basename(filePath),
110
+ );
111
+ if (raw === null) {
112
+ return null;
113
+ }
114
+ return safeParseState(raw);
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ export async function writeNostrBusState(params: {
121
+ accountId?: string;
122
+ lastProcessedAt: number;
123
+ gatewayStartedAt: number;
124
+ recentEventIds?: string[];
125
+ env?: NodeJS.ProcessEnv;
126
+ }): Promise<void> {
127
+ const filePath = resolveNostrStatePath(params.accountId, params.env);
128
+ const payload: NostrBusState = {
129
+ version: STORE_VERSION,
130
+ lastProcessedAt: params.lastProcessedAt,
131
+ gatewayStartedAt: params.gatewayStartedAt,
132
+ recentEventIds: (params.recentEventIds ?? []).filter((x): x is string => typeof x === "string"),
133
+ };
134
+ await privateFileStore(path.dirname(filePath)).writeJson(path.basename(filePath), payload, {
135
+ trailingNewline: true,
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Determine the `since` timestamp for subscription.
141
+ * Returns the later of: lastProcessedAt or gatewayStartedAt (both from disk),
142
+ * falling back to `now` for fresh starts.
143
+ */
144
+ export function computeSinceTimestamp(
145
+ state: NostrBusState | null,
146
+ nowSec: number = Math.floor(Date.now() / 1000),
147
+ ): number {
148
+ if (!state) {
149
+ return nowSec;
150
+ }
151
+
152
+ // Use the most recent timestamp we have
153
+ const candidates = [state.lastProcessedAt, state.gatewayStartedAt].filter(
154
+ (t): t is number => t !== null && t > 0,
155
+ );
156
+
157
+ if (candidates.length === 0) {
158
+ return nowSec;
159
+ }
160
+ return Math.max(...candidates);
161
+ }
162
+
163
+ // ============================================================================
164
+ // Profile State Management
165
+ // ============================================================================
166
+
167
+ function safeParseProfileState(raw: string): NostrProfileState | null {
168
+ return safeParseJsonWithSchema(NostrProfileStateSchema, raw);
169
+ }
170
+
171
+ export async function readNostrProfileState(params: {
172
+ accountId?: string;
173
+ env?: NodeJS.ProcessEnv;
174
+ }): Promise<NostrProfileState | null> {
175
+ const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
176
+ try {
177
+ const raw = await privateFileStore(path.dirname(filePath)).readTextIfExists(
178
+ path.basename(filePath),
179
+ );
180
+ if (raw === null) {
181
+ return null;
182
+ }
183
+ return safeParseProfileState(raw);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ export async function writeNostrProfileState(params: {
190
+ accountId?: string;
191
+ lastPublishedAt: number;
192
+ lastPublishedEventId: string;
193
+ lastPublishResults: Record<string, "ok" | "failed" | "timeout">;
194
+ env?: NodeJS.ProcessEnv;
195
+ }): Promise<void> {
196
+ const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
197
+ const payload: NostrProfileState = {
198
+ version: PROFILE_STATE_VERSION,
199
+ lastPublishedAt: params.lastPublishedAt,
200
+ lastPublishedEventId: params.lastPublishedEventId,
201
+ lastPublishResults: params.lastPublishResults,
202
+ };
203
+ await privateFileStore(path.dirname(filePath)).writeJson(path.basename(filePath), payload, {
204
+ trailingNewline: true,
205
+ });
206
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { PluginRuntime } from "autobot/plugin-sdk/core";
2
+ import { createPluginRuntimeStore } from "autobot/plugin-sdk/runtime-store";
3
+
4
+ const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
5
+ createPluginRuntimeStore<PluginRuntime>({
6
+ pluginId: "nostr",
7
+ errorMessage: "Nostr runtime not initialized",
8
+ });
9
+ export { getNostrRuntime, setNostrRuntime };