@openclaw/nostr 2026.3.13 → 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 -283
  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 +276 -167
  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 -116
  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,7 +162,29 @@ function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrP
115
162
  };
116
163
  }
117
164
 
118
- function expectOkResponse(res: ReturnType<typeof createMockResponse>) {
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) {
119
188
  expect(res._getStatusCode()).toBe(200);
120
189
  const data = JSON.parse(res._getData());
121
190
  expect(data.ok).toBe(true);
@@ -131,14 +200,35 @@ function mockSuccessfulProfileImport() {
131
200
  },
132
201
  event: {
133
202
  id: "evt123",
134
- pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
203
+ pubkey: TEST_HEX_PUBLIC_KEY,
135
204
  created_at: 1234567890,
136
205
  },
137
- relaysQueried: ["wss://relay.damus.io"],
138
- sourceRelay: "wss://relay.damus.io",
206
+ relaysQueried: [TEST_PROFILE_RELAY_URL],
207
+ sourceRelay: TEST_PROFILE_RELAY_URL,
139
208
  });
140
209
  }
141
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
+
142
232
  // ============================================================================
143
233
  // Tests
144
234
  // ============================================================================
@@ -147,40 +237,30 @@ describe("nostr-profile-http", () => {
147
237
  beforeEach(() => {
148
238
  vi.clearAllMocks();
149
239
  clearNostrProfileRateLimitStateForTest();
240
+ setGatewayRuntimeScopes(["operator.admin"]);
150
241
  });
151
242
 
152
243
  describe("route matching", () => {
153
244
  it("returns false for non-nostr paths", async () => {
154
- const ctx = createMockContext();
155
- const handler = createNostrProfileHttpHandler(ctx);
156
- const req = createMockRequest("GET", "/api/channels/telegram/profile");
157
- const res = createMockResponse();
158
-
159
- const result = await handler(req, res);
245
+ const { run } = createProfileHttpHarness("GET", "/api/channels/telegram/profile");
246
+ const result = await run();
160
247
 
161
248
  expect(result).toBe(false);
162
249
  });
163
250
 
164
251
  it("returns false for paths without accountId", async () => {
165
- const ctx = createMockContext();
166
- const handler = createNostrProfileHttpHandler(ctx);
167
- const req = createMockRequest("GET", "/api/channels/nostr/");
168
- const res = createMockResponse();
169
-
170
- const result = await handler(req, res);
252
+ const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/");
253
+ const result = await run();
171
254
 
172
255
  expect(result).toBe(false);
173
256
  });
174
257
 
175
258
  it("handles /api/channels/nostr/:accountId/profile", async () => {
176
- const ctx = createMockContext();
177
- const handler = createNostrProfileHttpHandler(ctx);
178
- const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
179
- const res = createMockResponse();
259
+ const { run } = createProfileHttpHarness("GET", "/api/channels/nostr/default/profile");
180
260
 
181
261
  vi.mocked(getNostrProfileState).mockResolvedValue(null);
182
262
 
183
- const result = await handler(req, res);
263
+ const result = await run();
184
264
 
185
265
  expect(result).toBe(true);
186
266
  });
@@ -188,23 +268,22 @@ describe("nostr-profile-http", () => {
188
268
 
189
269
  describe("GET /api/channels/nostr/:accountId/profile", () => {
190
270
  it("returns profile and publish state", async () => {
191
- const ctx = createMockContext({
192
- getConfigProfile: vi.fn().mockReturnValue({
193
- name: "testuser",
194
- displayName: "Test User",
195
- }),
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
+ },
196
278
  });
197
- const handler = createNostrProfileHttpHandler(ctx);
198
- const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
199
- const res = createMockResponse();
200
279
 
201
280
  vi.mocked(getNostrProfileState).mockResolvedValue({
202
281
  lastPublishedAt: 1234567890,
203
282
  lastPublishedEventId: "abc123",
204
- lastPublishResults: { "wss://relay.damus.io": "ok" },
283
+ lastPublishResults: { [TEST_PROFILE_RELAY_URL]: "ok" },
205
284
  });
206
285
 
207
- await handler(req, res);
286
+ await run();
208
287
 
209
288
  expect(res._getStatusCode()).toBe(200);
210
289
  const data = JSON.parse(res._getData());
@@ -219,7 +298,7 @@ describe("nostr-profile-http", () => {
219
298
  vi.mocked(publishNostrProfile).mockResolvedValue({
220
299
  eventId: "event123",
221
300
  createdAt: 1234567890,
222
- successes: ["wss://relay.damus.io"],
301
+ successes: [TEST_PROFILE_RELAY_URL],
223
302
  failures: [],
224
303
  });
225
304
  }
@@ -232,101 +311,103 @@ describe("nostr-profile-http", () => {
232
311
  }
233
312
 
234
313
  async function expectPrivatePictureRejected(pictureUrl: string) {
235
- const ctx = createMockContext();
236
- const handler = createNostrProfileHttpHandler(ctx);
237
- const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
238
- name: "hacker",
239
- picture: pictureUrl,
314
+ const { res, run } = createProfileHttpHarness("PUT", "/api/channels/nostr/default/profile", {
315
+ body: {
316
+ name: "hacker",
317
+ picture: pictureUrl,
318
+ },
240
319
  });
241
- const res = createMockResponse();
242
320
 
243
- await handler(req, res);
321
+ await run();
244
322
 
245
323
  const data = expectBadRequestResponse(res);
246
324
  expect(data.error).toContain("private");
247
325
  }
248
326
 
249
327
  it("validates profile and publishes", async () => {
250
- const ctx = createMockContext();
251
- const handler = createNostrProfileHttpHandler(ctx);
252
- const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
253
- name: "satoshi",
254
- displayName: "Satoshi Nakamoto",
255
- about: "Creator of Bitcoin",
256
- });
257
- 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
+ );
258
339
 
259
340
  mockPublishSuccess();
260
341
 
261
- await handler(req, res);
342
+ await run();
262
343
 
263
344
  const data = expectOkResponse(res);
264
345
  expect(data.eventId).toBe("event123");
265
- expect(data.successes).toContain("wss://relay.damus.io");
346
+ expect(data.successes).toContain(TEST_PROFILE_RELAY_URL);
266
347
  expect(data.persisted).toBe(true);
267
348
  expect(ctx.updateConfigProfile).toHaveBeenCalled();
268
349
  });
269
350
 
270
351
  it("rejects profile mutation from non-loopback remote address", async () => {
271
- const ctx = createMockContext();
272
- const handler = createNostrProfileHttpHandler(ctx);
273
- const req = createMockRequest(
274
- "PUT",
275
- "/api/channels/nostr/default/profile",
276
- { name: "attacker" },
277
- { remoteAddress: "198.51.100.10" },
278
- );
279
- 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
+ });
280
356
 
281
- await handler(req, res);
357
+ await run();
282
358
  expect(res._getStatusCode()).toBe(403);
283
359
  });
284
360
 
285
361
  it("rejects cross-origin profile mutation attempts", async () => {
286
- const ctx = createMockContext();
287
- const handler = createNostrProfileHttpHandler(ctx);
288
- const req = createMockRequest(
289
- "PUT",
290
- "/api/channels/nostr/default/profile",
291
- { name: "attacker" },
292
- { headers: { origin: "https://evil.example" } },
293
- );
294
- 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
+ });
295
366
 
296
- await handler(req, res);
367
+ await run();
297
368
  expect(res._getStatusCode()).toBe(403);
298
369
  });
299
370
 
300
371
  it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
301
- const ctx = createMockContext();
302
- const handler = createNostrProfileHttpHandler(ctx);
303
- const req = createMockRequest(
304
- "PUT",
305
- "/api/channels/nostr/default/profile",
306
- { name: "attacker" },
307
- { headers: { "sec-fetch-site": "cross-site" } },
308
- );
309
- 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
+ });
310
376
 
311
- await handler(req, res);
377
+ await run();
312
378
  expect(res._getStatusCode()).toBe(403);
313
379
  });
314
380
 
315
381
  it("rejects profile mutation when forwarded client ip is non-loopback", async () => {
316
- const ctx = createMockContext();
317
- const handler = createNostrProfileHttpHandler(ctx);
318
- const req = createMockRequest(
319
- "PUT",
320
- "/api/channels/nostr/default/profile",
321
- { name: "attacker" },
322
- { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
323
- );
324
- 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
+ });
325
386
 
326
- await handler(req, res);
387
+ await run();
327
388
  expect(res._getStatusCode()).toBe(403);
328
389
  });
329
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
+
330
411
  it("rejects private IP in picture URL (SSRF protection)", async () => {
331
412
  await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
332
413
  });
@@ -336,15 +417,14 @@ describe("nostr-profile-http", () => {
336
417
  });
337
418
 
338
419
  it("rejects non-https URLs", async () => {
339
- const ctx = createMockContext();
340
- const handler = createNostrProfileHttpHandler(ctx);
341
- const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
342
- name: "test",
343
- 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
+ },
344
425
  });
345
- const res = createMockResponse();
346
426
 
347
- await handler(req, res);
427
+ await run();
348
428
 
349
429
  const data = expectBadRequestResponse(res);
350
430
  // The schema validation catches non-https URLs before SSRF check
@@ -354,21 +434,24 @@ describe("nostr-profile-http", () => {
354
434
  });
355
435
 
356
436
  it("does not persist if all relays fail", async () => {
357
- const ctx = createMockContext();
358
- const handler = createNostrProfileHttpHandler(ctx);
359
- const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
360
- name: "test",
361
- });
362
- 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
+ );
363
446
 
364
447
  vi.mocked(publishNostrProfile).mockResolvedValue({
365
448
  eventId: "event123",
366
449
  createdAt: 1234567890,
367
450
  successes: [],
368
- failures: [{ relay: "wss://relay.damus.io", error: "timeout" }],
451
+ failures: [{ relay: TEST_PROFILE_RELAY_URL, error: "timeout" }],
369
452
  });
370
453
 
371
- await handler(req, res);
454
+ await run();
372
455
 
373
456
  expect(res._getStatusCode()).toBe(200);
374
457
  const data = JSON.parse(res._getData());
@@ -377,18 +460,20 @@ describe("nostr-profile-http", () => {
377
460
  });
378
461
 
379
462
  it("enforces rate limiting", async () => {
380
- const ctx = createMockContext();
381
- const handler = createNostrProfileHttpHandler(ctx);
382
-
383
463
  mockPublishSuccess();
384
464
 
385
465
  // Make 6 requests (limit is 5/min)
386
466
  for (let i = 0; i < 6; i++) {
387
- const req = createMockRequest("PUT", "/api/channels/nostr/rate-test/profile", {
388
- name: `user${i}`,
389
- });
390
- const res = createMockResponse();
391
- 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();
392
477
 
393
478
  if (i < 5) {
394
479
  expectOkResponse(res);
@@ -428,77 +513,97 @@ describe("nostr-profile-http", () => {
428
513
  }
429
514
 
430
515
  it("imports profile from relays", async () => {
431
- const ctx = createMockContext();
432
- const handler = createNostrProfileHttpHandler(ctx);
433
- const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
434
- const res = createMockResponse();
516
+ const { res, run } = createProfileHttpHarness(
517
+ "POST",
518
+ "/api/channels/nostr/default/profile/import",
519
+ { body: {} },
520
+ );
435
521
 
436
522
  mockSuccessfulProfileImport();
437
523
 
438
- await handler(req, res);
524
+ await run();
439
525
 
440
526
  const data = expectImportSuccessResponse(res);
441
527
  expect(data.saved).toBe(false); // autoMerge not requested
442
528
  });
443
529
 
444
530
  it("rejects import mutation from non-loopback remote address", async () => {
445
- const ctx = createMockContext();
446
- const handler = createNostrProfileHttpHandler(ctx);
447
- const req = createMockRequest(
531
+ const { res, run } = createProfileHttpHarness(
448
532
  "POST",
449
533
  "/api/channels/nostr/default/profile/import",
450
- {},
451
- { remoteAddress: "203.0.113.10" },
534
+ {
535
+ body: {},
536
+ req: { remoteAddress: "203.0.113.10" },
537
+ },
452
538
  );
453
- const res = createMockResponse();
454
539
 
455
- await handler(req, res);
540
+ await run();
456
541
  expect(res._getStatusCode()).toBe(403);
457
542
  });
458
543
 
459
544
  it("rejects cross-origin import mutation attempts", async () => {
460
- const ctx = createMockContext();
461
- const handler = createNostrProfileHttpHandler(ctx);
462
- const req = createMockRequest(
545
+ const { res, run } = createProfileHttpHarness(
463
546
  "POST",
464
547
  "/api/channels/nostr/default/profile/import",
465
- {},
466
- { headers: { origin: "https://evil.example" } },
548
+ {
549
+ body: {},
550
+ req: { headers: { origin: "https://evil.example" } },
551
+ },
467
552
  );
468
- const res = createMockResponse();
469
553
 
470
- await handler(req, res);
554
+ await run();
471
555
  expect(res._getStatusCode()).toBe(403);
472
556
  });
473
557
 
474
558
  it("rejects import mutation when x-real-ip is non-loopback", async () => {
475
- const ctx = createMockContext();
476
- const handler = createNostrProfileHttpHandler(ctx);
477
- const req = createMockRequest(
559
+ const { res, run } = createProfileHttpHarness(
478
560
  "POST",
479
561
  "/api/channels/nostr/default/profile/import",
480
- {},
481
- { headers: { "x-real-ip": "198.51.100.55" } },
562
+ {
563
+ body: {},
564
+ req: { headers: { "x-real-ip": "198.51.100.55" } },
565
+ },
482
566
  );
483
- const res = createMockResponse();
484
567
 
485
- await handler(req, res);
568
+ await run();
486
569
  expect(res._getStatusCode()).toBe(403);
487
570
  });
488
571
 
489
- it("auto-merges when requested", async () => {
490
- const ctx = createMockContext({
491
- 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(),
492
579
  });
493
- const handler = createNostrProfileHttpHandler(ctx);
494
- const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {
495
- 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(),
496
589
  });
497
- 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
+ );
498
603
 
499
604
  mockSuccessfulProfileImport();
500
605
 
501
- await handler(req, res);
606
+ await run();
502
607
 
503
608
  const data = expectImportSuccessResponse(res);
504
609
  expect(data.saved).toBe(true);
@@ -506,14 +611,18 @@ describe("nostr-profile-http", () => {
506
611
  });
507
612
 
508
613
  it("returns error when account not found", async () => {
509
- const ctx = createMockContext({
510
- getAccountInfo: vi.fn().mockReturnValue(null),
511
- });
512
- const handler = createNostrProfileHttpHandler(ctx);
513
- const req = createMockRequest("POST", "/api/channels/nostr/unknown/profile/import", {});
514
- 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
+ );
515
624
 
516
- await handler(req, res);
625
+ await run();
517
626
 
518
627
  expect(res._getStatusCode()).toBe(404);
519
628
  const data = JSON.parse(res._getData());