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.
Files changed (72) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +845 -0
  3. package/api.ts +6 -0
  4. package/index.ts +69 -0
  5. package/node_modules/cascadia-ts/cascadia.ts +1187 -0
  6. package/node_modules/cascadia-ts/dist/cascadia.d.ts +607 -0
  7. package/node_modules/cascadia-ts/dist/cascadia.js +705 -0
  8. package/node_modules/cascadia-ts/package.json +21 -0
  9. package/openclaw.plugin.json +153 -0
  10. package/package.json +81 -0
  11. package/runtime-api.ts +12 -0
  12. package/setup-api.ts +1 -0
  13. package/setup-entry.ts +4 -0
  14. package/src/channel-actions.test.ts +304 -0
  15. package/src/channel-actions.ts +199 -0
  16. package/src/channel.dm-access.test.ts +387 -0
  17. package/src/channel.inbound.test.ts +476 -0
  18. package/src/channel.lifecycle.test.ts +768 -0
  19. package/src/channel.outbound.test.ts +1143 -0
  20. package/src/channel.test.ts +175 -0
  21. package/src/channel.ts +2827 -0
  22. package/src/config-schema.test.ts +486 -0
  23. package/src/config-schema.ts +356 -0
  24. package/src/default-relays.ts +1 -0
  25. package/src/fleet-agent.test.ts +423 -0
  26. package/src/fleet-agent.ts +549 -0
  27. package/src/metrics.ts +466 -0
  28. package/src/nip46-cutover.test.ts +324 -0
  29. package/src/nip46-cutover.ts +461 -0
  30. package/src/nip46-doctor.test.ts +191 -0
  31. package/src/nip46-doctor.ts +329 -0
  32. package/src/nip46-enroll.test.ts +346 -0
  33. package/src/nip46-enroll.ts +567 -0
  34. package/src/nip46-signer.test.ts +809 -0
  35. package/src/nip46-signer.ts +1263 -0
  36. package/src/nip46-status.test.ts +186 -0
  37. package/src/nip46-status.ts +250 -0
  38. package/src/nip51-lists.test.ts +404 -0
  39. package/src/nip51-lists.ts +777 -0
  40. package/src/nostr-access.test.ts +175 -0
  41. package/src/nostr-access.ts +134 -0
  42. package/src/nostr-bus-nip29.ts +998 -0
  43. package/src/nostr-bus-relay.ts +477 -0
  44. package/src/nostr-bus-types.ts +392 -0
  45. package/src/nostr-bus.fuzz.test.ts +533 -0
  46. package/src/nostr-bus.integration.test.ts +505 -0
  47. package/src/nostr-bus.protocol.test.ts +2057 -0
  48. package/src/nostr-bus.test.ts +235 -0
  49. package/src/nostr-bus.ts +1868 -0
  50. package/src/nostr-capabilities-extended.test.ts +612 -0
  51. package/src/nostr-capabilities.ts +719 -0
  52. package/src/nostr-discovery.test.ts +568 -0
  53. package/src/nostr-discovery.ts +336 -0
  54. package/src/nostr-extras.test.ts +88 -0
  55. package/src/nostr-extras.ts +136 -0
  56. package/src/nostr-profile-http.test.ts +662 -0
  57. package/src/nostr-profile-http.ts +836 -0
  58. package/src/nostr-profile-import.test.ts +274 -0
  59. package/src/nostr-profile-import.ts +295 -0
  60. package/src/nostr-profile.fuzz.test.ts +480 -0
  61. package/src/nostr-profile.test.ts +410 -0
  62. package/src/nostr-profile.ts +320 -0
  63. package/src/nostr-pubkey.ts +56 -0
  64. package/src/nostr-state-store.test.ts +790 -0
  65. package/src/nostr-state-store.ts +1284 -0
  66. package/src/runtime.ts +9 -0
  67. package/src/seen-tracker.test.ts +25 -0
  68. package/src/seen-tracker.ts +292 -0
  69. package/src/session-route.ts +25 -0
  70. package/src/setup-surface.ts +320 -0
  71. package/src/types.test.ts +783 -0
  72. package/src/types.ts +508 -0
@@ -0,0 +1,320 @@
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, type EventTemplate, type VerifiedEvent } from "nostr-tools";
9
+
10
+ export type EventSigner = (template: EventTemplate) => Promise<Event>;
11
+ import { type NostrProfile, NostrProfileSchema } from "./config-schema.js";
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ /** Result of a profile publish attempt */
18
+ export interface ProfilePublishResult {
19
+ /** Event ID of the published profile */
20
+ eventId: string;
21
+ /** Relays that successfully received the event */
22
+ successes: string[];
23
+ /** Relays that failed with their error messages */
24
+ failures: Array<{ relay: string; error: string }>;
25
+ /** Unix timestamp when the event was created */
26
+ createdAt: number;
27
+ }
28
+
29
+ /** NIP-01 profile content (JSON inside kind:0 event) */
30
+ export interface ProfileContent {
31
+ name?: string;
32
+ display_name?: string;
33
+ about?: string;
34
+ picture?: string;
35
+ banner?: string;
36
+ website?: string;
37
+ nip05?: string;
38
+ lud16?: string;
39
+ }
40
+
41
+ // ============================================================================
42
+ // Profile Content Conversion
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Convert our config profile schema to NIP-01 content format.
47
+ * Strips undefined fields and validates URLs.
48
+ */
49
+ export function profileToContent(profile: NostrProfile): ProfileContent {
50
+ const validated = NostrProfileSchema.parse(profile);
51
+
52
+ const content: ProfileContent = {};
53
+
54
+ if (validated.name !== undefined) {
55
+ content.name = validated.name;
56
+ }
57
+ if (validated.displayName !== undefined) {
58
+ content.display_name = validated.displayName;
59
+ }
60
+ if (validated.about !== undefined) {
61
+ content.about = validated.about;
62
+ }
63
+ if (validated.picture !== undefined) {
64
+ content.picture = validated.picture;
65
+ }
66
+ if (validated.banner !== undefined) {
67
+ content.banner = validated.banner;
68
+ }
69
+ if (validated.website !== undefined) {
70
+ content.website = validated.website;
71
+ }
72
+ if (validated.nip05 !== undefined) {
73
+ content.nip05 = validated.nip05;
74
+ }
75
+ if (validated.lud16 !== undefined) {
76
+ content.lud16 = validated.lud16;
77
+ }
78
+
79
+ return content;
80
+ }
81
+
82
+ /**
83
+ * Convert NIP-01 content format back to our config profile schema.
84
+ * Useful for importing existing profiles from relays.
85
+ */
86
+ export function contentToProfile(content: ProfileContent): NostrProfile {
87
+ const profile: NostrProfile = {};
88
+
89
+ if (content.name !== undefined) {
90
+ profile.name = content.name;
91
+ }
92
+ if (content.display_name !== undefined) {
93
+ profile.displayName = content.display_name;
94
+ }
95
+ if (content.about !== undefined) {
96
+ profile.about = content.about;
97
+ }
98
+ if (content.picture !== undefined) {
99
+ profile.picture = content.picture;
100
+ }
101
+ if (content.banner !== undefined) {
102
+ profile.banner = content.banner;
103
+ }
104
+ if (content.website !== undefined) {
105
+ profile.website = content.website;
106
+ }
107
+ if (content.nip05 !== undefined) {
108
+ profile.nip05 = content.nip05;
109
+ }
110
+ if (content.lud16 !== undefined) {
111
+ profile.lud16 = content.lud16;
112
+ }
113
+
114
+ return profile;
115
+ }
116
+
117
+ // ============================================================================
118
+ // Event Creation
119
+ // ============================================================================
120
+
121
+ /**
122
+ * Create a signed kind:0 profile event.
123
+ *
124
+ * @param sk - Private key as Uint8Array (32 bytes)
125
+ * @param profile - Profile data to include
126
+ * @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee)
127
+ * @returns Signed Nostr event
128
+ */
129
+ function createProfileEventTemplate(
130
+ profile: NostrProfile,
131
+ lastPublishedAt?: number,
132
+ ): EventTemplate {
133
+ const content = profileToContent(profile);
134
+ const contentJson = JSON.stringify(content);
135
+
136
+ // Ensure monotonic timestamp (new event > previous)
137
+ const now = Math.floor(Date.now() / 1000);
138
+ const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now;
139
+
140
+ return {
141
+ kind: 0,
142
+ content: contentJson,
143
+ tags: [],
144
+ created_at: createdAt,
145
+ };
146
+ }
147
+
148
+ export function createProfileEvent(
149
+ sk: Uint8Array,
150
+ profile: NostrProfile,
151
+ lastPublishedAt?: number,
152
+ ): Event {
153
+ return finalizeEvent(createProfileEventTemplate(profile, lastPublishedAt), sk);
154
+ }
155
+
156
+ // ============================================================================
157
+ // Profile Publishing
158
+ // ============================================================================
159
+
160
+ /**
161
+ * Publish a profile event to multiple relays.
162
+ *
163
+ * Best-effort: publishes to all relays in parallel, reports per-relay results.
164
+ * nostr-tools handles per-relay timeouts internally (~4.4s).
165
+ *
166
+ * @param pool - SimplePool instance for relay connections
167
+ * @param relays - Array of relay WebSocket URLs
168
+ * @param event - Signed profile event (kind:0)
169
+ * @returns Publish results with successes and failures
170
+ */
171
+ export async function publishProfileEvent(
172
+ pool: SimplePool,
173
+ relays: string[],
174
+ event: Event,
175
+ onauth?: (evt: EventTemplate) => Promise<VerifiedEvent>,
176
+ ): Promise<ProfilePublishResult> {
177
+ const successes: string[] = [];
178
+ const failures: Array<{ relay: string; error: string }> = [];
179
+
180
+ // pool.publish returns an array of per-relay promises
181
+ const perRelayPromises = pool.publish(relays, event, {
182
+ ...(onauth ? { onauth } : {}),
183
+ }) as unknown as Promise<string>[];
184
+
185
+ const settled = await Promise.allSettled(perRelayPromises);
186
+ for (let i = 0; i < relays.length; i++) {
187
+ const result = settled[i];
188
+ if (result.status === "fulfilled") {
189
+ successes.push(relays[i]);
190
+ } else {
191
+ const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
192
+ failures.push({ relay: relays[i], error: errorMessage });
193
+ }
194
+ }
195
+
196
+ return {
197
+ eventId: event.id,
198
+ successes,
199
+ failures,
200
+ createdAt: event.created_at,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Create and publish a profile event in one call.
206
+ *
207
+ * @param pool - SimplePool instance
208
+ * @param sk - Private key as Uint8Array
209
+ * @param relays - Array of relay URLs
210
+ * @param profile - Profile data
211
+ * @param lastPublishedAt - Previous timestamp for monotonic ordering
212
+ * @returns Publish results
213
+ */
214
+ export async function publishProfile(
215
+ pool: SimplePool,
216
+ signEvent: EventSigner,
217
+ relays: string[],
218
+ profile: NostrProfile,
219
+ lastPublishedAt?: number,
220
+ onauth?: (evt: EventTemplate) => Promise<VerifiedEvent>,
221
+ ): Promise<ProfilePublishResult> {
222
+ const event = await signEvent(createProfileEventTemplate(profile, lastPublishedAt));
223
+ return publishProfileEvent(pool, relays, event, onauth);
224
+ }
225
+
226
+ // ============================================================================
227
+ // NIP-65 Relay List Metadata (kind:10002)
228
+ // ============================================================================
229
+
230
+ /**
231
+ * Create a signed kind:10002 relay list metadata event (NIP-65).
232
+ * Tags each relay with "r" and an optional read/write marker.
233
+ *
234
+ * @param sk - Private key as Uint8Array (32 bytes)
235
+ * @param relays - Array of relay URLs (all marked as read+write)
236
+ * @returns Signed Nostr event
237
+ */
238
+ function createRelayListEventTemplate(relays: string[]): EventTemplate {
239
+ return {
240
+ kind: 10002,
241
+ content: "",
242
+ tags: relays.map((url) => ["r", url]),
243
+ created_at: Math.floor(Date.now() / 1000),
244
+ };
245
+ }
246
+
247
+ export function createRelayListEvent(sk: Uint8Array, relays: string[]): Event {
248
+ return finalizeEvent(createRelayListEventTemplate(relays), sk);
249
+ }
250
+
251
+ /**
252
+ * Create and publish a NIP-65 relay list metadata event.
253
+ *
254
+ * @param pool - SimplePool instance
255
+ * @param sk - Private key as Uint8Array
256
+ * @param relays - Array of relay URLs to advertise and publish to
257
+ * @returns Publish results
258
+ */
259
+ export async function publishRelayList(
260
+ pool: SimplePool,
261
+ signEvent: EventSigner,
262
+ relays: string[],
263
+ onauth?: (evt: EventTemplate) => Promise<VerifiedEvent>,
264
+ ): Promise<ProfilePublishResult> {
265
+ const event = await signEvent(createRelayListEventTemplate(relays));
266
+ return publishProfileEvent(pool, relays, event, onauth);
267
+ }
268
+
269
+ // ============================================================================
270
+ // Profile Validation Helpers
271
+ // ============================================================================
272
+
273
+ /**
274
+ * Validate a profile without throwing (returns result object).
275
+ */
276
+ export function validateProfile(profile: unknown): {
277
+ valid: boolean;
278
+ profile?: NostrProfile;
279
+ errors?: string[];
280
+ } {
281
+ const result = NostrProfileSchema.safeParse(profile);
282
+
283
+ if (result.success) {
284
+ return { valid: true, profile: result.data };
285
+ }
286
+
287
+ return {
288
+ valid: false,
289
+ errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`),
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Sanitize profile text fields to prevent XSS when displaying in UI.
295
+ * Escapes HTML special characters.
296
+ */
297
+ export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile {
298
+ const escapeHtml = (str: string | undefined): string | undefined => {
299
+ if (str === undefined) {
300
+ return undefined;
301
+ }
302
+ return str
303
+ .replace(/&/g, "&amp;")
304
+ .replace(/</g, "&lt;")
305
+ .replace(/>/g, "&gt;")
306
+ .replace(/"/g, "&quot;")
307
+ .replace(/'/g, "&#039;");
308
+ };
309
+
310
+ return {
311
+ name: escapeHtml(profile.name),
312
+ displayName: escapeHtml(profile.displayName),
313
+ about: escapeHtml(profile.about),
314
+ picture: profile.picture, // URLs already validated by schema
315
+ banner: profile.banner,
316
+ website: profile.website,
317
+ nip05: escapeHtml(profile.nip05),
318
+ lud16: escapeHtml(profile.lud16),
319
+ };
320
+ }
@@ -0,0 +1,56 @@
1
+ import { nip19 } from "nostr-tools";
2
+
3
+ const HEX_PUBKEY_REGEX = /^[0-9a-fA-F]{64}$/;
4
+
5
+ function stripOptionalNostrPrefix(input: string): string {
6
+ const trimmed = input.trim();
7
+ return trimmed.replace(/^nostr:/i, "").trim();
8
+ }
9
+
10
+ /**
11
+ * Check if a string looks like a valid Nostr pubkey (hex or npub).
12
+ */
13
+ export function isValidPubkey(input: string): boolean {
14
+ if (typeof input !== "string") return false;
15
+
16
+ try {
17
+ normalizePubkey(input);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Normalize a pubkey to canonical lowercase hex format.
26
+ *
27
+ * Accepts raw 64-character hex, lowercase npub, and either form with an
28
+ * optional leading nostr: prefix. Unsupported NIP-19 entities and mixed-case
29
+ * npub identifiers are rejected by nip19.decode.
30
+ */
31
+ export function normalizePubkey(input: string): string {
32
+ if (typeof input !== "string") {
33
+ throw new Error("Pubkey must be 64 hex characters or npub format");
34
+ }
35
+
36
+ const normalizedInput = stripOptionalNostrPrefix(input);
37
+
38
+ if (normalizedInput.startsWith("npub1")) {
39
+ const decoded = nip19.decode(normalizedInput);
40
+ if (decoded.type !== "npub") throw new Error("Invalid npub key");
41
+ return decoded.data as string;
42
+ }
43
+
44
+ if (!HEX_PUBKEY_REGEX.test(normalizedInput)) {
45
+ throw new Error("Pubkey must be 64 hex characters or npub format");
46
+ }
47
+ return normalizedInput.toLowerCase();
48
+ }
49
+
50
+ /**
51
+ * Convert a pubkey identifier to npub format.
52
+ */
53
+ export function pubkeyToNpub(pubkey: string): string {
54
+ const normalized = normalizePubkey(pubkey);
55
+ return nip19.npubEncode(normalized);
56
+ }