@kodelyth/nostr 2026.5.39 → 2026.5.42

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