@openclaw/nostr 2026.5.2 → 2026.5.3-beta.1
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/dist/api.js +532 -0
- package/dist/channel-DfEqBtUh.js +1466 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/config-schema-DIk4jlBg.js +64 -0
- package/dist/default-relays-DLwdWOTu.js +4 -0
- package/dist/inbound-direct-dm-runtime-22bZWcIW.js +2 -0
- package/dist/index.js +84 -0
- package/dist/runtime-api.js +2 -0
- package/dist/setup-api.js +2 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-plugin-api.js +165 -0
- package/dist/setup-surface-DxAaUTyC.js +336 -0
- package/dist/test-api.js +2 -0
- package/package.json +15 -6
- package/api.ts +0 -10
- package/channel-plugin-api.ts +0 -1
- package/index.ts +0 -97
- package/runtime-api.ts +0 -6
- package/setup-api.ts +0 -1
- package/setup-entry.ts +0 -9
- package/setup-plugin-api.ts +0 -3
- package/src/channel-api.ts +0 -15
- package/src/channel.inbound.test.ts +0 -176
- package/src/channel.outbound.test.ts +0 -128
- package/src/channel.setup.ts +0 -231
- package/src/channel.test.ts +0 -519
- package/src/channel.ts +0 -207
- package/src/config-schema.ts +0 -98
- package/src/default-relays.ts +0 -1
- package/src/gateway.ts +0 -302
- package/src/inbound-direct-dm-runtime.ts +0 -1
- package/src/metrics.ts +0 -458
- package/src/nostr-bus.fuzz.test.ts +0 -360
- package/src/nostr-bus.inbound.test.ts +0 -526
- package/src/nostr-bus.integration.test.ts +0 -472
- package/src/nostr-bus.test.ts +0 -190
- package/src/nostr-bus.ts +0 -789
- package/src/nostr-key-utils.ts +0 -94
- package/src/nostr-profile-core.ts +0 -134
- package/src/nostr-profile-http-runtime.ts +0 -6
- package/src/nostr-profile-http.test.ts +0 -632
- package/src/nostr-profile-http.ts +0 -594
- package/src/nostr-profile-import.test.ts +0 -119
- package/src/nostr-profile-import.ts +0 -262
- package/src/nostr-profile-url-safety.ts +0 -21
- package/src/nostr-profile.fuzz.test.ts +0 -430
- package/src/nostr-profile.test.ts +0 -412
- package/src/nostr-profile.ts +0 -144
- package/src/nostr-state-store.test.ts +0 -237
- package/src/nostr-state-store.ts +0 -223
- package/src/runtime.ts +0 -9
- package/src/seen-tracker.ts +0 -289
- package/src/session-route.ts +0 -25
- package/src/setup-surface.ts +0 -265
- package/src/test-fixtures.ts +0 -45
- package/src/types.ts +0 -117
- package/test/setup.ts +0 -5
- package/test-api.ts +0 -1
- package/tsconfig.json +0 -16
package/dist/api.js
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { o as resolveNostrAccount } from "./setup-surface-DxAaUTyC.js";
|
|
2
|
+
import { getPluginRuntimeGatewayRequestScope } from "./runtime-api.js";
|
|
3
|
+
import { n as NostrProfileSchema } from "./config-schema-DIk4jlBg.js";
|
|
4
|
+
import { a as setNostrRuntime, i as getNostrRuntime, n as nostrPlugin, o as contentToProfile, r as publishNostrProfile, t as getNostrProfileState } from "./channel-DfEqBtUh.js";
|
|
5
|
+
import { z } from "openclaw/plugin-sdk/zod";
|
|
6
|
+
import { SimplePool, verifyEvent } from "nostr-tools";
|
|
7
|
+
import { readJsonBodyWithLimit, requestBodyErrorToText } from "openclaw/plugin-sdk/webhook-request-guards";
|
|
8
|
+
import { createFixedWindowRateLimiter } from "openclaw/plugin-sdk/webhook-ingress";
|
|
9
|
+
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
10
|
+
//#region extensions/nostr/src/nostr-profile-url-safety.ts
|
|
11
|
+
function validateUrlSafety(urlStr) {
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(urlStr);
|
|
14
|
+
if (url.protocol !== "https:") return {
|
|
15
|
+
ok: false,
|
|
16
|
+
error: "URL must use https:// protocol"
|
|
17
|
+
};
|
|
18
|
+
if (isBlockedHostnameOrIp(url.hostname.trim().toLowerCase())) return {
|
|
19
|
+
ok: false,
|
|
20
|
+
error: "URL must not point to private/internal addresses"
|
|
21
|
+
};
|
|
22
|
+
return { ok: true };
|
|
23
|
+
} catch {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: "Invalid URL format"
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region extensions/nostr/src/nostr-profile-import.ts
|
|
32
|
+
/**
|
|
33
|
+
* Nostr Profile Import
|
|
34
|
+
*
|
|
35
|
+
* Fetches and verifies kind:0 profile events from relays.
|
|
36
|
+
* Used to import existing profiles before editing.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
39
|
+
/**
|
|
40
|
+
* Sanitize URLs in an imported profile to prevent SSRF attacks.
|
|
41
|
+
* Removes any URLs that don't pass SSRF validation.
|
|
42
|
+
*/
|
|
43
|
+
function sanitizeProfileUrls(profile) {
|
|
44
|
+
const result = { ...profile };
|
|
45
|
+
for (const field of [
|
|
46
|
+
"picture",
|
|
47
|
+
"banner",
|
|
48
|
+
"website"
|
|
49
|
+
]) {
|
|
50
|
+
const value = result[field];
|
|
51
|
+
if (value && typeof value === "string") {
|
|
52
|
+
if (!validateUrlSafety(value).ok) delete result[field];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Fetch the latest kind:0 profile event for a pubkey from relays.
|
|
59
|
+
*
|
|
60
|
+
* - Queries all relays in parallel
|
|
61
|
+
* - Takes the event with the highest created_at
|
|
62
|
+
* - Verifies the event signature
|
|
63
|
+
* - Parses and returns the profile
|
|
64
|
+
*/
|
|
65
|
+
async function importProfileFromRelays(opts) {
|
|
66
|
+
const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
|
|
67
|
+
if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: "Invalid pubkey format (must be 64 hex characters)",
|
|
70
|
+
relaysQueried: []
|
|
71
|
+
};
|
|
72
|
+
if (relays.length === 0) return {
|
|
73
|
+
ok: false,
|
|
74
|
+
error: "No relays configured",
|
|
75
|
+
relaysQueried: []
|
|
76
|
+
};
|
|
77
|
+
const pool = new SimplePool();
|
|
78
|
+
const relaysQueried = [];
|
|
79
|
+
try {
|
|
80
|
+
const events = [];
|
|
81
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
82
|
+
setTimeout(resolve, timeoutMs);
|
|
83
|
+
});
|
|
84
|
+
const subscriptionPromise = new Promise((resolve) => {
|
|
85
|
+
let completed = 0;
|
|
86
|
+
for (const relay of relays) {
|
|
87
|
+
relaysQueried.push(relay);
|
|
88
|
+
const sub = pool.subscribeMany([relay], [{
|
|
89
|
+
kinds: [0],
|
|
90
|
+
authors: [pubkey],
|
|
91
|
+
limit: 1
|
|
92
|
+
}], {
|
|
93
|
+
onevent(event) {
|
|
94
|
+
events.push({
|
|
95
|
+
event,
|
|
96
|
+
relay
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
oneose() {
|
|
100
|
+
completed++;
|
|
101
|
+
if (completed >= relays.length) resolve();
|
|
102
|
+
},
|
|
103
|
+
onclose() {
|
|
104
|
+
completed++;
|
|
105
|
+
if (completed >= relays.length) resolve();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
sub.close();
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
await Promise.race([subscriptionPromise, timeoutPromise]);
|
|
114
|
+
if (events.length === 0) return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: "No profile found on any relay",
|
|
117
|
+
relaysQueried
|
|
118
|
+
};
|
|
119
|
+
let bestEvent = null;
|
|
120
|
+
for (const item of events) if (!bestEvent || item.event.created_at > bestEvent.event.created_at) bestEvent = item;
|
|
121
|
+
if (!bestEvent) return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: "No valid profile event found",
|
|
124
|
+
relaysQueried
|
|
125
|
+
};
|
|
126
|
+
if (!verifyEvent(bestEvent.event)) return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: "Profile event has invalid signature",
|
|
129
|
+
relaysQueried,
|
|
130
|
+
sourceRelay: bestEvent.relay
|
|
131
|
+
};
|
|
132
|
+
let content;
|
|
133
|
+
try {
|
|
134
|
+
content = JSON.parse(bestEvent.event.content);
|
|
135
|
+
} catch {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
error: "Profile event has invalid JSON content",
|
|
139
|
+
relaysQueried,
|
|
140
|
+
sourceRelay: bestEvent.relay
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
profile: sanitizeProfileUrls(contentToProfile(content)),
|
|
146
|
+
event: {
|
|
147
|
+
id: bestEvent.event.id,
|
|
148
|
+
pubkey: bestEvent.event.pubkey,
|
|
149
|
+
created_at: bestEvent.event.created_at
|
|
150
|
+
},
|
|
151
|
+
relaysQueried,
|
|
152
|
+
sourceRelay: bestEvent.relay
|
|
153
|
+
};
|
|
154
|
+
} finally {
|
|
155
|
+
pool.close(relays);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Merge imported profile with local profile.
|
|
160
|
+
*
|
|
161
|
+
* Strategy:
|
|
162
|
+
* - For each field, prefer local if set, otherwise use imported
|
|
163
|
+
* - This preserves user customizations while filling in missing data
|
|
164
|
+
*/
|
|
165
|
+
function mergeProfiles(local, imported) {
|
|
166
|
+
if (!imported) return local ?? {};
|
|
167
|
+
if (!local) return imported;
|
|
168
|
+
return {
|
|
169
|
+
name: local.name ?? imported.name,
|
|
170
|
+
displayName: local.displayName ?? imported.displayName,
|
|
171
|
+
about: local.about ?? imported.about,
|
|
172
|
+
picture: local.picture ?? imported.picture,
|
|
173
|
+
banner: local.banner ?? imported.banner,
|
|
174
|
+
website: local.website ?? imported.website,
|
|
175
|
+
nip05: local.nip05 ?? imported.nip05,
|
|
176
|
+
lud16: local.lud16 ?? imported.lud16
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region extensions/nostr/src/nostr-profile-http.ts
|
|
181
|
+
function readStringValue(value) {
|
|
182
|
+
return typeof value === "string" ? value : void 0;
|
|
183
|
+
}
|
|
184
|
+
function normalizeOptionalLowercaseString(value) {
|
|
185
|
+
if (typeof value !== "string") return;
|
|
186
|
+
const trimmed = value.trim();
|
|
187
|
+
return trimmed ? trimmed.toLowerCase() : void 0;
|
|
188
|
+
}
|
|
189
|
+
function normalizeLowercaseStringOrEmpty(value) {
|
|
190
|
+
return normalizeOptionalLowercaseString(value) ?? "";
|
|
191
|
+
}
|
|
192
|
+
const profileRateLimiter = createFixedWindowRateLimiter({
|
|
193
|
+
windowMs: 6e4,
|
|
194
|
+
maxRequests: 5,
|
|
195
|
+
maxTrackedKeys: 2048
|
|
196
|
+
});
|
|
197
|
+
function checkRateLimit(accountId) {
|
|
198
|
+
return !profileRateLimiter.isRateLimited(accountId);
|
|
199
|
+
}
|
|
200
|
+
const publishLocks = /* @__PURE__ */ new Map();
|
|
201
|
+
async function withPublishLock(accountId, fn) {
|
|
202
|
+
const prev = publishLocks.get(accountId) ?? Promise.resolve();
|
|
203
|
+
let resolve;
|
|
204
|
+
const next = new Promise((r) => {
|
|
205
|
+
resolve = r;
|
|
206
|
+
});
|
|
207
|
+
publishLocks.set(accountId, next);
|
|
208
|
+
await prev.catch(() => {});
|
|
209
|
+
try {
|
|
210
|
+
return await fn();
|
|
211
|
+
} finally {
|
|
212
|
+
resolve();
|
|
213
|
+
if (publishLocks.get(accountId) === next) publishLocks.delete(accountId);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const nip05FormatSchema = z.string().regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid NIP-05 format (user@domain.com)").optional();
|
|
217
|
+
const lud16FormatSchema = z.string().regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid Lightning address format").optional();
|
|
218
|
+
const ProfileUpdateSchema = NostrProfileSchema.extend({
|
|
219
|
+
nip05: nip05FormatSchema,
|
|
220
|
+
lud16: lud16FormatSchema
|
|
221
|
+
});
|
|
222
|
+
const PROFILE_MUTATION_SCOPE = "operator.admin";
|
|
223
|
+
function sendJson(res, status, body) {
|
|
224
|
+
res.statusCode = status;
|
|
225
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
226
|
+
res.end(JSON.stringify(body));
|
|
227
|
+
}
|
|
228
|
+
async function readJsonBody(req, maxBytes = 64 * 1024, timeoutMs = 3e4) {
|
|
229
|
+
const result = await readJsonBodyWithLimit(req, {
|
|
230
|
+
maxBytes,
|
|
231
|
+
timeoutMs,
|
|
232
|
+
emptyObjectOnEmpty: true
|
|
233
|
+
});
|
|
234
|
+
if (result.ok) return result.value;
|
|
235
|
+
if (result.code === "PAYLOAD_TOO_LARGE") throw new Error("Request body too large");
|
|
236
|
+
if (result.code === "REQUEST_BODY_TIMEOUT") throw new Error(requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
|
|
237
|
+
if (result.code === "CONNECTION_CLOSED") throw new Error(requestBodyErrorToText("CONNECTION_CLOSED"));
|
|
238
|
+
throw new Error(result.code === "INVALID_JSON" ? "Invalid JSON" : result.error);
|
|
239
|
+
}
|
|
240
|
+
function parseAccountIdFromPath(pathname) {
|
|
241
|
+
return pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/)?.[1] ?? null;
|
|
242
|
+
}
|
|
243
|
+
function isLoopbackRemoteAddress(remoteAddress) {
|
|
244
|
+
if (!remoteAddress) return false;
|
|
245
|
+
const ipLower = normalizeLowercaseStringOrEmpty(remoteAddress).replace(/^\[|\]$/g, "");
|
|
246
|
+
if (ipLower === "::1") return true;
|
|
247
|
+
if (ipLower === "127.0.0.1" || ipLower.startsWith("127.")) return true;
|
|
248
|
+
const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
249
|
+
if (v4Mapped) return isLoopbackRemoteAddress(v4Mapped[1]);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
function isLoopbackOriginLike(value) {
|
|
253
|
+
try {
|
|
254
|
+
const hostname = normalizeLowercaseStringOrEmpty(new URL(value).hostname);
|
|
255
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function firstHeaderValue(value) {
|
|
261
|
+
if (Array.isArray(value)) return value[0];
|
|
262
|
+
return readStringValue(value);
|
|
263
|
+
}
|
|
264
|
+
function normalizeIpCandidate(raw) {
|
|
265
|
+
const unquoted = raw.trim().replace(/^"|"$/g, "");
|
|
266
|
+
const bracketedWithOptionalPort = unquoted.match(/^\[([^[\]]+)\](?::\d+)?$/);
|
|
267
|
+
if (bracketedWithOptionalPort) return bracketedWithOptionalPort[1] ?? "";
|
|
268
|
+
const ipv4WithPort = unquoted.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/);
|
|
269
|
+
if (ipv4WithPort) return ipv4WithPort[1] ?? "";
|
|
270
|
+
return unquoted;
|
|
271
|
+
}
|
|
272
|
+
function hasNonLoopbackForwardedClient(req) {
|
|
273
|
+
const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]);
|
|
274
|
+
if (forwardedFor) for (const hop of forwardedFor.split(",")) {
|
|
275
|
+
const candidate = normalizeIpCandidate(hop);
|
|
276
|
+
if (!candidate) continue;
|
|
277
|
+
if (!isLoopbackRemoteAddress(candidate)) return true;
|
|
278
|
+
}
|
|
279
|
+
const realIp = firstHeaderValue(req.headers["x-real-ip"]);
|
|
280
|
+
if (realIp) {
|
|
281
|
+
const candidate = normalizeIpCandidate(realIp);
|
|
282
|
+
if (candidate && !isLoopbackRemoteAddress(candidate)) return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
function enforceLoopbackMutationGuards(ctx, req, res) {
|
|
287
|
+
const remoteAddress = req.socket.remoteAddress;
|
|
288
|
+
if (!isLoopbackRemoteAddress(remoteAddress)) {
|
|
289
|
+
ctx.log?.warn?.(`Rejected mutation from non-loopback remoteAddress=${String(remoteAddress)}`);
|
|
290
|
+
sendJson(res, 403, {
|
|
291
|
+
ok: false,
|
|
292
|
+
error: "Forbidden"
|
|
293
|
+
});
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
if (hasNonLoopbackForwardedClient(req)) {
|
|
297
|
+
ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers");
|
|
298
|
+
sendJson(res, 403, {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: "Forbidden"
|
|
301
|
+
});
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
if (normalizeOptionalLowercaseString(firstHeaderValue(req.headers["sec-fetch-site"])) === "cross-site") {
|
|
305
|
+
ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header");
|
|
306
|
+
sendJson(res, 403, {
|
|
307
|
+
ok: false,
|
|
308
|
+
error: "Forbidden"
|
|
309
|
+
});
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
const origin = firstHeaderValue(req.headers.origin);
|
|
313
|
+
if (typeof origin === "string" && !isLoopbackOriginLike(origin)) {
|
|
314
|
+
ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`);
|
|
315
|
+
sendJson(res, 403, {
|
|
316
|
+
ok: false,
|
|
317
|
+
error: "Forbidden"
|
|
318
|
+
});
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
const referer = firstHeaderValue(req.headers.referer ?? req.headers.referrer);
|
|
322
|
+
if (typeof referer === "string" && !isLoopbackOriginLike(referer)) {
|
|
323
|
+
ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`);
|
|
324
|
+
sendJson(res, 403, {
|
|
325
|
+
ok: false,
|
|
326
|
+
error: "Forbidden"
|
|
327
|
+
});
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
function enforceGatewayMutationScope(ctx, accountId, res) {
|
|
333
|
+
const runtimeScopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes;
|
|
334
|
+
if ((Array.isArray(runtimeScopes) ? runtimeScopes : []).includes(PROFILE_MUTATION_SCOPE)) return true;
|
|
335
|
+
ctx.log?.warn?.(`[${accountId}] Rejected profile mutation missing ${PROFILE_MUTATION_SCOPE}`);
|
|
336
|
+
sendJson(res, 403, {
|
|
337
|
+
ok: false,
|
|
338
|
+
error: `missing scope: ${PROFILE_MUTATION_SCOPE}`
|
|
339
|
+
});
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
function createNostrProfileHttpHandler(ctx) {
|
|
343
|
+
return async (req, res) => {
|
|
344
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
345
|
+
if (!url.pathname.startsWith("/api/channels/nostr/")) return false;
|
|
346
|
+
const accountId = parseAccountIdFromPath(url.pathname);
|
|
347
|
+
if (!accountId) return false;
|
|
348
|
+
const isImport = url.pathname.endsWith("/profile/import");
|
|
349
|
+
if (!(url.pathname.endsWith("/profile") || isImport)) return false;
|
|
350
|
+
try {
|
|
351
|
+
if (req.method === "GET" && !isImport) return await handleGetProfile(accountId, ctx, res);
|
|
352
|
+
if (req.method === "PUT" && !isImport) return await handleUpdateProfile(accountId, ctx, req, res);
|
|
353
|
+
if (req.method === "POST" && isImport) return await handleImportProfile(accountId, ctx, req, res);
|
|
354
|
+
sendJson(res, 405, {
|
|
355
|
+
ok: false,
|
|
356
|
+
error: "Method not allowed"
|
|
357
|
+
});
|
|
358
|
+
return true;
|
|
359
|
+
} catch (err) {
|
|
360
|
+
ctx.log?.error(`Profile HTTP error: ${String(err)}`);
|
|
361
|
+
sendJson(res, 500, {
|
|
362
|
+
ok: false,
|
|
363
|
+
error: "Internal server error"
|
|
364
|
+
});
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
async function handleGetProfile(accountId, ctx, res) {
|
|
370
|
+
const configProfile = ctx.getConfigProfile(accountId);
|
|
371
|
+
const publishState = await getNostrProfileState(accountId);
|
|
372
|
+
sendJson(res, 200, {
|
|
373
|
+
ok: true,
|
|
374
|
+
profile: configProfile ?? null,
|
|
375
|
+
publishState: publishState ?? null
|
|
376
|
+
});
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
async function handleUpdateProfile(accountId, ctx, req, res) {
|
|
380
|
+
if (!enforceGatewayMutationScope(ctx, accountId, res)) return true;
|
|
381
|
+
if (!enforceLoopbackMutationGuards(ctx, req, res)) return true;
|
|
382
|
+
if (!checkRateLimit(accountId)) {
|
|
383
|
+
sendJson(res, 429, {
|
|
384
|
+
ok: false,
|
|
385
|
+
error: "Rate limit exceeded (5 requests/minute)"
|
|
386
|
+
});
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
let body;
|
|
390
|
+
try {
|
|
391
|
+
body = await readJsonBody(req);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
sendJson(res, 400, {
|
|
394
|
+
ok: false,
|
|
395
|
+
error: String(err)
|
|
396
|
+
});
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
const parseResult = ProfileUpdateSchema.safeParse(body);
|
|
400
|
+
if (!parseResult.success) {
|
|
401
|
+
sendJson(res, 400, {
|
|
402
|
+
ok: false,
|
|
403
|
+
error: "Validation failed",
|
|
404
|
+
details: parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
405
|
+
});
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
const profile = parseResult.data;
|
|
409
|
+
if (profile.picture) {
|
|
410
|
+
const pictureCheck = validateUrlSafety(profile.picture);
|
|
411
|
+
if (!pictureCheck.ok) {
|
|
412
|
+
sendJson(res, 400, {
|
|
413
|
+
ok: false,
|
|
414
|
+
error: `picture: ${pictureCheck.error}`
|
|
415
|
+
});
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (profile.banner) {
|
|
420
|
+
const bannerCheck = validateUrlSafety(profile.banner);
|
|
421
|
+
if (!bannerCheck.ok) {
|
|
422
|
+
sendJson(res, 400, {
|
|
423
|
+
ok: false,
|
|
424
|
+
error: `banner: ${bannerCheck.error}`
|
|
425
|
+
});
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (profile.website) {
|
|
430
|
+
const websiteCheck = validateUrlSafety(profile.website);
|
|
431
|
+
if (!websiteCheck.ok) {
|
|
432
|
+
sendJson(res, 400, {
|
|
433
|
+
ok: false,
|
|
434
|
+
error: `website: ${websiteCheck.error}`
|
|
435
|
+
});
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const mergedProfile = {
|
|
440
|
+
...ctx.getConfigProfile(accountId) ?? {},
|
|
441
|
+
...profile
|
|
442
|
+
};
|
|
443
|
+
try {
|
|
444
|
+
const result = await withPublishLock(accountId, async () => {
|
|
445
|
+
return await publishNostrProfile(accountId, mergedProfile);
|
|
446
|
+
});
|
|
447
|
+
if (result.successes.length > 0) {
|
|
448
|
+
await ctx.updateConfigProfile(accountId, mergedProfile);
|
|
449
|
+
ctx.log?.info(`[${accountId}] Profile published to ${result.successes.length} relay(s)`);
|
|
450
|
+
} else ctx.log?.warn(`[${accountId}] Profile publish failed on all relays`);
|
|
451
|
+
sendJson(res, 200, {
|
|
452
|
+
ok: true,
|
|
453
|
+
eventId: result.eventId,
|
|
454
|
+
createdAt: result.createdAt,
|
|
455
|
+
successes: result.successes,
|
|
456
|
+
failures: result.failures,
|
|
457
|
+
persisted: result.successes.length > 0
|
|
458
|
+
});
|
|
459
|
+
} catch (err) {
|
|
460
|
+
ctx.log?.error(`[${accountId}] Profile publish error: ${String(err)}`);
|
|
461
|
+
sendJson(res, 500, {
|
|
462
|
+
ok: false,
|
|
463
|
+
error: `Publish failed: ${String(err)}`
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
async function handleImportProfile(accountId, ctx, req, res) {
|
|
469
|
+
if (!enforceGatewayMutationScope(ctx, accountId, res)) return true;
|
|
470
|
+
if (!enforceLoopbackMutationGuards(ctx, req, res)) return true;
|
|
471
|
+
const accountInfo = ctx.getAccountInfo(accountId);
|
|
472
|
+
if (!accountInfo) {
|
|
473
|
+
sendJson(res, 404, {
|
|
474
|
+
ok: false,
|
|
475
|
+
error: `Account not found: ${accountId}`
|
|
476
|
+
});
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
const { pubkey, relays } = accountInfo;
|
|
480
|
+
if (!pubkey) {
|
|
481
|
+
sendJson(res, 400, {
|
|
482
|
+
ok: false,
|
|
483
|
+
error: "Account has no public key configured"
|
|
484
|
+
});
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
let autoMerge = false;
|
|
488
|
+
try {
|
|
489
|
+
const body = await readJsonBody(req);
|
|
490
|
+
if (typeof body === "object" && body !== null) autoMerge = body.autoMerge === true;
|
|
491
|
+
} catch {}
|
|
492
|
+
ctx.log?.info(`[${accountId}] Importing profile for ${pubkey.slice(0, 8)}...`);
|
|
493
|
+
const result = await importProfileFromRelays({
|
|
494
|
+
pubkey,
|
|
495
|
+
relays,
|
|
496
|
+
timeoutMs: 1e4
|
|
497
|
+
});
|
|
498
|
+
if (!result.ok) {
|
|
499
|
+
sendJson(res, 200, {
|
|
500
|
+
ok: false,
|
|
501
|
+
error: result.error,
|
|
502
|
+
relaysQueried: result.relaysQueried
|
|
503
|
+
});
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
if (autoMerge && result.profile) {
|
|
507
|
+
const merged = mergeProfiles(ctx.getConfigProfile(accountId), result.profile);
|
|
508
|
+
await ctx.updateConfigProfile(accountId, merged);
|
|
509
|
+
ctx.log?.info(`[${accountId}] Profile imported and merged`);
|
|
510
|
+
sendJson(res, 200, {
|
|
511
|
+
ok: true,
|
|
512
|
+
imported: result.profile,
|
|
513
|
+
merged,
|
|
514
|
+
saved: true,
|
|
515
|
+
event: result.event,
|
|
516
|
+
sourceRelay: result.sourceRelay,
|
|
517
|
+
relaysQueried: result.relaysQueried
|
|
518
|
+
});
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
sendJson(res, 200, {
|
|
522
|
+
ok: true,
|
|
523
|
+
imported: result.profile,
|
|
524
|
+
saved: false,
|
|
525
|
+
event: result.event,
|
|
526
|
+
sourceRelay: result.sourceRelay,
|
|
527
|
+
relaysQueried: result.relaysQueried
|
|
528
|
+
});
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
//#endregion
|
|
532
|
+
export { createNostrProfileHttpHandler, getNostrRuntime, getPluginRuntimeGatewayRequestScope, nostrPlugin, resolveNostrAccount, setNostrRuntime };
|