Skip to content
Merged
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
122 changes: 122 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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 \
-e GITHUB_WEBHOOK_SECRET=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
Comment on lines +93 to +122
Copy link
Contributor

Choose a reason for hiding this comment

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

Publish job rebuilds instead of reusing validated image. The validate job builds and tests the image, but the publish job builds a completely new image from source. Even with GHA cache, a cache miss or non-deterministic build step could mean the published image differs from the one validated. Consider saving the validated image as an artifact (e.g., via docker save / docker load or a shared registry tag) and pushing that instead.

13 changes: 13 additions & 0 deletions apps/api/src/app/billing/stripe.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,21 @@ 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();

stripeRouter.post(
"/stripe/webhook",
catchErrors(async (req, res) => {
if (!env.STRIPE_API_KEY) {
throw new IntegrationException("STRIPE_API_KEY is not set");
}

if (!env.STRIPE_WEBHOOK_SECRET) {
throw new IntegrationException("STRIPE_WEBHOOK_SECRET is not set");
}

const signature = req.get("Stripe-Signature");

try {
Expand All @@ -40,6 +49,10 @@ stripeRouter.post(
"/stripe/checkout",
urlencoded({ extended: true }),
catchErrors(async (req, res) => {
if (!env.STRIPE_API_KEY) {
throw new IntegrationException("STRIPE_API_KEY is not set");
}

const schema = z.object({
key: z.string(),
quantity: z.string(),
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/app/health/health.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Router } from "express";

export const healthRouter = Router();

healthRouter.get("/health", (_req, res) => {
res.status(200).json({ status: "ok" });
});
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ export const getIntegration = async (workspaceId: number) => {
};

export const getInstallUrl = (): string => {
if (!config.slack.clientId) {
throw new IntegrationException("SLACK_CLIENT_ID is not set");
}

const url = new URL("https://slack.com/oauth/v2/authorize");

url.searchParams.append("client_id", config.slack.clientId);
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/bull-mq/bull-board.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -22,7 +22,7 @@ if (env.BULLBOARD_PATH) {
.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.",
})
Expand Down
44 changes: 22 additions & 22 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,53 +35,47 @@ export const env = envsafe({
}),
GITHUB_WEBHOOK_SECRET: str({
desc: "The secret string used to sign GitHub webhooks",
allowEmpty: true,
devDefault: "",
}),
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: "",
}),
Comment on lines 41 to 64
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Search for Stripe client/SDK initialization
rg -nP --type=ts "stripe|STRIPE" --max-count=200 | head -100

Repository: sweetr-dev/sweetr.dev

Length of output: 3276


🏁 Script executed:

# Search for Slack client/SDK initialization  
rg -nP --type=ts "slack|SLACK" --max-count=200 | head -100

Repository: sweetr-dev/sweetr.dev

Length of output: 11198


🏁 Script executed:

cat -n apps/api/src/lib/stripe.ts

Repository: sweetr-dev/sweetr.dev

Length of output: 407


🏁 Script executed:

cat -n apps/api/src/app/integrations/slack/services/slack-integration.service.ts | head -120

Repository: sweetr-dev/sweetr.dev

Length of output: 4003


🏁 Script executed:

cat -n apps/api/src/config/slack.config.ts

Repository: sweetr-dev/sweetr.dev

Length of output: 542


🏁 Script executed:

cat -n apps/api/src/app/billing/stripe.router.ts

Repository: sweetr-dev/sweetr.dev

Length of output: 2179


🏁 Script executed:

# Check if stripe client is instantiated conditionally anywhere
rg -n "getStripeClient" --type=ts -B 3 -A 3

Repository: sweetr-dev/sweetr.dev

Length of output: 6037


🏁 Script executed:

# Find getSlackClient definition
rg -n "export.*getSlackClient" --type=ts -A 10 apps/api/src/app/integrations/slack/services/slack-client.service.ts

Repository: sweetr-dev/sweetr.dev

Length of output: 459


🏁 Script executed:

# Check where getInstallUrl is called and if it has guards
rg -n "getInstallUrl" --type=ts -B 5 -A 5

Repository: sweetr-dev/sweetr.dev

Length of output: 3428


🏁 Script executed:

# Check if Stripe is gated anywhere or if billing is feature-flagged
rg -n "STRIPE_API_KEY|getStripeClient" --type=ts -B 5 -A 5 | grep -E "(if|guard|feature|enabled|check)" -B 3 -A 3

Repository: sweetr-dev/sweetr.dev

Length of output: 1896


Add guards to Stripe and Slack initialization code before instantiating SDKs with potentially empty credentials.

Stripe's getStripeClient() currently instantiates the SDK unconditionally (apps/api/src/lib/stripe.ts:9), and all routes call it without checking if STRIPE_API_KEY is configured. Similarly, Slack's getInstallUrl() (slack-integration.service.ts:118) builds an OAuth URL without verifying clientId is non-empty, even though getIntegration() has such a guard. With both integrations now defaulting to empty strings in env.ts, these SDKs will fail at runtime with cryptic errors instead of gracefully disabling. Add checks like the ones in sentry.ts, logger.ts, and bull-board.router.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/env.ts` around lines 41 - 64, Add presence checks for
credentials before creating SDK instances: in getStripeClient() verify
env.STRIPE_API_KEY is non-empty and return null/undefined (or throw a clear,
documented error) instead of instantiating Stripe with an empty key, and update
callers to guard against a null client; in getInstallUrl() verify
env.SLACK_CLIENT_ID (the clientId used by getInstallUrl()/getIntegration()) is
non-empty before building the OAuth URL and return a disabled/no-op response
when missing. Follow the same guard pattern used in sentry.ts, logger.ts, and
bull-board.router.ts so integrations are gracefully disabled when STRIPE_API_KEY
or SLACK_CLIENT_ID are empty.

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",
desc: "The stream to log to",
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,
Expand All @@ -95,13 +89,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"],
Expand Down
18 changes: 15 additions & 3 deletions apps/api/src/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ 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";
import { isAppSelfHosted } from "./lib/self-host";

export const expressApp = express();

Expand All @@ -22,15 +24,25 @@ 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"],
})
)
.set("trust proxy", 1);

// 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
Expand Down
14 changes: 9 additions & 5 deletions apps/api/src/lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/lib/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/lib/stripe.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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 IntegrationException("STRIPE_API_KEY is not set");
}

if (stripeClient) return stripeClient;

stripeClient = new Stripe(env.STRIPE_API_KEY);
Expand Down
1 change: 1 addition & 0 deletions apps/docs/get-started/self-host.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -166,5 +166,6 @@ Update .env to enable:
SENTRY_DSN=

# LogTail
LOG_DRAIN=logtail
LOGTAIL_TOKEN=
```
Loading