Open History to see the last commit
1import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
4import * as NodeCrypto from "node:crypto";
5import * as EffectNodeCrypto from "@effect/platform-node/NodeCrypto";
6import { describe, expect, it } from "@effect/vitest";
7import { RelayApi, RelayHookDeliveryProofPayload } from "@t3tools/contracts/relay";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Redacted from "effect/Redacted";
12import * as Tracer from "effect/Tracer";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientError from "effect/http/HttpClientError";
15import type * as HttpClientRequest from "effect/http/HttpClientRequest";
16import * as HttpClientResponse from "effect/http/HttpClientResponse";
17import * as Etag from "effect/http/Etag";
18import * as HttpEffect from "effect/http/HttpEffect";
19import * as HttpRouter from "effect/http/HttpRouter";
20import * as HttpApi from "effect/http-api/HttpApi";
21import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
22import * as HttpServerRequest from "effect/http/HttpServerRequest";
23import * as HttpServerResponse from "effect/http/HttpServerResponse";
24
25import * as CloudMintSigner from "../auth/CloudMintSigner.ts";
26import * as RelayConfiguration from "../Config.ts";
27import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
28import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
29import { RELAY_HTTP_ROUTER_CONFIG, traceRelayHttpRequestWith } from "../http/Api.ts";
30import * as RelayHttpApi from "../http/Api.ts";
31import * as HookForwarder from "./HookForwarder.ts";
32import { RELAY_HOOK_DELIVERY_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";
33import * as HeldHooks from "./HeldHooks.ts";
34import * as HookInbox from "./HookInbox.ts";
35import type { HeldHook } from "./HookInboxStore.ts";
36
37const mintKeys = NodeCrypto.generateKeyPairSync("ed25519", {
38 privateKeyEncoding: { format: "pem", type: "pkcs8" },
39 publicKeyEncoding: { format: "pem", type: "spki" },
40});
41
42const settings: RelayConfiguration.RelayConfiguration["Service"] = {
43 relayIssuer: "https://relay.example.test",
44 apns: null,
45 clerkSecretKey: Redacted.make("clerk-secret-key"),
46 clerkPublishableKey: "pk_test_test",
47 clerkJwtAudience: "t3-code-relay",
48 apnsDeliveryJobSigningSecret: Redacted.make("apns-delivery-secret"),
49 cloudMintPrivateKey: Redacted.make(mintKeys.privateKey),
50 cloudMintPublicKey: mintKeys.publicKey,
51 managedEndpointBaseDomain: "example.test",
52 managedEndpointNamespace: "dev",
53};
54
55const environmentId = "env-hook";
56const endpointKey = "0123456789abcdef";
57const readyAllocation: ManagedEndpointAllocations.ManagedEndpointAllocation = {
58 userId: "user_1",
59 environmentId,
60 hostname: "env.example.test",
61 tunnelId: "tunnel-id",
62 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
63 dnsRecordId: "dns-record-id",
64 readyAt: "2026-05-25T00:00:00.000Z",
65 tunnelReleasedAt: null,
66 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
67 updatedAt: "2026-05-25T00:00:00.000Z",
68 generation: 1,
69};
70
71const managedLink = {
72 userId: "user_1",
73 environmentId: environmentId as never,
74 label: "Hook env",
75 endpoint: {
76 httpBaseUrl: "https://env.example.test/",
77 wsBaseUrl: "wss://env.example.test/ws",
78 providerKind: "cloudflare_tunnel" as const,
79 },
80 environmentPublicKey: "public-key",
81 linkedAt: "2026-05-25T00:00:00.000Z",
82 holdWebhooksWhileOffline: false,
83};
84
85interface Harness {
86 readonly execute?: (
87 request: HttpClientRequest.HttpClientRequest,
88 ) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>;
89 readonly links?: ReadonlyArray<typeof managedLink>;
90 readonly allocation?: ManagedEndpointAllocations.ManagedEndpointAllocation | null;
91 readonly allow?: (key: string) => boolean;
92 readonly allowEndpoint?: (endpointKey: string) => boolean;
93 readonly inboxCapacity?: number;
94}
95
96function makeHarness(options: Harness = {}) {
97 const sent: Array<HttpClientRequest.HttpClientRequest> = [];
98 const rateLimitKeys: Array<string> = [];
99 const held: Array<HeldHook & { readonly baseUrl: string }> = [];
100 const execute =
101 options.execute ??
102 ((request: HttpClientRequest.HttpClientRequest) =>
103 Effect.succeed(
104 HttpClientResponse.fromWeb(
105 request,
106 new Response("ok", { status: 200, headers: { "content-type": "text/plain" } }),
107 ),
108 ));
109 const layerForwarder = HookForwarder.layer.pipe(
110 Layer.provideMerge(HeldHooks.layer),
111 Layer.provide(CloudMintSigner.layer),
112 Layer.provide(
113 Layer.mergeAll(
114 Layer.succeed(RelayConfiguration.RelayConfiguration, settings),
115 Layer.mock(EnvironmentLinks.EnvironmentLinks, {
116 findActiveManagedForEnvironment: (input) =>
117 Effect.succeed(
118 (options.links ?? [managedLink]).filter(
119 (link) =>
120 link.environmentId === input.environmentId &&
121 (input.userId === undefined || link.userId === input.userId),
122 ),
123 ),
124 }),
125 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations, {
126 getByTunnelName: (tunnelName) => {
127 const allocation =
128 options.allocation === undefined ? readyAllocation : options.allocation;
129 return Effect.succeed(allocation?.tunnelName === tunnelName ? allocation : null);
130 },
131 }),
132 Layer.succeed(
133 HttpClient.HttpClient,
134 HttpClient.make((request) => {
135 sent.push(request);
136 return execute(request);
137 }),
138 ),
139 Layer.mock(HookInbox.HookInbox, {
140 hold: ({ hook, baseUrl }) =>
141 Effect.sync(() => {
142 if (held.length >= (options.inboxCapacity ?? Infinity)) return false;
143 held.push({ ...hook, baseUrl });
144 return true;
145 }),
146 }),
147 EffectNodeCrypto.layer,
148 Layer.succeed(HookForwarder.HookRateLimiter, {
149 allowHook: (key) =>
150 Effect.sync(() => {
151 rateLimitKeys.push(key);
152 return options.allow ? options.allow(key) : true;
153 }),
154 allowEndpoint: (key) =>
155 Effect.sync(() => (options.allowEndpoint ? options.allowEndpoint(key) : true)),
156 }),
157 ),
158 ),
159 );
160 const httpEffect = HttpRouter.toHttpEffect(
161 Layer.mergeAll(
162 HttpApiBuilder.layer(HttpApi.make("RelayApi").add(RelayApi.groups.hooks)).pipe(
163 Layer.provide(HookForwarder.layerApi.pipe(Layer.provide(layerForwarder))),
164 Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layerWeak]),
165 ),
166 RelayHttpApi.layerNotFoundRoute,
167 RelayHttpApi.layerCors,
168 ),
169 ).pipe(Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG));
170 // Use the Workers request path so pre-response handlers apply.
171 const send = (request: Request) =>
172 Effect.gen(function* () {
173 const handler = yield* httpEffect;
174 const sent = yield* Deferred.make<HttpServerResponse.HttpServerResponse>();
175 yield* HttpEffect.toHandled(handler, (_request, response) =>
176 Deferred.succeed(sent, response),
177 ).pipe(
178 Effect.provideService(
179 HttpServerRequest.HttpServerRequest,
180 HttpServerRequest.fromWeb(request),
181 ),
182 );
183 return yield* Deferred.await(sent);
184 });
185 return { sent, rateLimitKeys, held, send, httpEffect };
186}
187
188const hookUrl = (path = "hook-1/secret-token", query = "") =>
189 `https://relay.test/v1/hooks/${endpointKey}/${path}${query}`;
190
191const readBody = (response: HttpServerResponse.HttpServerResponse) =>
192 Effect.promise(() => HttpServerResponse.toWeb(response).arrayBuffer()).pipe(
193 Effect.map((buffer) => new Uint8Array(buffer)),
194 );
195const readJson = (response: HttpServerResponse.HttpServerResponse) =>
196 Effect.promise(() => HttpServerResponse.toWeb(response).json());
197
198const requestBytes = (request: HttpClientRequest.HttpClientRequest) =>
199 request.body._tag === "Uint8Array" ? request.body.body : new Uint8Array(0);
200
201describe("HookForwarder", () => {
202 it.effect("forwards the exact body bytes, query and filtered headers", () =>
203 Effect.gen(function* () {
204 const harness = makeHarness();
205 const body = new Uint8Array([0, 255, 10, 13, 0x7b, 0x22, 0xc3, 0x28]);
206 const response = yield* harness.send(
207 new Request(hookUrl("hook-1/tok%2Fen", "?a=1&b=two%20words"), {
208 method: "POST",
209 headers: {
210 "content-type": "application/octet-stream",
211 authorization: "Bearer sender-secret",
212 "x-hub-signature-256": "sha256=abc",
213 cookie: "session=1",
214 "cf-connecting-ip": "1.2.3.4",
215 "x-forwarded-for": "1.2.3.4",
216 "x-real-ip": "1.2.3.4",
217 "proxy-authorization": "Basic x",
218 connection: "keep-alive",
219 },
220 body,
221 }),
222 );
223 expect(response.status).toBe(200);
224 expect(harness.sent).toHaveLength(1);
225 const sent = harness.sent[0]!;
226 expect(sent.method).toBe("POST");
227 expect(sent.url).toBe("https://env.example.test/api/hooks/hook-1/tok%2Fen?a=1&b=two%20words");
228 expect(Array.from(requestBytes(sent))).toEqual(Array.from(body));
229 expect(sent.headers.authorization).toBe("Bearer sender-secret");
230 expect(sent.headers["x-hub-signature-256"]).toBe("sha256=abc");
231 expect(sent.headers["content-type"]).toBe("application/octet-stream");
232 for (const dropped of [
233 "cookie",
234 "cf-connecting-ip",
235 "x-forwarded-for",
236 "x-real-ip",
237 "proxy-authorization",
238 "connection",
239 "host",
240 ]) {
241 expect(sent.headers[dropped]).toBeUndefined();
242 }
243 expect(sent.headers["content-length"]).toBe(String(body.length));
244 expect(harness.rateLimitKeys).toHaveLength(1);
245 expect(harness.rateLimitKeys[0]).toMatch(/^[0-9a-f]{64}$/);
246 }),
247 );
248
249 it.effect("signs each forward for the environment, replacing any copy a sender sent", () =>
250 Effect.gen(function* () {
251 const harness = makeHarness();
252 yield* harness.send(
253 new Request(hookUrl("hook-1/tok"), {
254 method: "POST",
255 headers: { "x-t3-relay-delivery": "forged", "x-t3-relay-delivery-id": "forged" },
256 body: "{}",
257 }),
258 );
259 const sent = harness.sent[0]!;
260 const payload = yield* verifyRelayJwt(RelayHookDeliveryProofPayload, {
261 publicKey: mintKeys.publicKey,
262 token: sent.headers["x-t3-relay-delivery"]!,
263 typ: RELAY_HOOK_DELIVERY_TYP,
264 issuer: settings.relayIssuer,
265 audience: `t3-env:${environmentId}`,
266 }).pipe(Effect.provide(EffectNodeCrypto.layerSignature));
267 expect(payload).toMatchObject({
268 environmentId,
269 hookId: "hook-1",
270 deliveryId: sent.headers["x-t3-relay-delivery-id"],
271 receivedAt: sent.headers["x-t3-relay-received-at"],
272 });
273 expect(sent.headers["x-t3-relay-delivery-id"]).not.toBe("forged");
274 }),
275 );
276
277 it.effect("passes upstream status, body and content-type through", () =>
278 Effect.gen(function* () {
279 const harness = makeHarness({
280 execute: (request) =>
281 Effect.succeed(
282 HttpClientResponse.fromWeb(
283 request,
284 new Response('{"error":"bad_signature"}', {
285 status: 401,
286 headers: { "content-type": "application/json", "set-cookie": "x=1" },
287 }),
288 ),
289 ),
290 });
291 const response = yield* harness.send(new Request(hookUrl(), { method: "GET" }));
292 expect(response.status).toBe(401);
293 expect(response.headers["content-type"]).toBe("application/json");
294 expect(response.headers["set-cookie"]).toBeUndefined();
295 expect(response.headers["access-control-allow-origin"]).toBeUndefined();
296 expect(response.headers["x-content-type-options"]).toBe("nosniff");
297 expect(response.headers["content-security-policy"]).toBe("sandbox; default-src 'none'");
298 expect(new TextDecoder().decode(yield* readBody(response))).toBe('{"error":"bad_signature"}');
299 expect(harness.sent[0]?.method).toBe("GET");
300 }),
301 );
302
303 it.effect("rejects bodies over 1 MiB by content-length and while reading", () =>
304 Effect.gen(function* () {
305 const harness = makeHarness();
306 const declared = yield* harness.send(
307 new Request(hookUrl(), {
308 method: "POST",
309 headers: { "content-length": String(HookForwarder.RELAY_HOOK_MAX_BODY_BYTES + 1) },
310 body: "x",
311 }),
312 );
313 expect(declared.status).toBe(413);
314
315 const oversized = new Uint8Array(HookForwarder.RELAY_HOOK_MAX_BODY_BYTES + 1);
316 const stream = new ReadableStream<Uint8Array>({
317 start(controller) {
318 controller.enqueue(oversized.subarray(0, 600_000));
319 controller.enqueue(oversized.subarray(600_000));
320 controller.close();
321 },
322 });
323 const streamed = yield* harness.send(
324 new Request(hookUrl(), { method: "POST", body: stream, duplex: "half" } as RequestInit),
325 );
326 expect(streamed.status).toBe(413);
327 expect(yield* readJson(streamed)).toEqual({ error: "payload_too_large" });
328 expect(harness.sent).toHaveLength(0);
329 }),
330 );
331
332 it.effect("returns 404 for unknown endpoints and unready endpoints", () =>
333 Effect.gen(function* () {
334 const unknown = makeHarness();
335 for (const key of ["fedcba9876543210", environmentId]) {
336 const response = yield* unknown.send(
337 new Request(`https://relay.test/v1/hooks/${key}/hook-1/token`, { method: "POST" }),
338 );
339 expect(response.status).toBe(404);
340 expect(yield* readJson(response)).toEqual({ error: "hook_not_found" });
341 }
342 expect(unknown.sent).toHaveLength(0);
343
344 const unready = makeHarness({ allocation: { ...readyAllocation, readyAt: null } });
345 const unreadyResponse = yield* unready.send(new Request(hookUrl(), { method: "POST" }));
346 expect(unreadyResponse.status).toBe(404);
347 expect(unready.sent).toHaveLength(0);
348 }),
349 );
350
351 it.effect("forwards only to the link that owns the endpoint key", () =>
352 Effect.gen(function* () {
353 const intruder = {
354 ...managedLink,
355 userId: "user_attacker",
356 environmentPublicKey: "attacker-key",
357 endpoint: { ...managedLink.endpoint, httpBaseUrl: "https://attacker.example.test/" },
358 };
359 const harness = makeHarness({ links: [intruder, managedLink] });
360 const response = yield* harness.send(new Request(hookUrl(), { method: "POST", body: "{}" }));
361 expect(response.status).toBe(200);
362 expect(harness.sent.map((request) => new URL(request.url).host)).toEqual([
363 "env.example.test",
364 ]);
365
366 const orphaned = makeHarness({ links: [intruder] });
367 const orphanedResponse = yield* orphaned.send(
368 new Request(hookUrl(), { method: "POST", body: "{}" }),
369 );
370 expect(orphanedResponse.status).toBe(404);
371 expect(orphaned.sent).toHaveLength(0);
372 }),
373 );
374
375 it.effect("maps tunnel-offline and network failures to 503", () =>
376 Effect.gen(function* () {
377 const offline = makeHarness({
378 execute: (request) =>
379 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 530 }))),
380 });
381 const offlineResponse = yield* offline.send(new Request(hookUrl(), { method: "POST" }));
382 expect(offlineResponse.status).toBe(503);
383 expect(yield* readJson(offlineResponse)).toEqual({ error: "environment_unavailable" });
384
385 const network = makeHarness({
386 execute: (request) =>
387 Effect.fail(
388 new HttpClientError.HttpClientError({
389 reason: new HttpClientError.TransportError({ request, cause: new Error("reset") }),
390 }),
391 ),
392 });
393 const networkResponse = yield* network.send(new Request(hookUrl(), { method: "POST" }));
394 expect(networkResponse.status).toBe(503);
395 }),
396 );
397
398 it.effect("does not relay an upstream response larger than the cap", () =>
399 Effect.gen(function* () {
400 const harness = makeHarness({
401 execute: (request) =>
402 Effect.succeed(
403 HttpClientResponse.fromWeb(
404 request,
405 new Response(new Uint8Array(64 * 1024 + 1), { status: 200 }),
406 ),
407 ),
408 });
409 const response = yield* harness.send(new Request(hookUrl(), { method: "POST" }));
410 expect(response.status).toBe(503);
411 expect(yield* readJson(response)).toEqual({ error: "environment_unavailable" });
412 }),
413 );
414
415 it.effect("drops headers the sender names in Connection", () =>
416 Effect.gen(function* () {
417 const harness = makeHarness();
418 yield* harness.send(
419 new Request(hookUrl(), {
420 method: "POST",
421 body: "{}",
422 headers: { connection: "x-hop, keep-alive", "x-hop": "1", "x-keep": "2" },
423 }),
424 );
425 const sent: Readonly<Record<string, string>> = harness.sent[0]?.headers ?? {};
426 expect(sent["x-hop"]).toBeUndefined();
427 expect(sent["x-keep"]).toBe("2");
428 }),
429 );
430
431 it.effect("gives requests with a wrong token their own budget", () =>
432 Effect.gen(function* () {
433 const harness = makeHarness();
434 yield* harness.send(
435 new Request(hookUrl("hook-1/real-token"), { method: "POST", body: "{}" }),
436 );
437 yield* harness.send(new Request(hookUrl("hook-1/guessed"), { method: "POST", body: "{}" }));
438 expect(new Set(harness.rateLimitKeys).size).toBe(2);
439 }),
440 );
441
442 it.effect("gives two spellings of one token a single budget", () =>
443 Effect.gen(function* () {
444 const harness = makeHarness();
445 yield* harness.send(new Request(hookUrl("hook-1/token"), { method: "POST", body: "{}" }));
446 yield* harness.send(new Request(hookUrl("hook-1/%74oken"), { method: "POST", body: "{}" }));
447 expect(new Set(harness.rateLimitKeys).size).toBe(1);
448 }),
449 );
450
451 it.effect("returns 429 when the hook's budget is spent", () =>
452 Effect.gen(function* () {
453 const harness = makeHarness({ allow: () => false });
454 const response = yield* harness.send(new Request(hookUrl(), { method: "POST" }));
455 expect(response.status).toBe(429);
456 expect(harness.sent).toHaveLength(0);
457 }),
458 );
459
460 it.effect("never records the token in the server span", () =>
461 Effect.gen(function* () {
462 const spans: Array<Tracer.NativeSpan> = [];
463 const tracer = Tracer.make({
464 span: (options) => {
465 const span = new Tracer.NativeSpan(options);
466 spans.push(span);
467 return span;
468 },
469 });
470 const harness = makeHarness();
471 const handler = yield* harness.httpEffect;
472 const response = yield* traceRelayHttpRequestWith(
473 handler,
474 Layer.succeed(Tracer.Tracer, tracer),
475 ).pipe(
476 Effect.provideService(
477 HttpServerRequest.HttpServerRequest,
478 HttpServerRequest.fromWeb(
479 new Request(hookUrl("hook-1/super-secret-token", "?sig=also-secret"), {
480 method: "POST",
481 headers: { "x-gitlab-token": "header-secret" },
482 body: "{}",
483 }),
484 ),
485 ),
486 );
487 expect(response.status).toBe(200);
488 expect(harness.sent[0]?.url).toContain("super-secret-token");
489 yield* Effect.yieldNow;
490 const serialized = spans
491 .flatMap((span) => [span.name, ...Array.from(span.attributes.values(), String)])
492 .join("\n");
493 expect(serialized).not.toContain("super-secret-token");
494 expect(serialized).not.toContain("also-secret");
495 expect(serialized).not.toContain("header-secret");
496 const server = spans.find((span) => span.kind === "server");
497 expect(server?.attributes.get("url.path")).toBe(`/v1/hooks/${endpointKey}/hook-1/redacted`);
498 }),
499 );
500
501 describe("holding requests while the environment is offline", () => {
502 const offline = (request: HttpClientRequest.HttpClientRequest) =>
503 Effect.fail(
504 new HttpClientError.HttpClientError({
505 reason: new HttpClientError.TransportError({ request, cause: new Error("offline") }),
506 }),
507 );
508
509 it.effect("holds when cloudflared answers that the local server is down", () =>
510 Effect.gen(function* () {
511 for (const status of [502, 503, 504, 530]) {
512 const harness = makeHarness({
513 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
514 execute: (request) =>
515 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status }))),
516 });
517 const response = yield* harness.send(
518 new Request(hookUrl(), { method: "POST", body: "{}" }),
519 );
520 expect(response.status).toBe(202);
521 expect(harness.held).toHaveLength(1);
522 }
523 }),
524 );
525
526 it.effect("stays a plain proxy when the environment has not opted in", () =>
527 Effect.gen(function* () {
528 const harness = makeHarness({ execute: offline });
529 const response = yield* harness.send(
530 new Request(hookUrl(), { method: "POST", body: "{}" }),
531 );
532 expect(response.status).toBe(503);
533 expect(harness.held).toHaveLength(0);
534 }),
535 );
536
537 it.effect("holds the exact request and answers 202 once opted in", () =>
538 Effect.gen(function* () {
539 const harness = makeHarness({
540 execute: offline,
541 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
542 });
543 const body = new Uint8Array([0, 255, 10]);
544 const response = yield* harness.send(
545 new Request(hookUrl("%68ook-1/tok%2Fen", "?a=1"), {
546 method: "POST",
547 body,
548 headers: { "x-t3-relay-delivery-id": "forged", "x-sig": "s" },
549 }),
550 );
551 expect(response.status).toBe(202);
552 const [hook] = harness.held;
553 expect(hook?.baseUrl).toBe("https://env.example.test/");
554 expect(hook?.rawToken).toBe("tok%2Fen");
555 expect(hook?.query).toBe("a=1");
556 expect([...(hook?.body ?? [])]).toEqual([0, 255, 10]);
557 expect(hook?.headers["x-sig"]).toBe("s");
558 expect(hook?.hookKey).toBe("hook-1");
559 expect(hook?.headers["x-t3-relay-delivery-id"]).toBeUndefined();
560 expect(hook?.id).not.toBe("forged");
561 }),
562 );
563
564 it.effect("answers 503 inbox_full when the environment's inbox is full", () =>
565 Effect.gen(function* () {
566 const harness = makeHarness({
567 execute: offline,
568 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
569 inboxCapacity: 0,
570 });
571 const response = yield* harness.send(
572 new Request(hookUrl(), { method: "POST", body: "{}" }),
573 );
574 expect(response.status).toBe(503);
575 expect(yield* readJson(response)).toEqual({ error: "inbox_full" });
576 }),
577 );
578 });
579});