@openclaw/nostr 2026.3.12 → 2026.5.1-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/README.md +6 -0
- package/api.ts +10 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +60 -36
- package/openclaw.plugin.json +190 -1
- package/package.json +41 -9
- 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 +15 -0
- package/src/channel.inbound.test.ts +176 -0
- package/src/channel.outbound.test.ts +89 -49
- package/src/channel.setup.ts +231 -0
- package/src/channel.test.ts +439 -71
- package/src/channel.ts +146 -284
- package/src/config-schema.ts +18 -12
- package/src/default-relays.ts +1 -0
- package/src/gateway.ts +302 -0
- package/src/inbound-direct-dm-runtime.ts +1 -0
- package/src/metrics.ts +6 -6
- package/src/nostr-bus.fuzz.test.ts +74 -247
- package/src/nostr-bus.inbound.test.ts +526 -0
- package/src/nostr-bus.integration.test.ts +88 -64
- package/src/nostr-bus.test.ts +22 -31
- package/src/nostr-bus.ts +206 -136
- 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 +310 -192
- package/src/nostr-profile-http.ts +51 -36
- package/src/nostr-profile-import.ts +3 -3
- package/src/nostr-profile-url-safety.ts +21 -0
- package/src/nostr-profile.fuzz.test.ts +7 -57
- package/src/nostr-profile.test.ts +16 -14
- package/src/nostr-profile.ts +13 -146
- package/src/nostr-state-store.test.ts +106 -2
- package/src/nostr-state-store.ts +46 -49
- package/src/runtime.ts +6 -3
- package/src/seen-tracker.ts +1 -1
- package/src/session-route.ts +25 -0
- package/src/setup-surface.ts +265 -0
- package/src/test-fixtures.ts +45 -0
- package/src/types.ts +26 -25
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -110
- package/src/types.test.ts +0 -175
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { IncomingMessage, ServerResponse } from "node:http";
|
|
6
6
|
import { Socket } from "node:net";
|
|
7
|
-
import {
|
|
7
|
+
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
8
|
import {
|
|
9
9
|
clearNostrProfileRateLimitStateForTest,
|
|
10
10
|
createNostrProfileHttpHandler,
|
|
@@ -13,6 +13,19 @@ import {
|
|
|
13
13
|
type NostrProfileHttpContext,
|
|
14
14
|
} from "./nostr-profile-http.js";
|
|
15
15
|
|
|
16
|
+
const runtimeScopeMock = vi.hoisted(() => vi.fn());
|
|
17
|
+
|
|
18
|
+
vi.mock("./nostr-profile-http-runtime.js", async () => {
|
|
19
|
+
const webhookIngress = await import("openclaw/plugin-sdk/webhook-ingress");
|
|
20
|
+
const requestGuards = await import("openclaw/plugin-sdk/webhook-request-guards");
|
|
21
|
+
return {
|
|
22
|
+
createFixedWindowRateLimiter: webhookIngress.createFixedWindowRateLimiter,
|
|
23
|
+
readJsonBodyWithLimit: requestGuards.readJsonBodyWithLimit,
|
|
24
|
+
requestBodyErrorToText: requestGuards.requestBodyErrorToText,
|
|
25
|
+
getPluginRuntimeGatewayRequestScope: runtimeScopeMock,
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
16
29
|
// Mock the channel exports
|
|
17
30
|
vi.mock("./channel.js", () => ({
|
|
18
31
|
publishNostrProfile: vi.fn(),
|
|
@@ -27,11 +40,42 @@ vi.mock("./nostr-profile-import.js", () => ({
|
|
|
27
40
|
|
|
28
41
|
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
|
29
42
|
import { importProfileFromRelays } from "./nostr-profile-import.js";
|
|
43
|
+
import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
|
|
30
44
|
|
|
31
45
|
// ============================================================================
|
|
32
46
|
// Test Helpers
|
|
33
47
|
// ============================================================================
|
|
34
48
|
|
|
49
|
+
const TEST_PROFILE_RELAY_URL = TEST_SETUP_RELAY_URLS[0];
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
runtimeScopeMock.mockReset();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function setGatewayRuntimeScopes(scopes: readonly string[] | undefined): void {
|
|
56
|
+
if (!scopes) {
|
|
57
|
+
runtimeScopeMock.mockReturnValue(undefined);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
runtimeScopeMock.mockReturnValue({
|
|
61
|
+
client: {
|
|
62
|
+
connect: {
|
|
63
|
+
scopes: [...scopes],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function responseChunkText(chunk: unknown): string {
|
|
70
|
+
if (typeof chunk === "string") {
|
|
71
|
+
return chunk;
|
|
72
|
+
}
|
|
73
|
+
if (Buffer.isBuffer(chunk)) {
|
|
74
|
+
return chunk.toString();
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
35
79
|
function createMockRequest(
|
|
36
80
|
method: string,
|
|
37
81
|
url: string,
|
|
@@ -46,7 +90,7 @@ function createMockRequest(
|
|
|
46
90
|
const req = new IncomingMessage(socket);
|
|
47
91
|
req.method = method;
|
|
48
92
|
req.url = url;
|
|
49
|
-
req.headers = { host: "localhost:3000", ...
|
|
93
|
+
req.headers = { host: "localhost:3000", ...opts?.headers };
|
|
50
94
|
|
|
51
95
|
if (body) {
|
|
52
96
|
const bodyStr = JSON.stringify(body);
|
|
@@ -63,24 +107,30 @@ function createMockRequest(
|
|
|
63
107
|
return req;
|
|
64
108
|
}
|
|
65
109
|
|
|
66
|
-
|
|
110
|
+
type MockResponse = {
|
|
67
111
|
_getData: () => string;
|
|
68
112
|
_getStatusCode: () => number;
|
|
69
|
-
|
|
70
|
-
|
|
113
|
+
write: (chunk: unknown) => boolean;
|
|
114
|
+
end: (chunk?: unknown) => MockResponse;
|
|
115
|
+
statusCode: number;
|
|
116
|
+
};
|
|
71
117
|
|
|
118
|
+
function createMockResponse(): MockResponse {
|
|
72
119
|
let data = "";
|
|
73
120
|
let statusCode = 200;
|
|
121
|
+
const res = Object.assign(new ServerResponse({} as IncomingMessage), {
|
|
122
|
+
_getData: () => data,
|
|
123
|
+
_getStatusCode: () => statusCode,
|
|
124
|
+
}) as MockResponse;
|
|
74
125
|
|
|
75
126
|
res.write = function (chunk: unknown) {
|
|
76
|
-
data +=
|
|
127
|
+
data += responseChunkText(chunk);
|
|
77
128
|
return true;
|
|
78
129
|
};
|
|
79
130
|
|
|
80
131
|
res.end = function (chunk?: unknown) {
|
|
81
132
|
if (chunk) {
|
|
82
|
-
|
|
83
|
-
data += String(chunk);
|
|
133
|
+
data += responseChunkText(chunk);
|
|
84
134
|
}
|
|
85
135
|
return this;
|
|
86
136
|
};
|
|
@@ -92,10 +142,7 @@ function createMockResponse(): ServerResponse & {
|
|
|
92
142
|
},
|
|
93
143
|
});
|
|
94
144
|
|
|
95
|
-
|
|
96
|
-
(res as unknown as { _getStatusCode: () => number })._getStatusCode = () => statusCode;
|
|
97
|
-
|
|
98
|
-
return res as ServerResponse & { _getData: () => string; _getStatusCode: () => number };
|
|
145
|
+
return res;
|
|
99
146
|
}
|
|
100
147
|
|
|
101
148
|
function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrProfileHttpContext {
|
|
@@ -103,8 +150,8 @@ function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrP
|
|
|
103
150
|
getConfigProfile: vi.fn().mockReturnValue(undefined),
|
|
104
151
|
updateConfigProfile: vi.fn().mockResolvedValue(undefined),
|
|
105
152
|
getAccountInfo: vi.fn().mockReturnValue({
|
|
106
|
-
pubkey:
|
|
107
|
-
relays: [
|
|
153
|
+
pubkey: TEST_HEX_PUBLIC_KEY,
|
|
154
|
+
relays: [TEST_PROFILE_RELAY_URL],
|
|
108
155
|
}),
|
|
109
156
|
log: {
|
|
110
157
|
info: vi.fn(),
|
|
@@ -115,6 +162,35 @@ function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrP
|
|
|
115
162
|
};
|
|
116
163
|
}
|
|
117
164
|
|
|
165
|
+
function createProfileHttpHarness(
|
|
166
|
+
method: string,
|
|
167
|
+
url: string,
|
|
168
|
+
options?: {
|
|
169
|
+
body?: unknown;
|
|
170
|
+
ctx?: Partial<NostrProfileHttpContext>;
|
|
171
|
+
req?: Parameters<typeof createMockRequest>[3];
|
|
172
|
+
},
|
|
173
|
+
) {
|
|
174
|
+
const ctx = createMockContext(options?.ctx);
|
|
175
|
+
const handler = createNostrProfileHttpHandler(ctx);
|
|
176
|
+
const req = createMockRequest(method, url, options?.body, options?.req);
|
|
177
|
+
const res = createMockResponse();
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
ctx,
|
|
181
|
+
req,
|
|
182
|
+
res,
|
|
183
|
+
run: () => handler(req, res as unknown as ServerResponse),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function expectOkResponse(res: MockResponse) {
|
|
188
|
+
expect(res._getStatusCode()).toBe(200);
|
|
189
|
+
const data = JSON.parse(res._getData());
|
|
190
|
+
expect(data.ok).toBe(true);
|
|
191
|
+
return data;
|
|
192
|
+
}
|
|
193
|
+
|
|
118
194
|
function mockSuccessfulProfileImport() {
|
|
119
195
|
vi.mocked(importProfileFromRelays).mockResolvedValue({
|
|
120
196
|
ok: true,
|
|
@@ -124,14 +200,35 @@ function mockSuccessfulProfileImport() {
|
|
|
124
200
|
},
|
|
125
201
|
event: {
|
|
126
202
|
id: "evt123",
|
|
127
|
-
pubkey:
|
|
203
|
+
pubkey: TEST_HEX_PUBLIC_KEY,
|
|
128
204
|
created_at: 1234567890,
|
|
129
205
|
},
|
|
130
|
-
relaysQueried: [
|
|
131
|
-
sourceRelay:
|
|
206
|
+
relaysQueried: [TEST_PROFILE_RELAY_URL],
|
|
207
|
+
sourceRelay: TEST_PROFILE_RELAY_URL,
|
|
132
208
|
});
|
|
133
209
|
}
|
|
134
210
|
|
|
211
|
+
async function expectAdminScopeRejected(params: {
|
|
212
|
+
scopes: readonly string[] | undefined;
|
|
213
|
+
method: string;
|
|
214
|
+
url: string;
|
|
215
|
+
body: unknown;
|
|
216
|
+
expectOperationNotCalled: () => void;
|
|
217
|
+
}) {
|
|
218
|
+
setGatewayRuntimeScopes(params.scopes);
|
|
219
|
+
const { ctx, res, run } = createProfileHttpHarness(params.method, params.url, {
|
|
220
|
+
body: params.body,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await run();
|
|
224
|
+
|
|
225
|
+
expect(res._getStatusCode()).toBe(403);
|
|
226
|
+
const data = JSON.parse(res._getData());
|
|
227
|
+
expect(data.error).toBe("missing scope: operator.admin");
|
|
228
|
+
params.expectOperationNotCalled();
|
|
229
|
+
expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
|
|
230
|
+
}
|
|
231
|
+
|
|
135
232
|
// ============================================================================
|
|
136
233
|
// Tests
|
|
137
234
|
// ============================================================================
|
|
@@ -140,40 +237,30 @@ describe("nostr-profile-http", () => {
|
|
|
140
237
|
beforeEach(() => {
|
|
141
238
|
vi.clearAllMocks();
|
|
142
239
|
clearNostrProfileRateLimitStateForTest();
|
|
240
|
+
setGatewayRuntimeScopes(["operator.admin"]);
|
|
143
241
|
});
|
|
144
242
|
|
|
145
243
|
describe("route matching", () => {
|
|
146
244
|
it("returns false for non-nostr paths", async () => {
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
const req = createMockRequest("GET", "/api/channels/telegram/profile");
|
|
150
|
-
const res = createMockResponse();
|
|
151
|
-
|
|
152
|
-
const result = await handler(req, res);
|
|
245
|
+
const { run } = createProfileHttpHarness("GET", "/api/channels/telegram/profile");
|
|
246
|
+
const result = await run();
|
|
153
247
|
|
|
154
248
|
expect(result).toBe(false);
|
|
155
249
|
});
|
|
156
250
|
|
|
157
251
|
it("returns false for paths without accountId", async () => {
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const req = createMockRequest("GET", "/api/channels/nostr/");
|
|
161
|
-
const res = createMockResponse();
|
|
162
|
-
|
|
163
|
-
const result = await handler(req, res);
|
|
252
|
+
const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/");
|
|
253
|
+
const result = await run();
|
|
164
254
|
|
|
165
255
|
expect(result).toBe(false);
|
|
166
256
|
});
|
|
167
257
|
|
|
168
258
|
it("handles /api/channels/nostr/:accountId/profile", async () => {
|
|
169
|
-
const
|
|
170
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
171
|
-
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
|
|
172
|
-
const res = createMockResponse();
|
|
259
|
+
const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile");
|
|
173
260
|
|
|
174
261
|
vi.mocked(getNostrProfileState).mockResolvedValue(null);
|
|
175
262
|
|
|
176
|
-
const result = await
|
|
263
|
+
const result = await run();
|
|
177
264
|
|
|
178
265
|
expect(result).toBe(true);
|
|
179
266
|
});
|
|
@@ -181,23 +268,22 @@ describe("nostr-profile-http", () => {
|
|
|
181
268
|
|
|
182
269
|
describe("GET /api/channels/nostr/:accountId/profile", () => {
|
|
183
270
|
it("returns profile and publish state", async () => {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
271
|
+
const { res, run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile", {
|
|
272
|
+
ctx: {
|
|
273
|
+
getConfigProfile: vi.fn().mockReturnValue({
|
|
274
|
+
name: "testuser",
|
|
275
|
+
displayName: "Test User",
|
|
276
|
+
}),
|
|
277
|
+
},
|
|
189
278
|
});
|
|
190
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
191
|
-
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
|
|
192
|
-
const res = createMockResponse();
|
|
193
279
|
|
|
194
280
|
vi.mocked(getNostrProfileState).mockResolvedValue({
|
|
195
281
|
lastPublishedAt: 1234567890,
|
|
196
282
|
lastPublishedEventId: "abc123",
|
|
197
|
-
lastPublishResults: {
|
|
283
|
+
lastPublishResults: { [TEST_PROFILE_RELAY_URL]: "ok" },
|
|
198
284
|
});
|
|
199
285
|
|
|
200
|
-
await
|
|
286
|
+
await run();
|
|
201
287
|
|
|
202
288
|
expect(res._getStatusCode()).toBe(200);
|
|
203
289
|
const data = JSON.parse(res._getData());
|
|
@@ -208,111 +294,120 @@ describe("nostr-profile-http", () => {
|
|
|
208
294
|
});
|
|
209
295
|
|
|
210
296
|
describe("PUT /api/channels/nostr/:accountId/profile", () => {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
297
|
+
function mockPublishSuccess() {
|
|
298
|
+
vi.mocked(publishNostrProfile).mockResolvedValue({
|
|
299
|
+
eventId: "event123",
|
|
300
|
+
createdAt: 1234567890,
|
|
301
|
+
successes: [TEST_PROFILE_RELAY_URL],
|
|
302
|
+
failures: [],
|
|
217
303
|
});
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
await handler(req, res);
|
|
304
|
+
}
|
|
221
305
|
|
|
306
|
+
function expectBadRequestResponse(res: ReturnType<typeof createMockResponse>) {
|
|
222
307
|
expect(res._getStatusCode()).toBe(400);
|
|
223
308
|
const data = JSON.parse(res._getData());
|
|
224
309
|
expect(data.ok).toBe(false);
|
|
310
|
+
return data;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function expectPrivatePictureRejected(pictureUrl: string) {
|
|
314
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
315
|
+
body: {
|
|
316
|
+
name: "hacker",
|
|
317
|
+
picture: pictureUrl,
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await run();
|
|
322
|
+
|
|
323
|
+
const data = expectBadRequestResponse(res);
|
|
225
324
|
expect(data.error).toContain("private");
|
|
226
325
|
}
|
|
227
326
|
|
|
228
327
|
it("validates profile and publishes", async () => {
|
|
229
|
-
const ctx =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
328
|
+
const { ctx, res, run } = createProfileHttpHarness(
|
|
329
|
+
"PUT",
|
|
330
|
+
"/api/channels/nostr/default/profile",
|
|
331
|
+
{
|
|
332
|
+
body: {
|
|
333
|
+
name: "satoshi",
|
|
334
|
+
displayName: "Satoshi Nakamoto",
|
|
335
|
+
about: "Creator of Bitcoin",
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
);
|
|
237
339
|
|
|
238
|
-
|
|
239
|
-
eventId: "event123",
|
|
240
|
-
createdAt: 1234567890,
|
|
241
|
-
successes: ["wss://relay.damus.io"],
|
|
242
|
-
failures: [],
|
|
243
|
-
});
|
|
340
|
+
mockPublishSuccess();
|
|
244
341
|
|
|
245
|
-
await
|
|
342
|
+
await run();
|
|
246
343
|
|
|
247
|
-
|
|
248
|
-
const data = JSON.parse(res._getData());
|
|
249
|
-
expect(data.ok).toBe(true);
|
|
344
|
+
const data = expectOkResponse(res);
|
|
250
345
|
expect(data.eventId).toBe("event123");
|
|
251
|
-
expect(data.successes).toContain(
|
|
346
|
+
expect(data.successes).toContain(TEST_PROFILE_RELAY_URL);
|
|
252
347
|
expect(data.persisted).toBe(true);
|
|
253
348
|
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
|
254
349
|
});
|
|
255
350
|
|
|
256
351
|
it("rejects profile mutation from non-loopback remote address", async () => {
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
"/api/channels/nostr/default/profile",
|
|
262
|
-
{ name: "attacker" },
|
|
263
|
-
{ remoteAddress: "198.51.100.10" },
|
|
264
|
-
);
|
|
265
|
-
const res = createMockResponse();
|
|
352
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
353
|
+
body: { name: "attacker" },
|
|
354
|
+
req: { remoteAddress: "198.51.100.10" },
|
|
355
|
+
});
|
|
266
356
|
|
|
267
|
-
await
|
|
357
|
+
await run();
|
|
268
358
|
expect(res._getStatusCode()).toBe(403);
|
|
269
359
|
});
|
|
270
360
|
|
|
271
361
|
it("rejects cross-origin profile mutation attempts", async () => {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
"/api/channels/nostr/default/profile",
|
|
277
|
-
{ name: "attacker" },
|
|
278
|
-
{ headers: { origin: "https://evil.example" } },
|
|
279
|
-
);
|
|
280
|
-
const res = createMockResponse();
|
|
362
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
363
|
+
body: { name: "attacker" },
|
|
364
|
+
req: { headers: { origin: "https://evil.example" } },
|
|
365
|
+
});
|
|
281
366
|
|
|
282
|
-
await
|
|
367
|
+
await run();
|
|
283
368
|
expect(res._getStatusCode()).toBe(403);
|
|
284
369
|
});
|
|
285
370
|
|
|
286
371
|
it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
"/api/channels/nostr/default/profile",
|
|
292
|
-
{ name: "attacker" },
|
|
293
|
-
{ headers: { "sec-fetch-site": "cross-site" } },
|
|
294
|
-
);
|
|
295
|
-
const res = createMockResponse();
|
|
372
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
373
|
+
body: { name: "attacker" },
|
|
374
|
+
req: { headers: { "sec-fetch-site": "cross-site" } },
|
|
375
|
+
});
|
|
296
376
|
|
|
297
|
-
await
|
|
377
|
+
await run();
|
|
298
378
|
expect(res._getStatusCode()).toBe(403);
|
|
299
379
|
});
|
|
300
380
|
|
|
301
381
|
it("rejects profile mutation when forwarded client ip is non-loopback", async () => {
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
"/api/channels/nostr/default/profile",
|
|
307
|
-
{ name: "attacker" },
|
|
308
|
-
{ headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
|
|
309
|
-
);
|
|
310
|
-
const res = createMockResponse();
|
|
382
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
383
|
+
body: { name: "attacker" },
|
|
384
|
+
req: { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
|
|
385
|
+
});
|
|
311
386
|
|
|
312
|
-
await
|
|
387
|
+
await run();
|
|
313
388
|
expect(res._getStatusCode()).toBe(403);
|
|
314
389
|
});
|
|
315
390
|
|
|
391
|
+
it("rejects profile mutation when gateway caller is missing operator.admin", async () => {
|
|
392
|
+
await expectAdminScopeRejected({
|
|
393
|
+
scopes: ["operator.read"],
|
|
394
|
+
method: "PUT",
|
|
395
|
+
url: "/api/channels/nostr/default/profile",
|
|
396
|
+
body: { name: "attacker" },
|
|
397
|
+
expectOperationNotCalled: () => expect(publishNostrProfile).not.toHaveBeenCalled(),
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("rejects profile mutation when gateway scope context is missing", async () => {
|
|
402
|
+
await expectAdminScopeRejected({
|
|
403
|
+
scopes: undefined,
|
|
404
|
+
method: "PUT",
|
|
405
|
+
url: "/api/channels/nostr/default/profile",
|
|
406
|
+
body: { name: "attacker" },
|
|
407
|
+
expectOperationNotCalled: () => expect(publishNostrProfile).not.toHaveBeenCalled(),
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
316
411
|
it("rejects private IP in picture URL (SSRF protection)", async () => {
|
|
317
412
|
await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
|
|
318
413
|
});
|
|
@@ -322,19 +417,16 @@ describe("nostr-profile-http", () => {
|
|
|
322
417
|
});
|
|
323
418
|
|
|
324
419
|
it("rejects non-https URLs", async () => {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
420
|
+
const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
|
|
421
|
+
body: {
|
|
422
|
+
name: "test",
|
|
423
|
+
picture: "http://example.com/pic.jpg",
|
|
424
|
+
},
|
|
330
425
|
});
|
|
331
|
-
const res = createMockResponse();
|
|
332
426
|
|
|
333
|
-
await
|
|
427
|
+
await run();
|
|
334
428
|
|
|
335
|
-
|
|
336
|
-
const data = JSON.parse(res._getData());
|
|
337
|
-
expect(data.ok).toBe(false);
|
|
429
|
+
const data = expectBadRequestResponse(res);
|
|
338
430
|
// The schema validation catches non-https URLs before SSRF check
|
|
339
431
|
expect(data.error).toBe("Validation failed");
|
|
340
432
|
expect(data.details).toBeDefined();
|
|
@@ -342,21 +434,24 @@ describe("nostr-profile-http", () => {
|
|
|
342
434
|
});
|
|
343
435
|
|
|
344
436
|
it("does not persist if all relays fail", async () => {
|
|
345
|
-
const ctx =
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
437
|
+
const { ctx, res, run } = createProfileHttpHarness(
|
|
438
|
+
"PUT",
|
|
439
|
+
"/api/channels/nostr/default/profile",
|
|
440
|
+
{
|
|
441
|
+
body: {
|
|
442
|
+
name: "test",
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
);
|
|
351
446
|
|
|
352
447
|
vi.mocked(publishNostrProfile).mockResolvedValue({
|
|
353
448
|
eventId: "event123",
|
|
354
449
|
createdAt: 1234567890,
|
|
355
450
|
successes: [],
|
|
356
|
-
failures: [{ relay:
|
|
451
|
+
failures: [{ relay: TEST_PROFILE_RELAY_URL, error: "timeout" }],
|
|
357
452
|
});
|
|
358
453
|
|
|
359
|
-
await
|
|
454
|
+
await run();
|
|
360
455
|
|
|
361
456
|
expect(res._getStatusCode()).toBe(200);
|
|
362
457
|
const data = JSON.parse(res._getData());
|
|
@@ -365,26 +460,23 @@ describe("nostr-profile-http", () => {
|
|
|
365
460
|
});
|
|
366
461
|
|
|
367
462
|
it("enforces rate limiting", async () => {
|
|
368
|
-
|
|
369
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
370
|
-
|
|
371
|
-
vi.mocked(publishNostrProfile).mockResolvedValue({
|
|
372
|
-
eventId: "event123",
|
|
373
|
-
createdAt: 1234567890,
|
|
374
|
-
successes: ["wss://relay.damus.io"],
|
|
375
|
-
failures: [],
|
|
376
|
-
});
|
|
463
|
+
mockPublishSuccess();
|
|
377
464
|
|
|
378
465
|
// Make 6 requests (limit is 5/min)
|
|
379
466
|
for (let i = 0; i < 6; i++) {
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
467
|
+
const { res, run } = createProfileHttpHarness(
|
|
468
|
+
"PUT",
|
|
469
|
+
"/api/channels/nostr/rate-test/profile",
|
|
470
|
+
{
|
|
471
|
+
body: {
|
|
472
|
+
name: `user${i}`,
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
);
|
|
476
|
+
await run();
|
|
385
477
|
|
|
386
478
|
if (i < 5) {
|
|
387
|
-
|
|
479
|
+
expectOkResponse(res);
|
|
388
480
|
} else {
|
|
389
481
|
expect(res._getStatusCode()).toBe(429);
|
|
390
482
|
const data = JSON.parse(res._getData());
|
|
@@ -414,97 +506,123 @@ describe("nostr-profile-http", () => {
|
|
|
414
506
|
});
|
|
415
507
|
|
|
416
508
|
describe("POST /api/channels/nostr/:accountId/profile/import", () => {
|
|
509
|
+
function expectImportSuccessResponse(res: ReturnType<typeof createMockResponse>) {
|
|
510
|
+
const data = expectOkResponse(res);
|
|
511
|
+
expect(data.imported.name).toBe("imported");
|
|
512
|
+
return data;
|
|
513
|
+
}
|
|
514
|
+
|
|
417
515
|
it("imports profile from relays", async () => {
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
516
|
+
const { res, run } = createProfileHttpHarness(
|
|
517
|
+
"POST",
|
|
518
|
+
"/api/channels/nostr/default/profile/import",
|
|
519
|
+
{ body: {} },
|
|
520
|
+
);
|
|
422
521
|
|
|
423
522
|
mockSuccessfulProfileImport();
|
|
424
523
|
|
|
425
|
-
await
|
|
524
|
+
await run();
|
|
426
525
|
|
|
427
|
-
|
|
428
|
-
const data = JSON.parse(res._getData());
|
|
429
|
-
expect(data.ok).toBe(true);
|
|
430
|
-
expect(data.imported.name).toBe("imported");
|
|
526
|
+
const data = expectImportSuccessResponse(res);
|
|
431
527
|
expect(data.saved).toBe(false); // autoMerge not requested
|
|
432
528
|
});
|
|
433
529
|
|
|
434
530
|
it("rejects import mutation from non-loopback remote address", async () => {
|
|
435
|
-
const
|
|
436
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
437
|
-
const req = createMockRequest(
|
|
531
|
+
const { res, run } = createProfileHttpHarness(
|
|
438
532
|
"POST",
|
|
439
533
|
"/api/channels/nostr/default/profile/import",
|
|
440
|
-
{
|
|
441
|
-
|
|
534
|
+
{
|
|
535
|
+
body: {},
|
|
536
|
+
req: { remoteAddress: "203.0.113.10" },
|
|
537
|
+
},
|
|
442
538
|
);
|
|
443
|
-
const res = createMockResponse();
|
|
444
539
|
|
|
445
|
-
await
|
|
540
|
+
await run();
|
|
446
541
|
expect(res._getStatusCode()).toBe(403);
|
|
447
542
|
});
|
|
448
543
|
|
|
449
544
|
it("rejects cross-origin import mutation attempts", async () => {
|
|
450
|
-
const
|
|
451
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
452
|
-
const req = createMockRequest(
|
|
545
|
+
const { res, run } = createProfileHttpHarness(
|
|
453
546
|
"POST",
|
|
454
547
|
"/api/channels/nostr/default/profile/import",
|
|
455
|
-
{
|
|
456
|
-
|
|
548
|
+
{
|
|
549
|
+
body: {},
|
|
550
|
+
req: { headers: { origin: "https://evil.example" } },
|
|
551
|
+
},
|
|
457
552
|
);
|
|
458
|
-
const res = createMockResponse();
|
|
459
553
|
|
|
460
|
-
await
|
|
554
|
+
await run();
|
|
461
555
|
expect(res._getStatusCode()).toBe(403);
|
|
462
556
|
});
|
|
463
557
|
|
|
464
558
|
it("rejects import mutation when x-real-ip is non-loopback", async () => {
|
|
465
|
-
const
|
|
466
|
-
const handler = createNostrProfileHttpHandler(ctx);
|
|
467
|
-
const req = createMockRequest(
|
|
559
|
+
const { res, run } = createProfileHttpHarness(
|
|
468
560
|
"POST",
|
|
469
561
|
"/api/channels/nostr/default/profile/import",
|
|
470
|
-
{
|
|
471
|
-
|
|
562
|
+
{
|
|
563
|
+
body: {},
|
|
564
|
+
req: { headers: { "x-real-ip": "198.51.100.55" } },
|
|
565
|
+
},
|
|
472
566
|
);
|
|
473
|
-
const res = createMockResponse();
|
|
474
567
|
|
|
475
|
-
await
|
|
568
|
+
await run();
|
|
476
569
|
expect(res._getStatusCode()).toBe(403);
|
|
477
570
|
});
|
|
478
571
|
|
|
479
|
-
it("
|
|
480
|
-
|
|
481
|
-
|
|
572
|
+
it("rejects profile import when gateway caller is missing operator.admin", async () => {
|
|
573
|
+
await expectAdminScopeRejected({
|
|
574
|
+
scopes: ["operator.read"],
|
|
575
|
+
method: "POST",
|
|
576
|
+
url: "/api/channels/nostr/default/profile/import",
|
|
577
|
+
body: { autoMerge: true },
|
|
578
|
+
expectOperationNotCalled: () => expect(importProfileFromRelays).not.toHaveBeenCalled(),
|
|
482
579
|
});
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("rejects profile import when gateway scope context is missing", async () => {
|
|
583
|
+
await expectAdminScopeRejected({
|
|
584
|
+
scopes: undefined,
|
|
585
|
+
method: "POST",
|
|
586
|
+
url: "/api/channels/nostr/default/profile/import",
|
|
587
|
+
body: { autoMerge: true },
|
|
588
|
+
expectOperationNotCalled: () => expect(importProfileFromRelays).not.toHaveBeenCalled(),
|
|
486
589
|
});
|
|
487
|
-
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("auto-merges when requested", async () => {
|
|
593
|
+
const { ctx, res, run } = createProfileHttpHarness(
|
|
594
|
+
"POST",
|
|
595
|
+
"/api/channels/nostr/default/profile/import",
|
|
596
|
+
{
|
|
597
|
+
body: { autoMerge: true },
|
|
598
|
+
ctx: {
|
|
599
|
+
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
);
|
|
488
603
|
|
|
489
604
|
mockSuccessfulProfileImport();
|
|
490
605
|
|
|
491
|
-
await
|
|
606
|
+
await run();
|
|
492
607
|
|
|
493
|
-
|
|
494
|
-
const data = JSON.parse(res._getData());
|
|
608
|
+
const data = expectImportSuccessResponse(res);
|
|
495
609
|
expect(data.saved).toBe(true);
|
|
496
610
|
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
|
497
611
|
});
|
|
498
612
|
|
|
499
613
|
it("returns error when account not found", async () => {
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
614
|
+
const { res, run } = createProfileHttpHarness(
|
|
615
|
+
"POST",
|
|
616
|
+
"/api/channels/nostr/unknown/profile/import",
|
|
617
|
+
{
|
|
618
|
+
body: {},
|
|
619
|
+
ctx: {
|
|
620
|
+
getAccountInfo: vi.fn().mockReturnValue(null),
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
);
|
|
506
624
|
|
|
507
|
-
await
|
|
625
|
+
await run();
|
|
508
626
|
|
|
509
627
|
expect(res._getStatusCode()).toBe(404);
|
|
510
628
|
const data = JSON.parse(res._getData());
|