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,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, "&")
|
|
304
|
+
.replace(/</g, "<")
|
|
305
|
+
.replace(/>/g, ">")
|
|
306
|
+
.replace(/"/g, """)
|
|
307
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|