@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.
- package/README.md +142 -0
- package/api.ts +10 -0
- package/channel-plugin-api.ts +1 -0
- package/dist/api.js +522 -0
- package/dist/channel-CnPQxTzj.js +1467 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/config-schema-KoL8Et_9.js +63 -0
- package/dist/default-relays-DLwdWOTu.js +4 -0
- package/dist/inbound-direct-dm-runtime-CeYGU_Fo.js +2 -0
- package/dist/index.js +81 -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 +166 -0
- package/dist/setup-surface-DFlfVW6j.js +337 -0
- package/dist/test-api.js +2 -0
- package/index.ts +95 -0
- package/klaw.plugin.json +2 -185
- package/package.json +4 -4
- package/runtime-api.ts +6 -0
- package/setup-api.ts +1 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/channel-api.ts +11 -0
- package/src/channel.inbound.test.ts +187 -0
- package/src/channel.outbound.test.ts +163 -0
- package/src/channel.setup.ts +234 -0
- package/src/channel.test.ts +526 -0
- package/src/channel.ts +215 -0
- package/src/config-schema.ts +98 -0
- package/src/default-relays.ts +1 -0
- package/src/gateway.ts +321 -0
- package/src/inbound-direct-dm-runtime.ts +1 -0
- package/src/metrics.ts +458 -0
- package/src/nostr-bus.fuzz.test.ts +382 -0
- package/src/nostr-bus.inbound.test.ts +526 -0
- package/src/nostr-bus.integration.test.ts +477 -0
- package/src/nostr-bus.test.ts +231 -0
- package/src/nostr-bus.ts +789 -0
- package/src/nostr-key-utils.ts +94 -0
- package/src/nostr-profile-core.ts +134 -0
- package/src/nostr-profile-http-runtime.ts +6 -0
- package/src/nostr-profile-http.test.ts +632 -0
- package/src/nostr-profile-http.ts +583 -0
- package/src/nostr-profile-import.test.ts +119 -0
- package/src/nostr-profile-import.ts +262 -0
- package/src/nostr-profile-url-safety.ts +21 -0
- package/src/nostr-profile.fuzz.test.ts +430 -0
- package/src/nostr-profile.test.ts +415 -0
- package/src/nostr-profile.ts +144 -0
- package/src/nostr-state-store.test.ts +237 -0
- package/src/nostr-state-store.ts +206 -0
- package/src/runtime.ts +9 -0
- package/src/seen-tracker.ts +289 -0
- package/src/session-route.ts +25 -0
- package/src/setup-surface.ts +264 -0
- package/src/test-fixtures.ts +45 -0
- package/src/types.ts +117 -0
- package/test/setup.ts +5 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/api.js +0 -7
- package/channel-plugin-api.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/setup-api.js +0 -7
- package/setup-entry.js +0 -7
- package/setup-plugin-api.js +0 -7
- 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 };
|