From c80fbc2aebab0fe320750aba57a8eec0065920d4 Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Sat, 21 Feb 2026 03:11:56 -0300 Subject: [PATCH 1/5] chore: add docker workflow --- .github/workflows/docker-publish.yml | 121 ++++++++++++++++++++++ apps/api/src/app/health/health.router.ts | 7 ++ apps/api/src/bull-mq/bull-board.router.ts | 2 +- apps/api/src/env.ts | 43 ++++---- apps/api/src/express.ts | 5 +- apps/api/src/lib/logger.ts | 14 ++- apps/api/src/lib/sentry.ts | 8 +- apps/docs/get-started/self-host.mdx | 1 + 8 files changed, 170 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/docker-publish.yml create mode 100644 apps/api/src/app/health/health.router.ts diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..e723ad24 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,121 @@ +name: Build and Publish Docker Image + +on: + workflow_dispatch: + inputs: + version: + description: "Version tag (e.g., 1.0.2)" + required: true + type: string + +env: + IMAGE: sweetr/sweetr + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: sweetr_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + dragonfly: + image: docker.dragonflydb.io/dragonflydb/dragonfly + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + file: ./apps/api/Dockerfile + push: false + load: true + tags: ${{ env.IMAGE }}:test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Start container + run: | + docker run -d --name sweetr-api --network host \ + -e PORT=8000 \ + -e FRONTEND_URL=http://localhost:3000 \ + -e USE_SELF_SIGNED_SSL=false \ + -e JWT_SECRET=test-secret \ + -e DATABASE_URL=postgres://test:test@localhost:5432/sweetr_test \ + -e REDIS_CONNECTION_STRING=redis://localhost:6379 \ + -e GITHUB_CLIENT_SECRET=test \ + -e GITHUB_CLIENT_ID=test \ + -e GITHUB_APP_HANDLE=test \ + -e GITHUB_APP_ID=test \ + -e GITHUB_APP_PRIVATE_KEY=test \ + ${{ env.IMAGE }}:test + + - name: Wait for API to be ready + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:8000/health > /dev/null 2>&1; then + echo "API is healthy" + exit 0 + fi + echo "Waiting for API... (attempt $i/30)" + sleep 2 + done + echo "API failed to start. Container logs:" + docker logs sweetr-api + exit 1 + + publish: + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./apps/api/Dockerfile + push: true + tags: | + ${{ env.IMAGE }}:${{ inputs.version }} + ${{ env.IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/apps/api/src/app/health/health.router.ts b/apps/api/src/app/health/health.router.ts new file mode 100644 index 00000000..7eb17c29 --- /dev/null +++ b/apps/api/src/app/health/health.router.ts @@ -0,0 +1,7 @@ +import { Router } from "express"; + +export const healthRouter = Router(); + +healthRouter.get("/health", (_req, res) => { + res.status(200).json({ status: "ok" }); +}); diff --git a/apps/api/src/bull-mq/bull-board.router.ts b/apps/api/src/bull-mq/bull-board.router.ts index 90b6b7e0..3f67ea73 100644 --- a/apps/api/src/bull-mq/bull-board.router.ts +++ b/apps/api/src/bull-mq/bull-board.router.ts @@ -9,7 +9,7 @@ import { rateLimit } from "express-rate-limit"; export const bullBoardRouter = Router(); -if (env.BULLBOARD_PATH) { +if (env.BULLBOARD_PATH && env.BULLBOARD_USERNAME && env.BULLBOARD_PASSWORD) { const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath(env.BULLBOARD_PATH); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index a5a5c476..3cd0b1c1 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -35,53 +35,48 @@ export const env = envsafe({ }), GITHUB_WEBHOOK_SECRET: str({ desc: "The secret string used to sign GitHub webhooks", - allowEmpty: true, - devDefault: "", + default: "", }), GITHUB_APP_ID: str({ desc: "The application id" }), GITHUB_APP_PRIVATE_KEY: str({ desc: "The application private key" }), STRIPE_API_KEY: str({ desc: "The secret API Key for Stripe access", - allowEmpty: true, - devDefault: "", + default: "", }), STRIPE_WEBHOOK_SECRET: str({ desc: "The secret string used to sign Stripe webhooks", - allowEmpty: true, - devDefault: "", + default: "", }), SLACK_CLIENT_ID: str({ desc: "The public Slack Client ID", - allowEmpty: true, - devDefault: "", + default: "", }), SLACK_CLIENT_SECRET: str({ desc: "The secret API Key for Slack access", - allowEmpty: true, - devDefault: "", + default: "", }), SLACK_WEBHOOK_SECRET: str({ desc: "The secret string used to sign Slack webhooks", - allowEmpty: true, - devDefault: "", + default: "", }), SLACK_INSTALL_NOTIFICATION_WEBHOOK_URL: str({ desc: "The URL to the Slack install notification webhook", - allowEmpty: true, - devDefault: "", + default: "", }), JWT_SECRET: str({ desc: "The secret string used to sign JWT tokens" }), SENTRY_DSN: str({ desc: "The DSN to connect to Sentry project", - allowEmpty: true, + default: "", }), LOG_DRAIN: str({ desc: "The secret string used to sign GitHub webhooks", choices: ["logtail", "console"], - default: "logtail", - devDefault: "console", + default: "console", + }), + LOGTAIL_TOKEN: str({ + desc: "The source token to forward logs to LogTail", + default: "", }), - LOGTAIL_TOKEN: str({ desc: "The source token to forward logs to LogTail" }), USE_SELF_SIGNED_SSL: bool({ desc: "Whether the server should use self-signed certificates generated by devcert (see npm run ssl:generate)", default: false, @@ -95,13 +90,19 @@ export const env = envsafe({ devDefault: "/bullboard", default: "", }), - BULLBOARD_USERNAME: str({ desc: "The username to login to BullBoard." }), - BULLBOARD_PASSWORD: str({ desc: "The password to login to BullBoard." }), + BULLBOARD_USERNAME: str({ + desc: "The username to login to BullBoard.", + default: "", + }), + BULLBOARD_PASSWORD: str({ + desc: "The password to login to BullBoard.", + default: "", + }), EMAIL_ENABLED: bool({ desc: "Whether transactional emails are enabled", default: false, }), - RESEND_API_KEY: str({ desc: "The API Key for Resend.", allowEmpty: true }), + RESEND_API_KEY: str({ desc: "The API Key for Resend.", default: "" }), APP_MODE: str({ desc: "Whether the application is being self-hosted", choices: ["self-hosted", "saas"], diff --git a/apps/api/src/express.ts b/apps/api/src/express.ts index 04d36f7b..3eb8b565 100644 --- a/apps/api/src/express.ts +++ b/apps/api/src/express.ts @@ -9,6 +9,7 @@ import { slackRouter } from "./app/integrations/slack/slack.router"; import cors from "cors"; import { env } from "./env"; import { deploymentsRouter } from "./app/deployment/deployments.router"; +import { healthRouter } from "./app/health/health.router"; export const expressApp = express(); @@ -22,10 +23,10 @@ expressApp }, }) ) - .use(cors({ origin: env.FRONTEND_URL, credentials: true, methods: ["*"] })) - .set("trust proxy", 1); + .use(cors({ origin: env.FRONTEND_URL, credentials: true, methods: ["*"] })); // Route handlers +expressApp.use(healthRouter); expressApp.use(githubRouter); expressApp.use(stripeRouter); expressApp.use(slackRouter); diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts index 61926d06..cb5a670e 100644 --- a/apps/api/src/lib/logger.ts +++ b/apps/api/src/lib/logger.ts @@ -3,13 +3,17 @@ import { env } from "../env"; import { addBreadcrumb } from "@sentry/node"; import { pick } from "radash"; -const logTailStream = pino.transport({ - target: "@logtail/pino", - options: { sourceToken: env.LOGTAIL_TOKEN }, -}); +const logTailStream = + env.LOG_DRAIN === "logtail" && env.LOGTAIL_TOKEN + ? pino.transport({ + target: "@logtail/pino", + options: { sourceToken: env.LOGTAIL_TOKEN }, + }) + : undefined; + const consoleStream = pino.destination(); -const stream = env.LOG_DRAIN === "logtail" ? logTailStream : consoleStream; +const stream = logTailStream || consoleStream; const pinoLogger = pino( { diff --git a/apps/api/src/lib/sentry.ts b/apps/api/src/lib/sentry.ts index 0655d5ea..310e5a95 100644 --- a/apps/api/src/lib/sentry.ts +++ b/apps/api/src/lib/sentry.ts @@ -4,13 +4,17 @@ import { nodeProfilingIntegration } from "@sentry/profiling-node"; import { BaseException } from "../app/errors/exceptions/base.exception"; import { env, isDev } from "../env"; import { logger } from "./logger"; -import { isAppSelfHosted } from "./self-host"; export const initSentry = () => { + if (!env.SENTRY_DSN) { + logger.info("Skipping Sentry initialization. No SENTRY_DSN provided."); + return; + } + Sentry.init({ dsn: env.SENTRY_DSN, environment: env.APP_ENV, - enabled: !isAppSelfHosted() && !isDev && !!env.SENTRY_DSN, + enabled: !isDev, beforeBreadcrumb(breadcrumb) { if (breadcrumb.category === "console") { return null; diff --git a/apps/docs/get-started/self-host.mdx b/apps/docs/get-started/self-host.mdx index 47a10482..84b0f322 100644 --- a/apps/docs/get-started/self-host.mdx +++ b/apps/docs/get-started/self-host.mdx @@ -166,5 +166,6 @@ Update .env to enable: SENTRY_DSN= # LogTail +LOG_DRAIN=logtail LOGTAIL_TOKEN= ``` From d88f3828815f4e2693c3b800ec836acb308efbc8 Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Sat, 21 Feb 2026 03:34:03 -0300 Subject: [PATCH 2/5] fix: address review comments --- .github/workflows/docker-publish.yml | 1 + apps/api/src/env.ts | 3 +-- apps/api/src/express.ts | 8 +++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e723ad24..cf899723 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -73,6 +73,7 @@ jobs: -e GITHUB_APP_HANDLE=test \ -e GITHUB_APP_ID=test \ -e GITHUB_APP_PRIVATE_KEY=test \ + -e GITHUB_WEBHOOK_SECRET=test \ ${{ env.IMAGE }}:test - name: Wait for API to be ready diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 3cd0b1c1..a181c310 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -35,7 +35,6 @@ export const env = envsafe({ }), GITHUB_WEBHOOK_SECRET: str({ desc: "The secret string used to sign GitHub webhooks", - default: "", }), GITHUB_APP_ID: str({ desc: "The application id" }), GITHUB_APP_PRIVATE_KEY: str({ desc: "The application private key" }), @@ -69,7 +68,7 @@ export const env = envsafe({ default: "", }), LOG_DRAIN: str({ - desc: "The secret string used to sign GitHub webhooks", + desc: "The stream to log to", choices: ["logtail", "console"], default: "console", }), diff --git a/apps/api/src/express.ts b/apps/api/src/express.ts index 3eb8b565..85256fff 100644 --- a/apps/api/src/express.ts +++ b/apps/api/src/express.ts @@ -23,7 +23,13 @@ expressApp }, }) ) - .use(cors({ origin: env.FRONTEND_URL, credentials: true, methods: ["*"] })); + .use( + cors({ + origin: env.FRONTEND_URL, + credentials: true, + methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], + }) + ); // Route handlers expressApp.use(healthRouter); From 8998b9244d4ce8abc0e9aeadefe8217bba23186b Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Sat, 21 Feb 2026 03:52:00 -0300 Subject: [PATCH 3/5] fix: address review comments --- apps/api/src/bull-mq/bull-board.router.ts | 2 +- apps/api/src/express.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/src/bull-mq/bull-board.router.ts b/apps/api/src/bull-mq/bull-board.router.ts index 3f67ea73..c04ade82 100644 --- a/apps/api/src/bull-mq/bull-board.router.ts +++ b/apps/api/src/bull-mq/bull-board.router.ts @@ -22,7 +22,7 @@ if (env.BULLBOARD_PATH && env.BULLBOARD_USERNAME && env.BULLBOARD_PASSWORD) { .use( env.BULLBOARD_PATH, rateLimit({ - windowMs: 60, // 15 minutes + windowMs: 15 * 60 * 1000, // 15 minutes max: 200, message: "Too many requests, please try again later.", }) diff --git a/apps/api/src/express.ts b/apps/api/src/express.ts index 85256fff..d4e36af3 100644 --- a/apps/api/src/express.ts +++ b/apps/api/src/express.ts @@ -29,7 +29,8 @@ expressApp credentials: true, methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], }) - ); + ) + .set("trust proxy", 1); // Route handlers expressApp.use(healthRouter); From 631d5f4bd3b0d578ca4dab8490930e0d7c0a7488 Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Sun, 22 Feb 2026 03:26:30 -0300 Subject: [PATCH 4/5] fix: stripe + slack guards --- apps/api/src/app/billing/stripe.router.ts | 12 ++++++++++++ .../slack/services/slack-integration.service.ts | 4 ++++ apps/api/src/express.ts | 8 ++++++-- apps/api/src/lib/stripe.ts | 4 ++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/billing/stripe.router.ts b/apps/api/src/app/billing/stripe.router.ts index 7e6afb7f..68001428 100644 --- a/apps/api/src/app/billing/stripe.router.ts +++ b/apps/api/src/app/billing/stripe.router.ts @@ -16,6 +16,14 @@ export const stripeRouter = Router(); stripeRouter.post( "/stripe/webhook", catchErrors(async (req, res) => { + if (!env.STRIPE_API_KEY) { + throw new Error("STRIPE_API_KEY is not set"); + } + + if (!env.STRIPE_WEBHOOK_SECRET) { + throw new Error("STRIPE_WEBHOOK_SECRET is not set"); + } + const signature = req.get("Stripe-Signature"); try { @@ -40,6 +48,10 @@ stripeRouter.post( "/stripe/checkout", urlencoded({ extended: true }), catchErrors(async (req, res) => { + if (!env.STRIPE_API_KEY) { + throw new Error("STRIPE_API_KEY is not set"); + } + const schema = z.object({ key: z.string(), quantity: z.string(), diff --git a/apps/api/src/app/integrations/slack/services/slack-integration.service.ts b/apps/api/src/app/integrations/slack/services/slack-integration.service.ts index b867d573..b3581596 100644 --- a/apps/api/src/app/integrations/slack/services/slack-integration.service.ts +++ b/apps/api/src/app/integrations/slack/services/slack-integration.service.ts @@ -116,6 +116,10 @@ export const getIntegration = async (workspaceId: number) => { }; export const getInstallUrl = (): string => { + if (!config.slack.clientId) { + throw new Error("SLACK_CLIENT_ID is not set"); + } + const url = new URL("https://slack.com/oauth/v2/authorize"); url.searchParams.append("client_id", config.slack.clientId); diff --git a/apps/api/src/express.ts b/apps/api/src/express.ts index d4e36af3..dde9a6ea 100644 --- a/apps/api/src/express.ts +++ b/apps/api/src/express.ts @@ -10,6 +10,7 @@ import cors from "cors"; import { env } from "./env"; import { deploymentsRouter } from "./app/deployment/deployments.router"; import { healthRouter } from "./app/health/health.router"; +import { isAppSelfHosted } from "./lib/self-host"; export const expressApp = express(); @@ -35,10 +36,13 @@ expressApp // Route handlers expressApp.use(healthRouter); expressApp.use(githubRouter); -expressApp.use(stripeRouter); + +if (!isAppSelfHosted()) { + expressApp.use(stripeRouter); +} + expressApp.use(slackRouter); expressApp.use(bullBoardRouter); - // Customer-facing API expressApp.use(deploymentsRouter); expressApp.use(yoga); // Leave Yoga last diff --git a/apps/api/src/lib/stripe.ts b/apps/api/src/lib/stripe.ts index c2f6c450..b1d72b9e 100644 --- a/apps/api/src/lib/stripe.ts +++ b/apps/api/src/lib/stripe.ts @@ -4,6 +4,10 @@ import { env } from "../env"; let stripeClient: Stripe | null; export const getStripeClient = (): Stripe => { + if (!env.STRIPE_API_KEY) { + throw new Error("STRIPE_API_KEY is not set"); + } + if (stripeClient) return stripeClient; stripeClient = new Stripe(env.STRIPE_API_KEY); From b9cfdb043e3093bebc877a2292a65e3d2b8e946b Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Sun, 22 Feb 2026 03:38:05 -0300 Subject: [PATCH 5/5] fix: exception thrown --- apps/api/src/app/billing/stripe.router.ts | 7 ++++--- .../slack/services/slack-integration.service.ts | 2 +- apps/api/src/lib/stripe.ts | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app/billing/stripe.router.ts b/apps/api/src/app/billing/stripe.router.ts index 68001428..44866b13 100644 --- a/apps/api/src/app/billing/stripe.router.ts +++ b/apps/api/src/app/billing/stripe.router.ts @@ -10,6 +10,7 @@ import { captureException } from "../../lib/sentry"; import { InputValidationException } from "../errors/exceptions/input-validation.exception"; import { z } from "zod"; import { decodeId } from "../../lib/hash-id"; +import { IntegrationException } from "../errors/exceptions/integration.exception"; export const stripeRouter = Router(); @@ -17,11 +18,11 @@ stripeRouter.post( "/stripe/webhook", catchErrors(async (req, res) => { if (!env.STRIPE_API_KEY) { - throw new Error("STRIPE_API_KEY is not set"); + throw new IntegrationException("STRIPE_API_KEY is not set"); } if (!env.STRIPE_WEBHOOK_SECRET) { - throw new Error("STRIPE_WEBHOOK_SECRET is not set"); + throw new IntegrationException("STRIPE_WEBHOOK_SECRET is not set"); } const signature = req.get("Stripe-Signature"); @@ -49,7 +50,7 @@ stripeRouter.post( urlencoded({ extended: true }), catchErrors(async (req, res) => { if (!env.STRIPE_API_KEY) { - throw new Error("STRIPE_API_KEY is not set"); + throw new IntegrationException("STRIPE_API_KEY is not set"); } const schema = z.object({ diff --git a/apps/api/src/app/integrations/slack/services/slack-integration.service.ts b/apps/api/src/app/integrations/slack/services/slack-integration.service.ts index b3581596..c207656a 100644 --- a/apps/api/src/app/integrations/slack/services/slack-integration.service.ts +++ b/apps/api/src/app/integrations/slack/services/slack-integration.service.ts @@ -117,7 +117,7 @@ export const getIntegration = async (workspaceId: number) => { export const getInstallUrl = (): string => { if (!config.slack.clientId) { - throw new Error("SLACK_CLIENT_ID is not set"); + throw new IntegrationException("SLACK_CLIENT_ID is not set"); } const url = new URL("https://slack.com/oauth/v2/authorize"); diff --git a/apps/api/src/lib/stripe.ts b/apps/api/src/lib/stripe.ts index b1d72b9e..b3503c66 100644 --- a/apps/api/src/lib/stripe.ts +++ b/apps/api/src/lib/stripe.ts @@ -1,11 +1,12 @@ import Stripe from "stripe"; import { env } from "../env"; +import { IntegrationException } from "../app/errors/exceptions/integration.exception"; let stripeClient: Stripe | null; export const getStripeClient = (): Stripe => { if (!env.STRIPE_API_KEY) { - throw new Error("STRIPE_API_KEY is not set"); + throw new IntegrationException("STRIPE_API_KEY is not set"); } if (stripeClient) return stripeClient;