@nkmc/gateway 0.1.0

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 (130) hide show
  1. package/dist/chunk-56RA53VS.js +37 -0
  2. package/dist/chunk-CZJ75YTV.js +969 -0
  3. package/dist/chunk-QGM4M3NI.js +37 -0
  4. package/dist/http.cjs +1772 -0
  5. package/dist/http.d.cts +49 -0
  6. package/dist/http.d.ts +49 -0
  7. package/dist/http.js +748 -0
  8. package/dist/index.cjs +2436 -0
  9. package/dist/index.d.cts +436 -0
  10. package/dist/index.d.ts +436 -0
  11. package/dist/index.js +1434 -0
  12. package/dist/proxy-ClPcDgsO.d.cts +283 -0
  13. package/dist/proxy-qpda1ANS.d.ts +283 -0
  14. package/dist/proxy.cjs +148 -0
  15. package/dist/proxy.d.cts +6 -0
  16. package/dist/proxy.d.ts +6 -0
  17. package/dist/proxy.js +90 -0
  18. package/dist/testing.cjs +865 -0
  19. package/dist/testing.d.cts +12 -0
  20. package/dist/testing.d.ts +12 -0
  21. package/dist/testing.js +831 -0
  22. package/dist/tunnels-BviBEaih.d.cts +12 -0
  23. package/dist/tunnels-DFHNgmN7.d.ts +12 -0
  24. package/dist/types-C6JC9oTm.d.cts +21 -0
  25. package/dist/types-C6JC9oTm.d.ts +21 -0
  26. package/package.json +47 -0
  27. package/src/__tests__/sqlite-integration.test.ts +384 -0
  28. package/src/credential/d1-vault.ts +134 -0
  29. package/src/credential/memory-vault.ts +50 -0
  30. package/src/credential/types.ts +16 -0
  31. package/src/d1/__tests__/sqlite-adapter.test.ts +75 -0
  32. package/src/d1/sqlite-adapter.ts +59 -0
  33. package/src/d1/types.ts +22 -0
  34. package/src/federation/__tests__/d1-peer-store.test.ts +218 -0
  35. package/src/federation/__tests__/peer-client.test.ts +205 -0
  36. package/src/federation/__tests__/peer-store.test.ts +114 -0
  37. package/src/federation/d1-peer-store.ts +164 -0
  38. package/src/federation/peer-backend.ts +60 -0
  39. package/src/federation/peer-client.ts +122 -0
  40. package/src/federation/peer-store.ts +45 -0
  41. package/src/federation/types.ts +39 -0
  42. package/src/http/app.ts +152 -0
  43. package/src/http/lib/dns.ts +30 -0
  44. package/src/http/middleware/admin-auth.ts +18 -0
  45. package/src/http/middleware/agent-auth.ts +27 -0
  46. package/src/http/middleware/publish-auth.ts +39 -0
  47. package/src/http/routes/__tests__/federation.test.ts +364 -0
  48. package/src/http/routes/__tests__/peers.test.ts +290 -0
  49. package/src/http/routes/__tests__/proxy.test.ts +159 -0
  50. package/src/http/routes/auth.ts +39 -0
  51. package/src/http/routes/byok.ts +62 -0
  52. package/src/http/routes/credentials.ts +40 -0
  53. package/src/http/routes/domains.ts +174 -0
  54. package/src/http/routes/federation.ts +170 -0
  55. package/src/http/routes/fs.ts +89 -0
  56. package/src/http/routes/peers.ts +103 -0
  57. package/src/http/routes/proxy.ts +57 -0
  58. package/src/http/routes/registry.ts +222 -0
  59. package/src/http/routes/tunnels.ts +124 -0
  60. package/src/http.ts +9 -0
  61. package/src/index.ts +63 -0
  62. package/src/metering/d1-store.ts +123 -0
  63. package/src/metering/memory-store.ts +29 -0
  64. package/src/metering/pricing-guard.ts +68 -0
  65. package/src/metering/types.ts +25 -0
  66. package/src/onboard/apis-guru.ts +64 -0
  67. package/src/onboard/index.ts +4 -0
  68. package/src/onboard/manifest.ts +362 -0
  69. package/src/onboard/pipeline.ts +214 -0
  70. package/src/onboard/types.ts +72 -0
  71. package/src/proxy/__tests__/tool-registry.test.ts +93 -0
  72. package/src/proxy/tool-registry.ts +122 -0
  73. package/src/proxy.ts +12 -0
  74. package/src/registry/context7-backend.ts +93 -0
  75. package/src/registry/context7.ts +54 -0
  76. package/src/registry/d1-store.ts +242 -0
  77. package/src/registry/memory-store.ts +101 -0
  78. package/src/registry/openapi-compiler.ts +284 -0
  79. package/src/registry/resolver.ts +196 -0
  80. package/src/registry/rpc-compiler.ts +142 -0
  81. package/src/registry/skill-parser.ts +119 -0
  82. package/src/registry/skill-to-config.ts +239 -0
  83. package/src/registry/source-refresher.ts +83 -0
  84. package/src/registry/types.ts +129 -0
  85. package/src/registry/virtual-files.ts +76 -0
  86. package/src/testing/sqlite-d1.ts +64 -0
  87. package/src/testing.ts +2 -0
  88. package/src/tunnel/__tests__/cloudflare-provider.test.ts +255 -0
  89. package/src/tunnel/__tests__/tunnel.test.ts +542 -0
  90. package/src/tunnel/cloudflare-provider.ts +121 -0
  91. package/src/tunnel/memory-store.ts +30 -0
  92. package/src/tunnel/types.ts +28 -0
  93. package/test/credential/d1-vault.test.ts +127 -0
  94. package/test/credential/injection.test.ts +67 -0
  95. package/test/credential/memory-vault.test.ts +63 -0
  96. package/test/http/app.test.ts +300 -0
  97. package/test/http/byok-e2e.test.ts +240 -0
  98. package/test/http/byok.test.ts +115 -0
  99. package/test/http/credentials.test.ts +57 -0
  100. package/test/http/e2e.test.ts +260 -0
  101. package/test/integration/authenticated-apis.test.ts +185 -0
  102. package/test/integration/free-apis-e2e.test.ts +222 -0
  103. package/test/metering/d1-store.test.ts +82 -0
  104. package/test/metering/memory-store.test.ts +76 -0
  105. package/test/metering/pricing-guard.test.ts +108 -0
  106. package/test/onboard/apis-guru.test.ts +57 -0
  107. package/test/onboard/e2e.test.ts +70 -0
  108. package/test/onboard/pipeline.test.ts +318 -0
  109. package/test/onboard/real-apis.test.ts +483 -0
  110. package/test/registry/compilation-correctness.test.ts +132 -0
  111. package/test/registry/context7-backend.test.ts +88 -0
  112. package/test/registry/context7-e2e.test.ts +92 -0
  113. package/test/registry/context7.test.ts +73 -0
  114. package/test/registry/d1-store.test.ts +184 -0
  115. package/test/registry/integration.test.ts +129 -0
  116. package/test/registry/lazy-mount.test.ts +138 -0
  117. package/test/registry/memory-store.test.ts +171 -0
  118. package/test/registry/openapi-compiler.test.ts +267 -0
  119. package/test/registry/openapi-e2e.test.ts +154 -0
  120. package/test/registry/passthrough-e2e.test.ts +109 -0
  121. package/test/registry/resolver-peer.test.ts +299 -0
  122. package/test/registry/resolver.test.ts +228 -0
  123. package/test/registry/rpc-compiler.test.ts +112 -0
  124. package/test/registry/skill-parser.test.ts +151 -0
  125. package/test/registry/skill-to-config.test.ts +151 -0
  126. package/test/registry/skill-to-rpc-config.test.ts +142 -0
  127. package/test/registry/source-refresher.test.ts +90 -0
  128. package/test/registry/virtual-files.test.ts +96 -0
  129. package/tsconfig.json +4 -0
  130. package/tsup.config.ts +8 -0
@@ -0,0 +1,30 @@
1
+ interface DohAnswer {
2
+ name: string;
3
+ type: number;
4
+ data: string;
5
+ }
6
+
7
+ interface DohResponse {
8
+ Answer?: DohAnswer[];
9
+ }
10
+
11
+ export async function queryDnsTxt(domain: string): Promise<string[]> {
12
+ const url = new URL("https://cloudflare-dns.com/dns-query");
13
+ url.searchParams.set("name", domain);
14
+ url.searchParams.set("type", "TXT");
15
+
16
+ const res = await fetch(url.toString(), {
17
+ headers: { Accept: "application/dns-json" },
18
+ });
19
+
20
+ if (!res.ok) {
21
+ throw new Error(`DNS query failed: ${res.status}`);
22
+ }
23
+
24
+ const data: DohResponse = await res.json();
25
+
26
+ // TXT records are type 16; data is quoted, strip surrounding quotes
27
+ return (data.Answer ?? [])
28
+ .filter((a) => a.type === 16)
29
+ .map((a) => a.data.replace(/^"|"$/g, ""));
30
+ }
@@ -0,0 +1,18 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import type { Env } from "../app.js";
3
+
4
+ export function adminAuth(adminToken: string) {
5
+ return createMiddleware<Env>(async (c, next) => {
6
+ const auth = c.req.header("Authorization");
7
+ if (!auth || !auth.startsWith("Bearer ")) {
8
+ return c.json({ error: "Missing Authorization header" }, 401);
9
+ }
10
+
11
+ const token = auth.slice(7);
12
+ if (token !== adminToken) {
13
+ return c.json({ error: "Invalid admin token" }, 403);
14
+ }
15
+
16
+ await next();
17
+ });
18
+ }
@@ -0,0 +1,27 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import { verifyJwt } from "@nkmc/core";
3
+ import type { JWK } from "jose";
4
+ import type { Env } from "../app.js";
5
+
6
+ export function agentAuth(publicKey: JWK) {
7
+ return createMiddleware<Env>(async (c, next) => {
8
+ const auth = c.req.header("Authorization");
9
+ if (!auth || !auth.startsWith("Bearer ")) {
10
+ return c.json({ error: "Missing Authorization header" }, 401);
11
+ }
12
+
13
+ const token = auth.slice(7);
14
+ try {
15
+ const payload = await verifyJwt(token, publicKey);
16
+ c.set("agent", { id: payload.sub, roles: payload.roles });
17
+ } catch (err) {
18
+ const message = err instanceof Error ? err.message : "Invalid token";
19
+ if (message.includes("exp") || message.includes("expired")) {
20
+ return c.json({ error: "Token has expired" }, 401);
21
+ }
22
+ return c.json({ error: "Invalid token" }, 401);
23
+ }
24
+
25
+ await next();
26
+ });
27
+ }
@@ -0,0 +1,39 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import type { JWK } from "jose";
3
+ import { verifyPublishToken } from "@nkmc/core";
4
+
5
+ export type PublishAuthContext =
6
+ | { type: "admin" }
7
+ | { type: "publish"; domain: string };
8
+
9
+ type PublishAuthEnv = {
10
+ Variables: {
11
+ publishAuth: PublishAuthContext;
12
+ };
13
+ };
14
+
15
+ export function publishOrAdminAuth(adminToken: string, publicKey: JWK) {
16
+ return createMiddleware<PublishAuthEnv>(async (c, next) => {
17
+ const auth = c.req.header("Authorization");
18
+ if (!auth || !auth.startsWith("Bearer ")) {
19
+ return c.json({ error: "Missing Authorization header" }, 401);
20
+ }
21
+
22
+ const token = auth.slice(7);
23
+
24
+ // 1. Try admin token (string comparison)
25
+ if (token === adminToken) {
26
+ c.set("publishAuth", { type: "admin" });
27
+ return next();
28
+ }
29
+
30
+ // 2. Try publish token (JWT signed by gateway)
31
+ try {
32
+ const payload = await verifyPublishToken(token, publicKey);
33
+ c.set("publishAuth", { type: "publish", domain: payload.sub });
34
+ return next();
35
+ } catch {
36
+ return c.json({ error: "Invalid token" }, 403);
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,364 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { Hono } from "hono";
3
+ import { MemoryPeerStore } from "../../../federation/peer-store.js";
4
+ import { MemoryCredentialVault } from "../../../credential/memory-vault.js";
5
+ import { federationRoutes } from "../federation.js";
6
+ import type { PeerGateway, LendingRule } from "../../../federation/types.js";
7
+ import type { AgentFs } from "@nkmc/agent-fs";
8
+
9
+ function makePeer(overrides: Partial<PeerGateway> = {}): PeerGateway {
10
+ return {
11
+ id: "peer-1",
12
+ name: "Test Peer",
13
+ url: "https://peer1.example.com",
14
+ sharedSecret: "secret-abc",
15
+ status: "active",
16
+ advertisedDomains: [],
17
+ lastSeen: Date.now(),
18
+ createdAt: Date.now(),
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ function makeRule(overrides: Partial<LendingRule> = {}): LendingRule {
24
+ return {
25
+ domain: "api.example.com",
26
+ allow: true,
27
+ peers: "*",
28
+ pricing: { mode: "free" },
29
+ createdAt: Date.now(),
30
+ updatedAt: Date.now(),
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function createMockAgentFs() {
36
+ return {
37
+ execute: vi.fn(async () => ({
38
+ ok: true as const,
39
+ data: ["file1.txt", "file2.txt"],
40
+ })),
41
+ executeCommand: vi.fn(async () => ({
42
+ ok: true as const,
43
+ data: ["file1.txt"],
44
+ })),
45
+ } as unknown as AgentFs;
46
+ }
47
+
48
+ function createTestApp(options?: {
49
+ peerStore?: MemoryPeerStore;
50
+ vault?: MemoryCredentialVault;
51
+ agentFs?: AgentFs;
52
+ }) {
53
+ const peerStore = options?.peerStore ?? new MemoryPeerStore();
54
+ const vault = options?.vault ?? new MemoryCredentialVault();
55
+ const agentFs = options?.agentFs ?? createMockAgentFs();
56
+
57
+ const app = new Hono();
58
+ app.route("/federation", federationRoutes({ peerStore, vault, agentFs }));
59
+
60
+ return { app, peerStore, vault, agentFs };
61
+ }
62
+
63
+ function peerHeaders(peer: PeerGateway) {
64
+ return {
65
+ "Content-Type": "application/json",
66
+ "X-Peer-Id": peer.id,
67
+ Authorization: `Bearer ${peer.sharedSecret}`,
68
+ };
69
+ }
70
+
71
+ describe("federation routes", () => {
72
+ describe("POST /federation/query", () => {
73
+ it("returns available when credential and rule exist", async () => {
74
+ const peerStore = new MemoryPeerStore();
75
+ const vault = new MemoryCredentialVault();
76
+ const peer = makePeer();
77
+
78
+ await peerStore.putPeer(peer);
79
+ await peerStore.putRule(makeRule({ domain: "api.example.com" }));
80
+ await vault.putPool("api.example.com", {
81
+ type: "bearer",
82
+ token: "test-token",
83
+ });
84
+
85
+ const { app } = createTestApp({ peerStore, vault });
86
+
87
+ const res = await app.request("/federation/query", {
88
+ method: "POST",
89
+ headers: peerHeaders(peer),
90
+ body: JSON.stringify({ domain: "api.example.com" }),
91
+ });
92
+
93
+ expect(res.status).toBe(200);
94
+ const body = await res.json();
95
+ expect(body.available).toBe(true);
96
+ expect(body.pricing).toEqual({ mode: "free" });
97
+ });
98
+
99
+ it("returns unavailable when rule disallows", async () => {
100
+ const peerStore = new MemoryPeerStore();
101
+ const vault = new MemoryCredentialVault();
102
+ const peer = makePeer();
103
+
104
+ await peerStore.putPeer(peer);
105
+ await peerStore.putRule(
106
+ makeRule({ domain: "api.example.com", allow: false }),
107
+ );
108
+ await vault.putPool("api.example.com", {
109
+ type: "bearer",
110
+ token: "test-token",
111
+ });
112
+
113
+ const { app } = createTestApp({ peerStore, vault });
114
+
115
+ const res = await app.request("/federation/query", {
116
+ method: "POST",
117
+ headers: peerHeaders(peer),
118
+ body: JSON.stringify({ domain: "api.example.com" }),
119
+ });
120
+
121
+ expect(res.status).toBe(200);
122
+ const body = await res.json();
123
+ expect(body.available).toBe(false);
124
+ });
125
+
126
+ it("returns unavailable when no credential exists", async () => {
127
+ const peerStore = new MemoryPeerStore();
128
+ const vault = new MemoryCredentialVault();
129
+ const peer = makePeer();
130
+
131
+ await peerStore.putPeer(peer);
132
+ await peerStore.putRule(makeRule({ domain: "api.example.com" }));
133
+ // No credential in vault
134
+
135
+ const { app } = createTestApp({ peerStore, vault });
136
+
137
+ const res = await app.request("/federation/query", {
138
+ method: "POST",
139
+ headers: peerHeaders(peer),
140
+ body: JSON.stringify({ domain: "api.example.com" }),
141
+ });
142
+
143
+ expect(res.status).toBe(200);
144
+ const body = await res.json();
145
+ expect(body.available).toBe(false);
146
+ });
147
+
148
+ it("returns unavailable when peer is not in allowed list", async () => {
149
+ const peerStore = new MemoryPeerStore();
150
+ const vault = new MemoryCredentialVault();
151
+ const peer = makePeer({ id: "peer-1" });
152
+
153
+ await peerStore.putPeer(peer);
154
+ await peerStore.putRule(
155
+ makeRule({
156
+ domain: "api.example.com",
157
+ peers: ["peer-other"], // peer-1 not in list
158
+ }),
159
+ );
160
+ await vault.putPool("api.example.com", {
161
+ type: "bearer",
162
+ token: "test-token",
163
+ });
164
+
165
+ const { app } = createTestApp({ peerStore, vault });
166
+
167
+ const res = await app.request("/federation/query", {
168
+ method: "POST",
169
+ headers: peerHeaders(peer),
170
+ body: JSON.stringify({ domain: "api.example.com" }),
171
+ });
172
+
173
+ expect(res.status).toBe(200);
174
+ const body = await res.json();
175
+ expect(body.available).toBe(false);
176
+ });
177
+
178
+ it("rejects unknown peer with 403", async () => {
179
+ const { app } = createTestApp();
180
+
181
+ const res = await app.request("/federation/query", {
182
+ method: "POST",
183
+ headers: {
184
+ "Content-Type": "application/json",
185
+ "X-Peer-Id": "unknown-peer",
186
+ Authorization: "Bearer wrong-secret",
187
+ },
188
+ body: JSON.stringify({ domain: "api.example.com" }),
189
+ });
190
+
191
+ expect(res.status).toBe(403);
192
+ });
193
+
194
+ it("rejects peer with wrong secret", async () => {
195
+ const peerStore = new MemoryPeerStore();
196
+ const peer = makePeer();
197
+ await peerStore.putPeer(peer);
198
+
199
+ const { app } = createTestApp({ peerStore });
200
+
201
+ const res = await app.request("/federation/query", {
202
+ method: "POST",
203
+ headers: {
204
+ "Content-Type": "application/json",
205
+ "X-Peer-Id": peer.id,
206
+ Authorization: "Bearer wrong-secret",
207
+ },
208
+ body: JSON.stringify({ domain: "api.example.com" }),
209
+ });
210
+
211
+ expect(res.status).toBe(403);
212
+ });
213
+ });
214
+
215
+ describe("POST /federation/exec", () => {
216
+ it("executes command with proper peer auth", async () => {
217
+ const peerStore = new MemoryPeerStore();
218
+ const vault = new MemoryCredentialVault();
219
+ const peer = makePeer();
220
+ const agentFs = createMockAgentFs();
221
+
222
+ await peerStore.putPeer(peer);
223
+ await peerStore.putRule(makeRule({ domain: "api.example.com" }));
224
+
225
+ const { app } = createTestApp({ peerStore, vault, agentFs });
226
+
227
+ const res = await app.request("/federation/exec", {
228
+ method: "POST",
229
+ headers: peerHeaders(peer),
230
+ body: JSON.stringify({
231
+ command: "ls /api.example.com/",
232
+ agentId: "agent-1",
233
+ }),
234
+ });
235
+
236
+ expect(res.status).toBe(200);
237
+ const body = await res.json();
238
+ expect(body.ok).toBe(true);
239
+ expect(body.data).toEqual(["file1.txt", "file2.txt"]);
240
+
241
+ // Verify synthetic agent context
242
+ expect(agentFs.execute).toHaveBeenCalledWith(
243
+ "ls /api.example.com/",
244
+ ["agent"],
245
+ { id: "peer:peer-1:agent-1", roles: ["agent"] },
246
+ );
247
+ });
248
+
249
+ it("returns 402 when pricing is non-free and no payment header", async () => {
250
+ const peerStore = new MemoryPeerStore();
251
+ const peer = makePeer();
252
+
253
+ await peerStore.putPeer(peer);
254
+ await peerStore.putRule(
255
+ makeRule({
256
+ domain: "api.example.com",
257
+ pricing: { mode: "per-request", amount: 100 },
258
+ }),
259
+ );
260
+
261
+ const { app } = createTestApp({ peerStore });
262
+
263
+ const res = await app.request("/federation/exec", {
264
+ method: "POST",
265
+ headers: peerHeaders(peer),
266
+ body: JSON.stringify({
267
+ command: "cat /api.example.com/data",
268
+ agentId: "agent-1",
269
+ }),
270
+ });
271
+
272
+ expect(res.status).toBe(402);
273
+ const body = await res.json();
274
+ expect(body.error).toContain("Payment required");
275
+ });
276
+
277
+ it("returns 403 when domain is not allowed for lending", async () => {
278
+ const peerStore = new MemoryPeerStore();
279
+ const peer = makePeer();
280
+
281
+ await peerStore.putPeer(peer);
282
+ await peerStore.putRule(
283
+ makeRule({ domain: "api.example.com", allow: false }),
284
+ );
285
+
286
+ const { app } = createTestApp({ peerStore });
287
+
288
+ const res = await app.request("/federation/exec", {
289
+ method: "POST",
290
+ headers: peerHeaders(peer),
291
+ body: JSON.stringify({
292
+ command: "ls /api.example.com/",
293
+ agentId: "agent-1",
294
+ }),
295
+ });
296
+
297
+ expect(res.status).toBe(403);
298
+ });
299
+
300
+ it("returns 403 for unauthorized peer", async () => {
301
+ const { app } = createTestApp();
302
+
303
+ const res = await app.request("/federation/exec", {
304
+ method: "POST",
305
+ headers: {
306
+ "Content-Type": "application/json",
307
+ "X-Peer-Id": "unknown",
308
+ Authorization: "Bearer wrong",
309
+ },
310
+ body: JSON.stringify({
311
+ command: "ls /api.example.com/",
312
+ agentId: "agent-1",
313
+ }),
314
+ });
315
+
316
+ expect(res.status).toBe(403);
317
+ });
318
+ });
319
+
320
+ describe("POST /federation/announce", () => {
321
+ it("updates peer advertised domains", async () => {
322
+ const peerStore = new MemoryPeerStore();
323
+ const peer = makePeer({ advertisedDomains: [] });
324
+ await peerStore.putPeer(peer);
325
+
326
+ const { app } = createTestApp({ peerStore });
327
+
328
+ const res = await app.request("/federation/announce", {
329
+ method: "POST",
330
+ headers: peerHeaders(peer),
331
+ body: JSON.stringify({
332
+ domains: ["api.example.com", "github.com"],
333
+ }),
334
+ });
335
+
336
+ expect(res.status).toBe(200);
337
+ const body = await res.json();
338
+ expect(body.ok).toBe(true);
339
+
340
+ // Verify domains were updated in the store
341
+ const updated = await peerStore.getPeer(peer.id);
342
+ expect(updated!.advertisedDomains).toEqual([
343
+ "api.example.com",
344
+ "github.com",
345
+ ]);
346
+ });
347
+
348
+ it("rejects unauthorized peer", async () => {
349
+ const { app } = createTestApp();
350
+
351
+ const res = await app.request("/federation/announce", {
352
+ method: "POST",
353
+ headers: {
354
+ "Content-Type": "application/json",
355
+ "X-Peer-Id": "unknown",
356
+ Authorization: "Bearer wrong",
357
+ },
358
+ body: JSON.stringify({ domains: ["api.example.com"] }),
359
+ });
360
+
361
+ expect(res.status).toBe(403);
362
+ });
363
+ });
364
+ });