@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.
Files changed (48) hide show
  1. package/README.md +6 -0
  2. package/api.ts +10 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/index.ts +60 -36
  5. package/openclaw.plugin.json +190 -1
  6. package/package.json +41 -9
  7. package/runtime-api.ts +6 -0
  8. package/setup-api.ts +1 -0
  9. package/setup-entry.ts +9 -0
  10. package/setup-plugin-api.ts +3 -0
  11. package/src/channel-api.ts +15 -0
  12. package/src/channel.inbound.test.ts +176 -0
  13. package/src/channel.outbound.test.ts +89 -49
  14. package/src/channel.setup.ts +231 -0
  15. package/src/channel.test.ts +439 -71
  16. package/src/channel.ts +146 -284
  17. package/src/config-schema.ts +18 -12
  18. package/src/default-relays.ts +1 -0
  19. package/src/gateway.ts +302 -0
  20. package/src/inbound-direct-dm-runtime.ts +1 -0
  21. package/src/metrics.ts +6 -6
  22. package/src/nostr-bus.fuzz.test.ts +74 -247
  23. package/src/nostr-bus.inbound.test.ts +526 -0
  24. package/src/nostr-bus.integration.test.ts +88 -64
  25. package/src/nostr-bus.test.ts +22 -31
  26. package/src/nostr-bus.ts +206 -136
  27. package/src/nostr-key-utils.ts +94 -0
  28. package/src/nostr-profile-core.ts +134 -0
  29. package/src/nostr-profile-http-runtime.ts +6 -0
  30. package/src/nostr-profile-http.test.ts +310 -192
  31. package/src/nostr-profile-http.ts +51 -36
  32. package/src/nostr-profile-import.ts +3 -3
  33. package/src/nostr-profile-url-safety.ts +21 -0
  34. package/src/nostr-profile.fuzz.test.ts +7 -57
  35. package/src/nostr-profile.test.ts +16 -14
  36. package/src/nostr-profile.ts +13 -146
  37. package/src/nostr-state-store.test.ts +106 -2
  38. package/src/nostr-state-store.ts +46 -49
  39. package/src/runtime.ts +6 -3
  40. package/src/seen-tracker.ts +1 -1
  41. package/src/session-route.ts +25 -0
  42. package/src/setup-surface.ts +265 -0
  43. package/src/test-fixtures.ts +45 -0
  44. package/src/types.ts +26 -25
  45. package/test-api.ts +1 -0
  46. package/tsconfig.json +16 -0
  47. package/CHANGELOG.md +0 -110
  48. 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 { describe, it, expect, vi, beforeEach } from "vitest";
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", ...(opts?.headers ?? {}) };
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
- function createMockResponse(): ServerResponse & {
110
+ type MockResponse = {
67
111
  _getData: () => string;
68
112
  _getStatusCode: () => number;
69
- } {
70
- const res = new ServerResponse({} as IncomingMessage);
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 += String(chunk);
127
+ data += responseChunkText(chunk);
77
128
  return true;
78
129
  };
79
130
 
80
131
  res.end = function (chunk?: unknown) {
81
132
  if (chunk) {
82
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
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
- (res as unknown as { _getData: () => string })._getData = () => data;
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: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
107
- relays: ["wss://relay.damus.io"],
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: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
203
+ pubkey: TEST_HEX_PUBLIC_KEY,
128
204
  created_at: 1234567890,
129
205
  },
130
- relaysQueried: ["wss://relay.damus.io"],
131
- sourceRelay: "wss://relay.damus.io",
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 ctx = createMockContext();
148
- const handler = createNostrProfileHttpHandler(ctx);
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 ctx = createMockContext();
159
- const handler = createNostrProfileHttpHandler(ctx);
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 ctx = createMockContext();
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 handler(req, res);
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 ctx = createMockContext({
185
- getConfigProfile: vi.fn().mockReturnValue({
186
- name: "testuser",
187
- displayName: "Test User",
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: { "wss://relay.damus.io": "ok" },
283
+ lastPublishResults: { [TEST_PROFILE_RELAY_URL]: "ok" },
198
284
  });
199
285
 
200
- await handler(req, res);
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
- async function expectPrivatePictureRejected(pictureUrl: string) {
212
- const ctx = createMockContext();
213
- const handler = createNostrProfileHttpHandler(ctx);
214
- const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
215
- name: "hacker",
216
- picture: pictureUrl,
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
- const res = createMockResponse();
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 = createMockContext();
230
- const handler = createNostrProfileHttpHandler(ctx);
231
- const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
232
- name: "satoshi",
233
- displayName: "Satoshi Nakamoto",
234
- about: "Creator of Bitcoin",
235
- });
236
- const res = createMockResponse();
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
- vi.mocked(publishNostrProfile).mockResolvedValue({
239
- eventId: "event123",
240
- createdAt: 1234567890,
241
- successes: ["wss://relay.damus.io"],
242
- failures: [],
243
- });
340
+ mockPublishSuccess();
244
341
 
245
- await handler(req, res);
342
+ await run();
246
343
 
247
- expect(res._getStatusCode()).toBe(200);
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("wss://relay.damus.io");
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 ctx = createMockContext();
258
- const handler = createNostrProfileHttpHandler(ctx);
259
- const req = createMockRequest(
260
- "PUT",
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 handler(req, res);
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 ctx = createMockContext();
273
- const handler = createNostrProfileHttpHandler(ctx);
274
- const req = createMockRequest(
275
- "PUT",
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 handler(req, res);
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 ctx = createMockContext();
288
- const handler = createNostrProfileHttpHandler(ctx);
289
- const req = createMockRequest(
290
- "PUT",
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 handler(req, res);
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 ctx = createMockContext();
303
- const handler = createNostrProfileHttpHandler(ctx);
304
- const req = createMockRequest(
305
- "PUT",
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 handler(req, res);
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 ctx = createMockContext();
326
- const handler = createNostrProfileHttpHandler(ctx);
327
- const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
328
- name: "test",
329
- picture: "http://example.com/pic.jpg",
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 handler(req, res);
427
+ await run();
334
428
 
335
- expect(res._getStatusCode()).toBe(400);
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 = createMockContext();
346
- const handler = createNostrProfileHttpHandler(ctx);
347
- const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
348
- name: "test",
349
- });
350
- const res = createMockResponse();
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: "wss://relay.damus.io", error: "timeout" }],
451
+ failures: [{ relay: TEST_PROFILE_RELAY_URL, error: "timeout" }],
357
452
  });
358
453
 
359
- await handler(req, res);
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
- const ctx = createMockContext();
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 req = createMockRequest("PUT", "/api/channels/nostr/rate-test/profile", {
381
- name: `user${i}`,
382
- });
383
- const res = createMockResponse();
384
- await handler(req, res);
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
- expect(res._getStatusCode()).toBe(200);
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 ctx = createMockContext();
419
- const handler = createNostrProfileHttpHandler(ctx);
420
- const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
421
- const res = createMockResponse();
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 handler(req, res);
524
+ await run();
426
525
 
427
- expect(res._getStatusCode()).toBe(200);
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 ctx = createMockContext();
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
- { remoteAddress: "203.0.113.10" },
534
+ {
535
+ body: {},
536
+ req: { remoteAddress: "203.0.113.10" },
537
+ },
442
538
  );
443
- const res = createMockResponse();
444
539
 
445
- await handler(req, res);
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 ctx = createMockContext();
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
- { headers: { origin: "https://evil.example" } },
548
+ {
549
+ body: {},
550
+ req: { headers: { origin: "https://evil.example" } },
551
+ },
457
552
  );
458
- const res = createMockResponse();
459
553
 
460
- await handler(req, res);
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 ctx = createMockContext();
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
- { headers: { "x-real-ip": "198.51.100.55" } },
562
+ {
563
+ body: {},
564
+ req: { headers: { "x-real-ip": "198.51.100.55" } },
565
+ },
472
566
  );
473
- const res = createMockResponse();
474
567
 
475
- await handler(req, res);
568
+ await run();
476
569
  expect(res._getStatusCode()).toBe(403);
477
570
  });
478
571
 
479
- it("auto-merges when requested", async () => {
480
- const ctx = createMockContext({
481
- getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
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
- const handler = createNostrProfileHttpHandler(ctx);
484
- const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {
485
- autoMerge: true,
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
- const res = createMockResponse();
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 handler(req, res);
606
+ await run();
492
607
 
493
- expect(res._getStatusCode()).toBe(200);
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 ctx = createMockContext({
501
- getAccountInfo: vi.fn().mockReturnValue(null),
502
- });
503
- const handler = createNostrProfileHttpHandler(ctx);
504
- const req = createMockRequest("POST", "/api/channels/nostr/unknown/profile/import", {});
505
- const res = createMockResponse();
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 handler(req, res);
625
+ await run();
508
626
 
509
627
  expect(res._getStatusCode()).toBe(404);
510
628
  const data = JSON.parse(res._getData());