@openclaw/nostr 2026.5.2 → 2026.5.3-beta.2
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
|
@@ -1,594 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Nostr Profile HTTP Handler
|
|
3
|
-
*
|
|
4
|
-
* Handles HTTP requests for profile management:
|
|
5
|
-
* - PUT /api/channels/nostr/:accountId/profile - Update and publish profile
|
|
6
|
-
* - POST /api/channels/nostr/:accountId/profile/import - Import from relays
|
|
7
|
-
* - GET /api/channels/nostr/:accountId/profile - Get current profile state
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
11
|
-
import { z } from "openclaw/plugin-sdk/zod";
|
|
12
|
-
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
|
13
|
-
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
|
|
14
|
-
import {
|
|
15
|
-
createFixedWindowRateLimiter,
|
|
16
|
-
getPluginRuntimeGatewayRequestScope,
|
|
17
|
-
readJsonBodyWithLimit,
|
|
18
|
-
requestBodyErrorToText,
|
|
19
|
-
} from "./nostr-profile-http-runtime.js";
|
|
20
|
-
import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
|
|
21
|
-
import { validateUrlSafety } from "./nostr-profile-url-safety.js";
|
|
22
|
-
|
|
23
|
-
// ============================================================================
|
|
24
|
-
// Types
|
|
25
|
-
// ============================================================================
|
|
26
|
-
|
|
27
|
-
function readStringValue(value: unknown): string | undefined {
|
|
28
|
-
return typeof value === "string" ? value : undefined;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function normalizeOptionalLowercaseString(value: unknown): string | undefined {
|
|
32
|
-
if (typeof value !== "string") {
|
|
33
|
-
return undefined;
|
|
34
|
-
}
|
|
35
|
-
const trimmed = value.trim();
|
|
36
|
-
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
|
40
|
-
return normalizeOptionalLowercaseString(value) ?? "";
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface NostrProfileHttpContext {
|
|
44
|
-
/** Get current profile from config */
|
|
45
|
-
getConfigProfile: (accountId: string) => NostrProfile | undefined;
|
|
46
|
-
/** Update profile in config (after successful publish) */
|
|
47
|
-
updateConfigProfile: (accountId: string, profile: NostrProfile) => Promise<void>;
|
|
48
|
-
/** Get account's public key and relays */
|
|
49
|
-
getAccountInfo: (accountId: string) => { pubkey: string; relays: string[] } | null;
|
|
50
|
-
/** Logger */
|
|
51
|
-
log?: {
|
|
52
|
-
info: (msg: string) => void;
|
|
53
|
-
warn: (msg: string) => void;
|
|
54
|
-
error: (msg: string) => void;
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ============================================================================
|
|
59
|
-
// Rate Limiting
|
|
60
|
-
// ============================================================================
|
|
61
|
-
|
|
62
|
-
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
|
|
63
|
-
const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute
|
|
64
|
-
const RATE_LIMIT_MAX_TRACKED_KEYS = 2_048;
|
|
65
|
-
const profileRateLimiter = createFixedWindowRateLimiter({
|
|
66
|
-
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
67
|
-
maxRequests: RATE_LIMIT_MAX_REQUESTS,
|
|
68
|
-
maxTrackedKeys: RATE_LIMIT_MAX_TRACKED_KEYS,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
export function clearNostrProfileRateLimitStateForTest(): void {
|
|
72
|
-
profileRateLimiter.clear();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function getNostrProfileRateLimitStateSizeForTest(): number {
|
|
76
|
-
return profileRateLimiter.size();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function isNostrProfileRateLimitedForTest(accountId: string, nowMs: number): boolean {
|
|
80
|
-
return profileRateLimiter.isRateLimited(accountId, nowMs);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function checkRateLimit(accountId: string): boolean {
|
|
84
|
-
return !profileRateLimiter.isRateLimited(accountId);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ============================================================================
|
|
88
|
-
// Mutex for Concurrent Publish Prevention
|
|
89
|
-
// ============================================================================
|
|
90
|
-
|
|
91
|
-
const publishLocks = new Map<string, Promise<void>>();
|
|
92
|
-
|
|
93
|
-
async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Promise<T> {
|
|
94
|
-
// Atomic mutex using promise chaining - prevents TOCTOU race condition
|
|
95
|
-
const prev = publishLocks.get(accountId) ?? Promise.resolve();
|
|
96
|
-
let resolve: () => void;
|
|
97
|
-
const next = new Promise<void>((r) => {
|
|
98
|
-
resolve = r;
|
|
99
|
-
});
|
|
100
|
-
// Atomically replace the lock before awaiting - any concurrent request
|
|
101
|
-
// will now wait on our `next` promise
|
|
102
|
-
publishLocks.set(accountId, next);
|
|
103
|
-
|
|
104
|
-
// Wait for previous operation to complete
|
|
105
|
-
await prev.catch(() => {});
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
return await fn();
|
|
109
|
-
} finally {
|
|
110
|
-
resolve!();
|
|
111
|
-
// Clean up if we're the last in chain
|
|
112
|
-
if (publishLocks.get(accountId) === next) {
|
|
113
|
-
publishLocks.delete(accountId);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ============================================================================
|
|
119
|
-
// Validation Schemas
|
|
120
|
-
// ============================================================================
|
|
121
|
-
|
|
122
|
-
// NIP-05 format: user@domain.com
|
|
123
|
-
const nip05FormatSchema = z
|
|
124
|
-
.string()
|
|
125
|
-
.regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid NIP-05 format (user@domain.com)")
|
|
126
|
-
.optional();
|
|
127
|
-
|
|
128
|
-
// LUD-16 Lightning address format: user@domain.com
|
|
129
|
-
const lud16FormatSchema = z
|
|
130
|
-
.string()
|
|
131
|
-
.regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid Lightning address format")
|
|
132
|
-
.optional();
|
|
133
|
-
|
|
134
|
-
// Extended profile schema with additional format validation
|
|
135
|
-
const ProfileUpdateSchema = NostrProfileSchema.extend({
|
|
136
|
-
nip05: nip05FormatSchema,
|
|
137
|
-
lud16: lud16FormatSchema,
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const PROFILE_MUTATION_SCOPE = "operator.admin";
|
|
141
|
-
|
|
142
|
-
// ============================================================================
|
|
143
|
-
// Request Helpers
|
|
144
|
-
// ============================================================================
|
|
145
|
-
|
|
146
|
-
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
147
|
-
res.statusCode = status;
|
|
148
|
-
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
149
|
-
res.end(JSON.stringify(body));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function readJsonBody(
|
|
153
|
-
req: IncomingMessage,
|
|
154
|
-
maxBytes = 64 * 1024,
|
|
155
|
-
timeoutMs = 30_000,
|
|
156
|
-
): Promise<unknown> {
|
|
157
|
-
const result = await readJsonBodyWithLimit(req, {
|
|
158
|
-
maxBytes,
|
|
159
|
-
timeoutMs,
|
|
160
|
-
emptyObjectOnEmpty: true,
|
|
161
|
-
});
|
|
162
|
-
if (result.ok) {
|
|
163
|
-
return result.value;
|
|
164
|
-
}
|
|
165
|
-
if (result.code === "PAYLOAD_TOO_LARGE") {
|
|
166
|
-
throw new Error("Request body too large");
|
|
167
|
-
}
|
|
168
|
-
if (result.code === "REQUEST_BODY_TIMEOUT") {
|
|
169
|
-
throw new Error(requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
|
|
170
|
-
}
|
|
171
|
-
if (result.code === "CONNECTION_CLOSED") {
|
|
172
|
-
throw new Error(requestBodyErrorToText("CONNECTION_CLOSED"));
|
|
173
|
-
}
|
|
174
|
-
throw new Error(result.code === "INVALID_JSON" ? "Invalid JSON" : result.error);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function parseAccountIdFromPath(pathname: string): string | null {
|
|
178
|
-
// Match: /api/channels/nostr/:accountId/profile
|
|
179
|
-
const match = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/);
|
|
180
|
-
return match?.[1] ?? null;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean {
|
|
184
|
-
if (!remoteAddress) {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const ipLower = normalizeLowercaseStringOrEmpty(remoteAddress).replace(/^\[|\]$/g, "");
|
|
189
|
-
|
|
190
|
-
// IPv6 loopback
|
|
191
|
-
if (ipLower === "::1") {
|
|
192
|
-
return true;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// IPv4 loopback (127.0.0.0/8)
|
|
196
|
-
if (ipLower === "127.0.0.1" || ipLower.startsWith("127.")) {
|
|
197
|
-
return true;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// IPv4-mapped IPv6
|
|
201
|
-
const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
202
|
-
if (v4Mapped) {
|
|
203
|
-
return isLoopbackRemoteAddress(v4Mapped[1]);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return false;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function isLoopbackOriginLike(value: string): boolean {
|
|
210
|
-
try {
|
|
211
|
-
const url = new URL(value);
|
|
212
|
-
const hostname = normalizeLowercaseStringOrEmpty(url.hostname);
|
|
213
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
214
|
-
} catch {
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
|
|
220
|
-
if (Array.isArray(value)) {
|
|
221
|
-
return value[0];
|
|
222
|
-
}
|
|
223
|
-
return readStringValue(value);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function normalizeIpCandidate(raw: string): string {
|
|
227
|
-
const unquoted = raw.trim().replace(/^"|"$/g, "");
|
|
228
|
-
const bracketedWithOptionalPort = unquoted.match(/^\[([^[\]]+)\](?::\d+)?$/);
|
|
229
|
-
if (bracketedWithOptionalPort) {
|
|
230
|
-
return bracketedWithOptionalPort[1] ?? "";
|
|
231
|
-
}
|
|
232
|
-
const ipv4WithPort = unquoted.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/);
|
|
233
|
-
if (ipv4WithPort) {
|
|
234
|
-
return ipv4WithPort[1] ?? "";
|
|
235
|
-
}
|
|
236
|
-
return unquoted;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function hasNonLoopbackForwardedClient(req: IncomingMessage): boolean {
|
|
240
|
-
const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]);
|
|
241
|
-
if (forwardedFor) {
|
|
242
|
-
for (const hop of forwardedFor.split(",")) {
|
|
243
|
-
const candidate = normalizeIpCandidate(hop);
|
|
244
|
-
if (!candidate) {
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
if (!isLoopbackRemoteAddress(candidate)) {
|
|
248
|
-
return true;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const realIp = firstHeaderValue(req.headers["x-real-ip"]);
|
|
254
|
-
if (realIp) {
|
|
255
|
-
const candidate = normalizeIpCandidate(realIp);
|
|
256
|
-
if (candidate && !isLoopbackRemoteAddress(candidate)) {
|
|
257
|
-
return true;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return false;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function enforceLoopbackMutationGuards(
|
|
265
|
-
ctx: NostrProfileHttpContext,
|
|
266
|
-
req: IncomingMessage,
|
|
267
|
-
res: ServerResponse,
|
|
268
|
-
): boolean {
|
|
269
|
-
// Mutation endpoints are local-control-plane only.
|
|
270
|
-
const remoteAddress = req.socket.remoteAddress;
|
|
271
|
-
if (!isLoopbackRemoteAddress(remoteAddress)) {
|
|
272
|
-
ctx.log?.warn?.(`Rejected mutation from non-loopback remoteAddress=${String(remoteAddress)}`);
|
|
273
|
-
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// If a proxy exposes client-origin headers showing a non-loopback client,
|
|
278
|
-
// treat this as a remote request and deny mutation.
|
|
279
|
-
if (hasNonLoopbackForwardedClient(req)) {
|
|
280
|
-
ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers");
|
|
281
|
-
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
282
|
-
return false;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const secFetchSite = normalizeOptionalLowercaseString(
|
|
286
|
-
firstHeaderValue(req.headers["sec-fetch-site"]),
|
|
287
|
-
);
|
|
288
|
-
if (secFetchSite === "cross-site") {
|
|
289
|
-
ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header");
|
|
290
|
-
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
291
|
-
return false;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// CSRF guard: browsers send Origin/Referer on cross-site requests.
|
|
295
|
-
const origin = firstHeaderValue(req.headers.origin);
|
|
296
|
-
if (typeof origin === "string" && !isLoopbackOriginLike(origin)) {
|
|
297
|
-
ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`);
|
|
298
|
-
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
299
|
-
return false;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const referer = firstHeaderValue(req.headers.referer ?? req.headers.referrer);
|
|
303
|
-
if (typeof referer === "string" && !isLoopbackOriginLike(referer)) {
|
|
304
|
-
ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`);
|
|
305
|
-
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
306
|
-
return false;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
return true;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function enforceGatewayMutationScope(
|
|
313
|
-
ctx: NostrProfileHttpContext,
|
|
314
|
-
accountId: string,
|
|
315
|
-
res: ServerResponse,
|
|
316
|
-
): boolean {
|
|
317
|
-
const runtimeScopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes;
|
|
318
|
-
const scopes = Array.isArray(runtimeScopes) ? runtimeScopes : [];
|
|
319
|
-
if (scopes.includes(PROFILE_MUTATION_SCOPE)) {
|
|
320
|
-
return true;
|
|
321
|
-
}
|
|
322
|
-
ctx.log?.warn?.(`[${accountId}] Rejected profile mutation missing ${PROFILE_MUTATION_SCOPE}`);
|
|
323
|
-
sendJson(res, 403, { ok: false, error: `missing scope: ${PROFILE_MUTATION_SCOPE}` });
|
|
324
|
-
return false;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// ============================================================================
|
|
328
|
-
// HTTP Handler
|
|
329
|
-
// ============================================================================
|
|
330
|
-
|
|
331
|
-
export function createNostrProfileHttpHandler(
|
|
332
|
-
ctx: NostrProfileHttpContext,
|
|
333
|
-
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
|
334
|
-
return async (req, res) => {
|
|
335
|
-
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
336
|
-
|
|
337
|
-
// Only handle /api/channels/nostr/:accountId/profile paths
|
|
338
|
-
if (!url.pathname.startsWith("/api/channels/nostr/")) {
|
|
339
|
-
return false;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const accountId = parseAccountIdFromPath(url.pathname);
|
|
343
|
-
if (!accountId) {
|
|
344
|
-
return false;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const isImport = url.pathname.endsWith("/profile/import");
|
|
348
|
-
const isProfilePath = url.pathname.endsWith("/profile") || isImport;
|
|
349
|
-
|
|
350
|
-
if (!isProfilePath) {
|
|
351
|
-
return false;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Handle different HTTP methods
|
|
355
|
-
try {
|
|
356
|
-
if (req.method === "GET" && !isImport) {
|
|
357
|
-
return await handleGetProfile(accountId, ctx, res);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
if (req.method === "PUT" && !isImport) {
|
|
361
|
-
return await handleUpdateProfile(accountId, ctx, req, res);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (req.method === "POST" && isImport) {
|
|
365
|
-
return await handleImportProfile(accountId, ctx, req, res);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Method not allowed
|
|
369
|
-
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
370
|
-
return true;
|
|
371
|
-
} catch (err) {
|
|
372
|
-
ctx.log?.error(`Profile HTTP error: ${String(err)}`);
|
|
373
|
-
sendJson(res, 500, { ok: false, error: "Internal server error" });
|
|
374
|
-
return true;
|
|
375
|
-
}
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// ============================================================================
|
|
380
|
-
// GET /api/channels/nostr/:accountId/profile
|
|
381
|
-
// ============================================================================
|
|
382
|
-
|
|
383
|
-
async function handleGetProfile(
|
|
384
|
-
accountId: string,
|
|
385
|
-
ctx: NostrProfileHttpContext,
|
|
386
|
-
res: ServerResponse,
|
|
387
|
-
): Promise<true> {
|
|
388
|
-
const configProfile = ctx.getConfigProfile(accountId);
|
|
389
|
-
const publishState = await getNostrProfileState(accountId);
|
|
390
|
-
|
|
391
|
-
sendJson(res, 200, {
|
|
392
|
-
ok: true,
|
|
393
|
-
profile: configProfile ?? null,
|
|
394
|
-
publishState: publishState ?? null,
|
|
395
|
-
});
|
|
396
|
-
return true;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// ============================================================================
|
|
400
|
-
// PUT /api/channels/nostr/:accountId/profile
|
|
401
|
-
// ============================================================================
|
|
402
|
-
|
|
403
|
-
async function handleUpdateProfile(
|
|
404
|
-
accountId: string,
|
|
405
|
-
ctx: NostrProfileHttpContext,
|
|
406
|
-
req: IncomingMessage,
|
|
407
|
-
res: ServerResponse,
|
|
408
|
-
): Promise<true> {
|
|
409
|
-
if (!enforceGatewayMutationScope(ctx, accountId, res)) {
|
|
410
|
-
return true;
|
|
411
|
-
}
|
|
412
|
-
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
|
413
|
-
return true;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Rate limiting
|
|
417
|
-
if (!checkRateLimit(accountId)) {
|
|
418
|
-
sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
|
|
419
|
-
return true;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Parse body
|
|
423
|
-
let body: unknown;
|
|
424
|
-
try {
|
|
425
|
-
body = await readJsonBody(req);
|
|
426
|
-
} catch (err) {
|
|
427
|
-
sendJson(res, 400, { ok: false, error: String(err) });
|
|
428
|
-
return true;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// Validate profile
|
|
432
|
-
const parseResult = ProfileUpdateSchema.safeParse(body);
|
|
433
|
-
if (!parseResult.success) {
|
|
434
|
-
const errors = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
|
|
435
|
-
sendJson(res, 400, { ok: false, error: "Validation failed", details: errors });
|
|
436
|
-
return true;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const profile = parseResult.data;
|
|
440
|
-
|
|
441
|
-
// SSRF check for picture URL
|
|
442
|
-
if (profile.picture) {
|
|
443
|
-
const pictureCheck = validateUrlSafety(profile.picture);
|
|
444
|
-
if (!pictureCheck.ok) {
|
|
445
|
-
sendJson(res, 400, { ok: false, error: `picture: ${pictureCheck.error}` });
|
|
446
|
-
return true;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// SSRF check for banner URL
|
|
451
|
-
if (profile.banner) {
|
|
452
|
-
const bannerCheck = validateUrlSafety(profile.banner);
|
|
453
|
-
if (!bannerCheck.ok) {
|
|
454
|
-
sendJson(res, 400, { ok: false, error: `banner: ${bannerCheck.error}` });
|
|
455
|
-
return true;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// SSRF check for website URL
|
|
460
|
-
if (profile.website) {
|
|
461
|
-
const websiteCheck = validateUrlSafety(profile.website);
|
|
462
|
-
if (!websiteCheck.ok) {
|
|
463
|
-
sendJson(res, 400, { ok: false, error: `website: ${websiteCheck.error}` });
|
|
464
|
-
return true;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Merge with existing profile to preserve unknown fields
|
|
469
|
-
const existingProfile = ctx.getConfigProfile(accountId) ?? {};
|
|
470
|
-
const mergedProfile: NostrProfile = {
|
|
471
|
-
...existingProfile,
|
|
472
|
-
...profile,
|
|
473
|
-
};
|
|
474
|
-
|
|
475
|
-
// Publish with mutex to prevent concurrent publishes
|
|
476
|
-
try {
|
|
477
|
-
const result = await withPublishLock(accountId, async () => {
|
|
478
|
-
return await publishNostrProfile(accountId, mergedProfile);
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
// Only persist if at least one relay succeeded
|
|
482
|
-
if (result.successes.length > 0) {
|
|
483
|
-
await ctx.updateConfigProfile(accountId, mergedProfile);
|
|
484
|
-
ctx.log?.info(`[${accountId}] Profile published to ${result.successes.length} relay(s)`);
|
|
485
|
-
} else {
|
|
486
|
-
ctx.log?.warn(`[${accountId}] Profile publish failed on all relays`);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
sendJson(res, 200, {
|
|
490
|
-
ok: true,
|
|
491
|
-
eventId: result.eventId,
|
|
492
|
-
createdAt: result.createdAt,
|
|
493
|
-
successes: result.successes,
|
|
494
|
-
failures: result.failures,
|
|
495
|
-
persisted: result.successes.length > 0,
|
|
496
|
-
});
|
|
497
|
-
} catch (err) {
|
|
498
|
-
ctx.log?.error(`[${accountId}] Profile publish error: ${String(err)}`);
|
|
499
|
-
sendJson(res, 500, { ok: false, error: `Publish failed: ${String(err)}` });
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
return true;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// ============================================================================
|
|
506
|
-
// POST /api/channels/nostr/:accountId/profile/import
|
|
507
|
-
// ============================================================================
|
|
508
|
-
|
|
509
|
-
async function handleImportProfile(
|
|
510
|
-
accountId: string,
|
|
511
|
-
ctx: NostrProfileHttpContext,
|
|
512
|
-
req: IncomingMessage,
|
|
513
|
-
res: ServerResponse,
|
|
514
|
-
): Promise<true> {
|
|
515
|
-
if (!enforceGatewayMutationScope(ctx, accountId, res)) {
|
|
516
|
-
return true;
|
|
517
|
-
}
|
|
518
|
-
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
|
519
|
-
return true;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Get account info
|
|
523
|
-
const accountInfo = ctx.getAccountInfo(accountId);
|
|
524
|
-
if (!accountInfo) {
|
|
525
|
-
sendJson(res, 404, { ok: false, error: `Account not found: ${accountId}` });
|
|
526
|
-
return true;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const { pubkey, relays } = accountInfo;
|
|
530
|
-
|
|
531
|
-
if (!pubkey) {
|
|
532
|
-
sendJson(res, 400, { ok: false, error: "Account has no public key configured" });
|
|
533
|
-
return true;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Parse options from body
|
|
537
|
-
let autoMerge = false;
|
|
538
|
-
try {
|
|
539
|
-
const body = await readJsonBody(req);
|
|
540
|
-
if (typeof body === "object" && body !== null) {
|
|
541
|
-
autoMerge = (body as { autoMerge?: boolean }).autoMerge === true;
|
|
542
|
-
}
|
|
543
|
-
} catch {
|
|
544
|
-
// Ignore body parse errors - use defaults
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
ctx.log?.info(`[${accountId}] Importing profile for ${pubkey.slice(0, 8)}...`);
|
|
548
|
-
|
|
549
|
-
// Import from relays
|
|
550
|
-
const result = await importProfileFromRelays({
|
|
551
|
-
pubkey,
|
|
552
|
-
relays,
|
|
553
|
-
timeoutMs: 10_000, // 10 seconds for import
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
if (!result.ok) {
|
|
557
|
-
sendJson(res, 200, {
|
|
558
|
-
ok: false,
|
|
559
|
-
error: result.error,
|
|
560
|
-
relaysQueried: result.relaysQueried,
|
|
561
|
-
});
|
|
562
|
-
return true;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// If autoMerge is requested, merge and save
|
|
566
|
-
if (autoMerge && result.profile) {
|
|
567
|
-
const localProfile = ctx.getConfigProfile(accountId);
|
|
568
|
-
const merged = mergeProfiles(localProfile, result.profile);
|
|
569
|
-
await ctx.updateConfigProfile(accountId, merged);
|
|
570
|
-
ctx.log?.info(`[${accountId}] Profile imported and merged`);
|
|
571
|
-
|
|
572
|
-
sendJson(res, 200, {
|
|
573
|
-
ok: true,
|
|
574
|
-
imported: result.profile,
|
|
575
|
-
merged,
|
|
576
|
-
saved: true,
|
|
577
|
-
event: result.event,
|
|
578
|
-
sourceRelay: result.sourceRelay,
|
|
579
|
-
relaysQueried: result.relaysQueried,
|
|
580
|
-
});
|
|
581
|
-
return true;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// Otherwise, just return the imported profile for review
|
|
585
|
-
sendJson(res, 200, {
|
|
586
|
-
ok: true,
|
|
587
|
-
imported: result.profile,
|
|
588
|
-
saved: false,
|
|
589
|
-
event: result.event,
|
|
590
|
-
sourceRelay: result.sourceRelay,
|
|
591
|
-
relaysQueried: result.relaysQueried,
|
|
592
|
-
});
|
|
593
|
-
return true;
|
|
594
|
-
}
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for Nostr Profile Import
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { describe, it, expect } from "vitest";
|
|
6
|
-
import type { NostrProfile } from "./config-schema.js";
|
|
7
|
-
import { mergeProfiles } from "./nostr-profile-import.js";
|
|
8
|
-
|
|
9
|
-
// Note: importProfileFromRelays requires real network calls or complex mocking
|
|
10
|
-
// of nostr-tools SimplePool, so we focus on unit testing mergeProfiles
|
|
11
|
-
|
|
12
|
-
describe("nostr-profile-import", () => {
|
|
13
|
-
describe("mergeProfiles", () => {
|
|
14
|
-
it("returns empty object when both are undefined", () => {
|
|
15
|
-
const result = mergeProfiles(undefined, undefined);
|
|
16
|
-
expect(result).toEqual({});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("returns imported when local is undefined", () => {
|
|
20
|
-
const imported: NostrProfile = {
|
|
21
|
-
name: "imported",
|
|
22
|
-
displayName: "Imported User",
|
|
23
|
-
about: "Bio from relay",
|
|
24
|
-
};
|
|
25
|
-
const result = mergeProfiles(undefined, imported);
|
|
26
|
-
expect(result).toEqual(imported);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("returns local when imported is undefined", () => {
|
|
30
|
-
const local: NostrProfile = {
|
|
31
|
-
name: "local",
|
|
32
|
-
displayName: "Local User",
|
|
33
|
-
};
|
|
34
|
-
const result = mergeProfiles(local, undefined);
|
|
35
|
-
expect(result).toEqual(local);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("prefers local values over imported", () => {
|
|
39
|
-
const local: NostrProfile = {
|
|
40
|
-
name: "localname",
|
|
41
|
-
about: "Local bio",
|
|
42
|
-
};
|
|
43
|
-
const imported: NostrProfile = {
|
|
44
|
-
name: "importedname",
|
|
45
|
-
displayName: "Imported Display",
|
|
46
|
-
about: "Imported bio",
|
|
47
|
-
picture: "https://example.com/pic.jpg",
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const result = mergeProfiles(local, imported);
|
|
51
|
-
|
|
52
|
-
expect(result.name).toBe("localname"); // local wins
|
|
53
|
-
expect(result.displayName).toBe("Imported Display"); // imported fills gap
|
|
54
|
-
expect(result.about).toBe("Local bio"); // local wins
|
|
55
|
-
expect(result.picture).toBe("https://example.com/pic.jpg"); // imported fills gap
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("fills all missing fields from imported", () => {
|
|
59
|
-
const local: NostrProfile = {
|
|
60
|
-
name: "myname",
|
|
61
|
-
};
|
|
62
|
-
const imported: NostrProfile = {
|
|
63
|
-
name: "theirname",
|
|
64
|
-
displayName: "Their Name",
|
|
65
|
-
about: "Their bio",
|
|
66
|
-
picture: "https://example.com/pic.jpg",
|
|
67
|
-
banner: "https://example.com/banner.jpg",
|
|
68
|
-
website: "https://example.com",
|
|
69
|
-
nip05: "user@example.com",
|
|
70
|
-
lud16: "user@getalby.com",
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const result = mergeProfiles(local, imported);
|
|
74
|
-
|
|
75
|
-
expect(result.name).toBe("myname");
|
|
76
|
-
expect(result.displayName).toBe("Their Name");
|
|
77
|
-
expect(result.about).toBe("Their bio");
|
|
78
|
-
expect(result.picture).toBe("https://example.com/pic.jpg");
|
|
79
|
-
expect(result.banner).toBe("https://example.com/banner.jpg");
|
|
80
|
-
expect(result.website).toBe("https://example.com");
|
|
81
|
-
expect(result.nip05).toBe("user@example.com");
|
|
82
|
-
expect(result.lud16).toBe("user@getalby.com");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("handles empty strings as falsy (prefers imported)", () => {
|
|
86
|
-
const local: NostrProfile = {
|
|
87
|
-
name: "",
|
|
88
|
-
displayName: "",
|
|
89
|
-
};
|
|
90
|
-
const imported: NostrProfile = {
|
|
91
|
-
name: "imported",
|
|
92
|
-
displayName: "Imported",
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const result = mergeProfiles(local, imported);
|
|
96
|
-
|
|
97
|
-
// Empty strings are still strings, so they "win" over imported
|
|
98
|
-
// This is JavaScript nullish coalescing behavior
|
|
99
|
-
expect(result.name).toBe("");
|
|
100
|
-
expect(result.displayName).toBe("");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("handles null values in local (prefers imported)", () => {
|
|
104
|
-
const local: NostrProfile = {
|
|
105
|
-
name: undefined,
|
|
106
|
-
displayName: undefined,
|
|
107
|
-
};
|
|
108
|
-
const imported: NostrProfile = {
|
|
109
|
-
name: "imported",
|
|
110
|
-
displayName: "Imported",
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const result = mergeProfiles(local, imported);
|
|
114
|
-
|
|
115
|
-
expect(result.name).toBe("imported");
|
|
116
|
-
expect(result.displayName).toBe("Imported");
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
});
|