Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brown-ties-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/infra": minor
---

🧱 setup services for api and hooks
5 changes: 5 additions & 0 deletions .changeset/yellow-corners-bet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ add runtime entrypoints for api and hooks
51 changes: 51 additions & 0 deletions infra/utils/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,61 @@ export default define({
common: ["redis-url", "sentry-dsn"],
crema: ["redis-address", "redis-password", "redis-username"],
Comment on lines 2 to 3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add trailing commas to compact configuration literals.

Several arrays and inline object literals omit an internal trailing comma. Add trailing commas consistently in this TypeScript file.

As per coding guidelines, use trailing commas in all structures. Based on learnings, this applies to TypeScript files.

Proposed formatting change
-  common: ["redis-url", "sentry-dsn"],
+  common: ["redis-url", "sentry-dsn",],

Also applies to: 29-30, 32-33, 36-37, 40-41, 44-46, 58-58, 62-75

Sources: Coding guidelines, Learnings

services: {
api: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass the deployment domain to the new API service

When the production or base-sepolia Cloud Run API receives browser traffic, its container has APP_STACK but no APP_DOMAIN (infra/index.ts:64-68), so common/domain.js falls back to sandbox.exactly.app. The API consequently configures CORS, CSRF, Better Auth, and Persona redirects for the sandbox origin, causing requests from web.exactly.app or base-sepolia.exactly.app to be rejected or redirected incorrectly; provide the stack's actual domain to this service at runtime.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide the activity webhook ID to the standalone API

When enqueueing an account subscription fails during a Redis outage, server/workers/subscribe/queue.ts falls back to calling Alchemy with the module-level webhookId. In the monolith this value was initialized by the colocated activity hook or ALCHEMY_ACTIVITY_ID, but the new API runs in a separate process and this service definition supplies neither; the fallback therefore throws no active webhook, and createCredential suppresses that failure, leaving the new account unsubscribed from activity events. Pass the existing alchemyActivityId config to the API service as well as the subscribe worker.

Useful? React with 👍 / 👎.

Comment on lines +4 to +5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Provision module prerequisites before deploying services

On the first deployment containing this services block, every service lookup in infra/index.ts:54 requires a ${stack}-${name} service account, and its IAM resources require the newly named secrets and signer keys; those resources are created only by the standalone infra/meta.ts program. I checked .github/workflows/server-deploy.yaml:39-41, and the deployment selects the ordinary stack and runs pulumi up without invoking the meta program, so repository-managed stacks cannot create these Cloud Run services unless an operator performs an undocumented out-of-band meta update first. Run the meta provisioning step before the ordinary stack deployment or create these prerequisites in the same automated flow.

AGENTS.md reference: AGENTS.md:L129-L131

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Forward the production domain into Pulumi

On a freshly managed production stack, .github/workflows/server-production.yaml:7-11 passes web.exactly.app to the reusable deployment, but .github/workflows/server-deploy.yaml:41 never forwards that input as exa:domain; therefore infra/index.ts:102 gives every newly enabled service APP_DOMAIN=production.exactly.app. The API then configures CORS, CSRF, passkeys, and cookies for the wrong host. The fresh evidence beyond the earlier missing-variable comment is that APP_DOMAIN now exists, but it is populated from an unset Pulumi configuration rather than the workflow's domain input.

Useful? React with 👍 / 👎.

secrets: [
"auth-secret",
Comment on lines +6 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Populate the newly namespaced service secrets

On a freshly provisioned stack, every namespaced value generated from these lists (for example, api-auth-secret) has no readable version: infra/meta.ts:203-213 creates only secretmanager.Secret containers, while each entrypoint immediately requests versions/latest through server/utils/secret.ts:5-10. Consequently all new API and hook processes fail during startup unless an operator manually copies every existing credential into the new names. Fresh evidence beyond the previously flagged meta prerequisite is that running the meta program still creates no SecretVersion; add an automated secret migration or version-provisioning step before deploying these services.

Useful? React with 👍 / 👎.

"bridge-api-key",
"intercom-identity-key",
"manteca-api-key",
"panda-api-key",
"pax-associate-id-key",
"pax-api-key",
"persona-api-key",
"postgres-url",
"sardine-api-key",
"segment-write-key",
"wallet-extension-secret",
],
shared: [
"bridge-api-url",
"manteca-api-url",
"panda-api-url",
"pax-api-url",
"persona-api-url",
"sardine-api-url",
],
},
activity: { secrets: ["alchemy-webhooks-key", "onesignal-api-key", "postgres-url"] },
block: { secrets: ["alchemy-webhooks-key", "onesignal-api-key"], signers: ["executor"] },
Comment on lines +29 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route Alchemy callbacks to the new hook services

When the GCP deployment enables these activity and block services, their standalone Hono apps listen on /, but server/hooks/activity.ts:214 and server/hooks/block.ts:625 still register callbacks at ${appOrigin}/hooks/activity and ${appOrigin}/hooks/block. I checked the deployment wiring: .do/app.yaml:45-50 continues routing those paths to the existing DigitalOcean server, while infra/index.ts:106-128 creates isolated Cloud Run services without any domain or path routing. Consequently the new instances never receive the Alchemy events they initialize themselves for; route these callback paths to the Cloud Run services or register their actual service URLs before enabling them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep scheduled block work on an always-running process

When a proposal or withdrawal is scheduled for a future unlock, server/hooks/block.ts:482 and server/hooks/block.ts:598 rely on in-process timers after the originating webhook response has finished. This definition moves that hook to a request-driven Cloud Run service whose minimum instance count is optional and whose container is not configured for always-allocated CPU; after it becomes idle, the instance may be suspended or scaled to zero, so the timer may not execute until an unrelated matching block event cold-starts the service and reloads Redis. Run this scheduler as an always-on worker or configure durable scheduled execution rather than attaching it to the webhook service lifecycle.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fund the new transaction-signing KMS accounts

On a freshly provisioned stack, this generated executor key—and the settler key configured below—has no native balance: infra/meta.ts:189-201 only creates the KMS keys, and a repo-wide search found no step that funds their derived addresses. Both hooks pass these accounts to createWallet, whose exaSend path prepares and broadcasts ordinary EIP-1559 transactions (server/utils/wallet.ts:117-160), so even after fixing the already-flagged contract permissions, block execution and Panda collection transactions fail for insufficient gas. Fund the new addresses during migration or retain funded signer accounts.

Useful? React with 👍 / 👎.

bridge: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove Redis access from hooks that do not use it

Provisioning either the bridge or manteca service implicitly appends every entry from common to its secret list in modules() at lines 103-105, so both public-facing service accounts receive Secret Manager access to redis-url even though their entrypoints and hook constructors never read Redis. A compromise of either webhook service can therefore disclose the shared Redis credentials and access unrelated queues and application data; add redis-url only to the services that consume it or split the common secret set by module type.

Useful? React with 👍 / 👎.

secrets: ["bridge-api-key", "onesignal-api-key", "persona-api-key", "postgres-url", "segment-write-key"],
shared: ["bridge-api-url", "persona-api-url"],
},
chat: {
env: { WHATSAPP_PHONE_NUMBER_ID: "whatsappPhoneNumberId" },
secrets: ["whatsapp-app-secret", "whatsapp-verify-token"],
},
manteca: {
secrets: ["manteca-api-key", "onesignal-api-key", "postgres-url", "segment-write-key", "webhooks-key"],
shared: ["manteca-api-url"],
},
panda: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep post-response hook work alive

When callbacks are directed to these standalone Cloud Run services, request-based CPU can be suspended immediately after a handler responds because the service template does not enable always-allocated CPU. This is distinct from the already-flagged block timers: the newly inspected Panda and Persona handlers acknowledge webhooks while unawaited work is still running—for example, server/hooks/panda.ts:150-154 leaves event publication pending, and server/hooks/persona.ts:462-494 leaves Pax enrollment and document upload pending—so those side effects can be abandoned despite the provider receiving a 200 response. Await this work, move it to a durable queue, or configure instance-based CPU allocation for the affected hook services.

Useful? React with 👍 / 👎.

secrets: ["onesignal-api-key", "panda-api-key", "postgres-url", "sardine-api-key", "segment-write-key"],
shared: ["panda-api-url", "sardine-api-url"],
signers: ["settler", "issuer"],
},
persona: {
secrets: [
"panda-api-key",
"pax-associate-id-key",
"pax-api-key",
"persona-api-key",
"persona-webhook-secret",
"postgres-url",
"sardine-api-key",
],
shared: ["panda-api-url", "pax-api-url", "persona-api-url", "sardine-api-url"],
},
},
workers: {
allow: { signers: ["allower"] },
Expand Down
95 changes: 95 additions & 0 deletions server/api/bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { drizzle } from "drizzle-orm/node-postgres";
import { Redis } from "ioredis";

import api from ".";
import * as schema from "../database/schema";
import supervise, { own } from "../supervise";
import createIntercom from "../utils/intercom";
import createPanda from "../utils/panda";
import createPax from "../utils/pax";
import createPersona from "../utils/persona";
import createBridge from "../utils/ramps/bridge";
import createManteca from "../utils/ramps/manteca";
import createSardine from "../utils/sardine";
import secret from "../utils/secret";
import createSegment from "../utils/segment";
import createWalletExtension from "../utils/walletExtension";
import createCredit from "../workers/credit/queue";
import createSubscribe from "../workers/subscribe/queue";
import { connect } from "../workers/worker";

const secrets = new SecretManagerServiceClient();

supervise(
"api",
Promise.all([
secret("redis-url", secrets).then((url) => {
const bullmq = connect(url);
return [new Redis(url), bullmq, createCredit(bullmq), createSubscribe(bullmq)] as const;
}),
secret("api-auth-secret", secrets),
Promise.all([secret("api-bridge-api-key", secrets), secret("bridge-api-url", secrets)]).then(([key, url]) =>
createBridge(key, url),
),
secret("api-postgres-url", secrets).then((url) => drizzle(url, { schema })),
secret("api-intercom-identity-key", secrets).then((key) => createIntercom(key)),
Promise.all([secret("api-manteca-api-key", secrets), secret("manteca-api-url", secrets)]).then(([key, url]) =>
createManteca(key, url),
),
Promise.all([secret("api-panda-api-key", secrets), secret("panda-api-url", secrets)]).then(([key, url]) =>
createPanda({ key, url }),
),
Promise.all([
secret("api-pax-associate-id-key", secrets),
secret("api-pax-api-key", secrets),
secret("pax-api-url", secrets),
]).then(([associateKey, key, url]) => createPax({ associateKey, key, url })),
Promise.all([secret("api-persona-api-key", secrets), secret("persona-api-url", secrets)]).then(([key, url]) =>
createPersona(key, url),
),
Promise.all([secret("api-sardine-api-key", secrets), secret("sardine-api-url", secrets)]).then(([key, url]) =>
createSardine(key, url),
),
secret("api-segment-write-key", secrets).then((key) => createSegment(key)),
secret("api-wallet-extension-secret", secrets).then((value) => createWalletExtension(value)),
]).then(
([
[redis, bullmq, credit, subscribe],
authSecret,
bridge,
database,
intercom,
manteca,
panda,
pax,
persona,
sardine,
segment,
walletExtension,
]) =>
own(
api({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mount the standalone API at its public /api prefix

Serve the API under /api rather than passing its inner Hono app directly to supervise. The client constructs every request with the /api prefix (src/utils/server.ts:74), and the monolith preserves that contract by mounting this app at /api (server/index.ts:157); the Cloud Run service wiring in infra/index.ts provides no prefix rewrite. Consequently, routing existing client traffic to this entrypoint would send paths such as /api/card/... to an app that only defines /card/..., producing 404 responses for the entire API. .agents/rules/server.mdL42-L45

Useful? React with 👍 / 👎.

authSecret,
bridge,
credit,
database,
intercom,
manteca,
panda,
pax,
persona,
redis,
sardine,
segment,
subscribe,
walletExtension,
}),
() => database.$client.end(),
() => redis.quit(),
() => secrets.close(),
() => segment.close(),
() => Promise.all([credit.close(), subscribe.close()]).finally(() => bullmq.quit()),
),
),
);
34 changes: 34 additions & 0 deletions server/hooks/bin/activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { drizzle } from "drizzle-orm/node-postgres";
import { Redis } from "ioredis";

import * as schema from "../../database/schema";
import supervise, { own } from "../../supervise";
import createAlchemy from "../../utils/alchemy";
import createOnesignal from "../../utils/onesignal";
import secret from "../../utils/secret";
import createPoke from "../../workers/poke/queue";
import { connect } from "../../workers/worker";
import activity from "../activity";

const secrets = new SecretManagerServiceClient();

supervise(
"activity",
Promise.all([
secret("activity-alchemy-webhooks-key", secrets).then((key) => createAlchemy(key)),
secret("activity-postgres-url", secrets).then((url) => drizzle(url, { schema })),
secret("activity-onesignal-api-key", secrets).then((key) => createOnesignal(key)),
secret("redis-url", secrets)
.then((url) => [new Redis(url), connect(url)] as const)
.then(([redis, bullmq]) => [redis, bullmq, createPoke(bullmq)] as const),
]).then(([alchemy, database, onesignal, [redis, bullmq, poke]]) =>
own(
activity({ alchemy, database, onesignal, poke, redis }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain the activity signing key fallback

Pass the configured activity signing key into this hook, or otherwise preserve its startup fallback. The monolith supplies ALCHEMY_ACTIVITY_KEY in server/index.ts:105-108, while server/hooks/activity.ts:190-205 makes Alchemy reconciliation part of ready; this entrypoint supplies no key and infra/utils/modules.ts:31 provisions none. During a transient Alchemy dashboard failure on a Cloud Run cold start, ready rejects and supervise terminates the service even though the existing signing key could continue authenticating callbacks.

Useful? React with 👍 / 👎.

() => database.$client.end(),
() => poke.close().finally(() => bullmq.quit()),
() => redis.quit(),
() => secrets.close(),
),
),
);
30 changes: 30 additions & 0 deletions server/hooks/bin/block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { KeyManagementServiceClient } from "@google-cloud/kms";
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { Redis } from "ioredis";

import supervise, { own } from "../../supervise";
import createAlchemy from "../../utils/alchemy";
import createOnesignal from "../../utils/onesignal";
import secret from "../../utils/secret";
import { signer } from "../../utils/wallet";
import block from "../block";

const kms = new KeyManagementServiceClient();
const secrets = new SecretManagerServiceClient();

supervise(
"block",
Promise.all([
secret("block-alchemy-webhooks-key", secrets).then((key) => createAlchemy(key)),
signer("executor", kms),
secret("block-onesignal-api-key", secrets).then((key) => createOnesignal(key)),
secret("redis-url", secrets).then((url) => new Redis(url)),
]).then(([alchemy, executor, onesignal, redis]) =>
own(
block({ alchemy, executor, onesignal, redis }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain the block signing key fallback

Pass the configured block signing key into this hook, or otherwise preserve its startup fallback. The monolith supplies blockKey in server/index.ts:113, and server/hooks/block.ts:102-104 deliberately tolerates a failed Alchemy reconciliation only when a signing key is already present. This entrypoint supplies no key, while infra/utils/modules.ts:32 provisions none, so a transient Alchemy dashboard outage during a Cloud Run cold start rejects ready and causes supervise to terminate the service even though the existing signing key could continue authenticating callbacks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep webhook reconciliation on the routed block process

When this Cloud Run instance starts after the DigitalOcean server and initializeAlchemy determines that the existing GraphQL webhook needs an update, constructing block(...) creates a webhook with a new signing key and deletes the old webhook (server/hooks/block.ts:625-709). Callbacks still route to the DigitalOcean process, whose in-memory signing-key set is only populated at its own startup, so it rejects every subsequent block callback until it restarts and discovers the replacement key. Reconcile the webhook only from the process receiving /hooks/block, or switch routing and signing keys atomically.

Useful? React with 👍 / 👎.

() => kms.close(),
() => redis.quit(),
() => secrets.close(),
),
),
);
35 changes: 35 additions & 0 deletions server/hooks/bin/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { drizzle } from "drizzle-orm/node-postgres";

import * as schema from "../../database/schema";
import supervise, { own } from "../../supervise";
import createOnesignal from "../../utils/onesignal";
import createPersona from "../../utils/persona";
import createBridge from "../../utils/ramps/bridge";
import secret from "../../utils/secret";
import createSegment from "../../utils/segment";
import bridge from "../bridge";

const secrets = new SecretManagerServiceClient();

supervise(
"bridge",
Promise.all([
Promise.all([secret("bridge-bridge-api-key", secrets), secret("bridge-api-url", secrets)]).then(([key, url]) =>
createBridge(key, url),
),
secret("bridge-postgres-url", secrets).then((url) => drizzle(url, { schema })),
secret("bridge-onesignal-api-key", secrets).then((key) => createOnesignal(key)),
Promise.all([secret("bridge-persona-api-key", secrets), secret("persona-api-url", secrets)]).then(([key, url]) =>
createPersona(key, url),
),
secret("bridge-segment-write-key", secrets).then((key) => createSegment(key)),
]).then(([provider, database, onesignal, persona, segment]) =>
own(
bridge({ bridge: provider, database, onesignal, persona, segment }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the configured Bridge webhook key

When BRIDGE_WEBHOOK_PUBLIC_KEY differs from the bundled domain-based fallback, such as after a Bridge key rotation or on a custom stack, this standalone entrypoint constructs the hook without bridgeWebhookKey. The monolith explicitly passes the configured value in server/index.ts:114-121, while server/hooks/bridge.ts:27-44 otherwise validates signatures with the hardcoded fallback, so legitimate callbacks to the new service receive 401 responses; provision the configured key for this service and pass it here.

Useful? React with 👍 / 👎.

() => database.$client.end(),
() => secrets.close(),
() => segment.close(),
),
),
);
32 changes: 32 additions & 0 deletions server/hooks/bin/manteca.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { drizzle } from "drizzle-orm/node-postgres";

import * as schema from "../../database/schema";
import supervise, { own } from "../../supervise";
import createOnesignal from "../../utils/onesignal";
import createManteca from "../../utils/ramps/manteca";
import secret from "../../utils/secret";
import createSegment from "../../utils/segment";
import manteca from "../manteca";

const secrets = new SecretManagerServiceClient();

supervise(
"manteca",
Promise.all([
secret("manteca-postgres-url", secrets).then((url) => drizzle(url, { schema })),
Promise.all([secret("manteca-manteca-api-key", secrets), secret("manteca-api-url", secrets)]).then(([key, url]) =>
createManteca(key, url),
),
secret("manteca-webhooks-key", secrets),
secret("manteca-onesignal-api-key", secrets).then((key) => createOnesignal(key)),
secret("manteca-segment-write-key", secrets).then((key) => createSegment(key)),
]).then(([database, provider, mantecaWebhookKey, onesignal, segment]) =>
own(
manteca({ database, manteca: provider, mantecaWebhookKey, onesignal, segment }),
() => database.$client.end(),
() => secrets.close(),
() => segment.close(),
),
),
);
48 changes: 48 additions & 0 deletions server/hooks/bin/panda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { KeyManagementServiceClient } from "@google-cloud/kms";
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { drizzle } from "drizzle-orm/node-postgres";

import * as schema from "../../database/schema";
import supervise, { own } from "../../supervise";
import createOnesignal from "../../utils/onesignal";
import createPanda from "../../utils/panda";
import createSardine from "../../utils/sardine";
import secret from "../../utils/secret";
import createSegment from "../../utils/segment";
import { signer } from "../../utils/wallet";
import createHook from "../../workers/hook/queue";
import createRefund from "../../workers/refund/queue";
import { connect } from "../../workers/worker";
import panda from "../panda";

const kms = new KeyManagementServiceClient();
const secrets = new SecretManagerServiceClient();

supervise(
"panda",
Promise.all([
secret("panda-postgres-url", secrets).then((url) => drizzle(url, { schema })),
signer("issuer", kms),
secret("panda-onesignal-api-key", secrets).then((key) => createOnesignal(key)),
Promise.all([secret("panda-panda-api-key", secrets), secret("panda-api-url", secrets)]).then(([key, url]) =>
createPanda({ key, url }),
),
secret("redis-url", secrets)
.then((url) => connect(url))
.then((bullmq) => [bullmq, createRefund(bullmq), createHook(bullmq)] as const),
Promise.all([secret("panda-sardine-api-key", secrets), secret("sardine-api-url", secrets)]).then(([key, url]) =>
createSardine(key, url),
),
secret("panda-segment-write-key", secrets).then((key) => createSegment(key)),
signer("settler", kms),
]).then(([database, issuer, onesignal, provider, [bullmq, refund, webhook], sardine, segment, settler]) =>
own(
panda({ database, issuer, onesignal, panda: provider, refund, sardine, segment, settler, webhook }),
() => database.$client.end(),
() => kms.close(),
() => secrets.close(),
() => segment.close(),
() => Promise.all([refund.close(), webhook.close()]).finally(() => bullmq.quit()),
),
),
);
Loading