From bd840b4f201de374a18d9b2f9eb879450e91055a Mon Sep 17 00:00:00 2001 From: raedbrahem Date: Thu, 23 Jul 2026 11:36:22 +0000 Subject: [PATCH 1/7] Enable self-hosted deployment with BunMail, SMTP, and Docker fixes. Add BunMail REST and SMTP email transports with direct send fallback when Trigger.dev is unset, wire the API service into docker-compose, and fix Prisma TLS for local Postgres hostnames. --- Dockerfile | 60 ++++-- apps/api/.env.example | 13 ++ apps/api/package.json | 2 + apps/api/prisma/client.ts | 4 +- apps/api/src/email/email-transport.ts | 243 +++++++++++++++++++++++ apps/api/src/email/trigger-email.ts | 48 ++++- apps/api/src/trigger/email/send-email.ts | 73 +------ apps/app/prisma/client.ts | 4 +- apps/portal/prisma/client.ts | 4 +- docker-compose.yml | 52 ++++- packages/db/src/client.ts | 2 +- packages/db/src/ssl-config.ts | 2 +- 12 files changed, 405 insertions(+), 102 deletions(-) create mode 100644 apps/api/src/email/email-transport.ts diff --git a/Dockerfile b/Dockerfile index d7904cc76e..38a8ca8500 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,11 @@ WORKDIR /app COPY package.json bun.lock ./ # Copy package.json files for all packages (exclude local db; use published @trycompai/db) +COPY packages/auth/package.json ./packages/auth/ +COPY packages/billing/package.json ./packages/billing/ +COPY packages/company/package.json ./packages/company/ +COPY packages/db/package.json ./packages/db/ + COPY packages/kv/package.json ./packages/kv/ COPY packages/ui/package.json ./packages/ui/ COPY packages/email/package.json ./packages/email/ @@ -26,28 +31,29 @@ COPY apps/portal/package.json ./apps/portal/ RUN PRISMA_SKIP_POSTINSTALL_GENERATE=true bun install --ignore-scripts # ============================================================================= -# STAGE 2: Ultra-Minimal Migrator - Only Prisma +# STAGE 2: Migrator - built from local db source (not published npm package) # ============================================================================= -FROM oven/bun:1.2.8 AS migrator +FROM deps AS migrator WORKDIR /app -# Copy local Prisma schema and migrations from workspace -COPY packages/db/prisma ./packages/db/prisma - -# Create minimal package.json for Prisma runtime (also used by seeder) -RUN echo '{"name":"migrator","type":"module","dependencies":{"prisma":"^6.14.0","@prisma/client":"^6.14.0","@trycompai/db":"^1.3.4","zod":"^3.25.7"}}' > package.json +# Copy full local db package source (schema, scripts, seed data, prisma files) +COPY packages/db ./packages/db -# Install ONLY Prisma dependencies -RUN bun install +# Build local db package: generates Prisma Client from local schema files +# AND builds the combined dist/schema.prisma - both from source, not npm +RUN cd packages/db && bun run build -# Ensure Prisma can find migrations relative to the published schema path -# We copy the local migrations into the published package's dist directory -RUN cp -R packages/db/prisma/migrations node_modules/@trycompai/db/dist/ +# Install Node.js (Bun's WASM engine has a known crash bug with Prisma 7's +# query compiler - see https://github.com/prisma/prisma/issues/28805 and +# https://github.com/oven-sh/bun/issues/17146). Run seed under Node instead. +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g tsx -# Run migrations against the combined schema published by @trycompai/db -RUN echo "Running migrations against @trycompai/db combined schema" -CMD ["bunx", "prisma", "migrate", "deploy", "--schema=node_modules/@trycompai/db/dist/schema.prisma"] +CMD ["sh", "-lc", "cd packages/db && bunx prisma migrate deploy"] # ============================================================================= # STAGE 3: App Builder @@ -68,8 +74,13 @@ COPY --from=deps /app/node_modules ./node_modules # `--ignore-scripts` so packages/db's postinstall was skipped; we run # it explicitly here so `next build` can resolve the generated runtime # + types when it imports @prisma/client. -RUN cd packages/db && node scripts/combine-schemas.js \ - && node scripts/generate-prisma-client-js.js +# Build local workspace packages in dependency order (db first, others depend on it) +RUN cd packages/db && bun run build +RUN cd packages/auth && bun run build +RUN cd packages/company && bun run build +RUN cd packages/billing && bun run build + +RUN cd apps/app && bun run db:getschema # Ensure Next build has required public env at build-time ARG NEXT_PUBLIC_BETTER_AUTH_URL @@ -104,7 +115,7 @@ COPY --from=app-builder /app/apps/app/.next/static ./apps/app/.next/static COPY --from=app-builder /app/apps/app/public ./apps/app/public EXPOSE 3000 -CMD ["node", "apps/app/server.js"] +CMD ["node", "--max-old-space-size=8192", "apps/app/server.js"] # ============================================================================= # STAGE 5: Portal Builder @@ -119,14 +130,21 @@ COPY apps/portal ./apps/portal # Bring in node_modules for build and prisma prebuild COPY --from=deps /app/node_modules ./node_modules +# Build local workspace packages in dependency order (db first, others depend on it) +RUN cd packages/db && bun run build + +RUN cd packages/auth && bun run build +RUN cd packages/company && bun run build +RUN cd packages/billing && bun run build # Pre-combine schemas for portal build -RUN cd packages/db && node scripts/combine-schemas.js -RUN cp packages/db/dist/schema.prisma apps/portal/prisma/schema.prisma +RUN cd apps/portal && bun run db:getschema # Ensure Next build has required public env at build-time ARG NEXT_PUBLIC_BETTER_AUTH_URL +ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL \ + NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ NEXT_TELEMETRY_DISABLED=1 NODE_ENV=production \ NEXT_OUTPUT_STANDALONE=true \ NODE_OPTIONS=--max_old_space_size=6144 @@ -147,6 +165,6 @@ COPY --from=portal-builder /app/apps/portal/.next/static ./apps/portal/.next/sta COPY --from=portal-builder /app/apps/portal/public ./apps/portal/public EXPOSE 3000 -CMD ["node", "apps/portal/server.js"] +CMD ["node", "--max-old-space-size=8192", "apps/portal/server.js"] # (Trigger.dev hosted; no local runner stage) diff --git a/apps/api/.env.example b/apps/api/.env.example index 8913358e40..56c8f65a57 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -97,3 +97,16 @@ SECURITY_HUB_GOVCLOUD_ACCESS_KEY_ID= SECURITY_HUB_GOVCLOUD_SECRET_ACCESS_KEY= # Optional: only set when using temporary GovCloud credentials. Leave unset for long-lived IAM user keys. # SECURITY_HUB_GOVCLOUD_SESSION_TOKEN= + +# SMTP (optional — if SMTP_HOST is set, Resend is ignored) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_SECURE=false +SMTP_FROM=noreply@yourdomain.com + +# BunMail (optional — REST email API; takes priority over SMTP/Resend) +BUNMAIL_API_URL= +BUNMAIL_API_KEY= +BUNMAIL_FROM=noreply@yourdomain.com diff --git a/apps/api/package.json b/apps/api/package.json index 27bca8836d..92262cddd3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -116,6 +116,8 @@ "react-dom": "^19.1.0", "reflect-metadata": "^0.2.2", "resend": "^6.4.2", +"nodemailer": "^6.10.1", +"@types/nodemailer": "^6.4.17", "rxjs": "^7.8.1", "safe-stable-stringify": "^2.5.0", "stripe": "^20.4.0", diff --git a/apps/api/prisma/client.ts b/apps/api/prisma/client.ts index 5f3c1738d2..3c43ad10e5 100644 --- a/apps/api/prisma/client.ts +++ b/apps/api/prisma/client.ts @@ -3,7 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; const globalForPrisma = global as unknown as { prisma?: PrismaClient }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); function stripSslMode(connectionString: string): string { const url = new URL(connectionString); @@ -61,7 +61,7 @@ function createPrismaClient(): PrismaClient { } // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; - const adapter = new PrismaPg({ connectionString: url, ssl }); + const adapter = new PrismaPg({ connectionString: url, ssl: ssl ?? false }); return new PrismaClient({ adapter, transactionOptions: { diff --git a/apps/api/src/email/email-transport.ts b/apps/api/src/email/email-transport.ts new file mode 100644 index 0000000000..b36510c940 --- /dev/null +++ b/apps/api/src/email/email-transport.ts @@ -0,0 +1,243 @@ +import nodemailer from 'nodemailer'; +import type Mail from 'nodemailer/lib/mailer'; +import { resend } from './resend'; + +export interface EmailAttachment { + filename: string; + content: Buffer | string; + contentType?: string; +} + +export type EmailChannel = 'marketing' | 'system' | 'trustPortal' | 'default'; + +export function isBunMailConfigured(): boolean { + return Boolean(process.env.BUNMAIL_API_URL?.trim()); +} + +export function isSmtpConfigured(): boolean { + return Boolean(process.env.SMTP_HOST?.trim()); +} + +export function resolveFromAddressForChannel( + channel: EmailChannel | undefined, +): string | undefined { + const bunMailFrom = process.env.BUNMAIL_FROM?.trim(); + if (bunMailFrom) return bunMailFrom; + + const smtpFrom = process.env.SMTP_FROM?.trim(); + if (smtpFrom) return smtpFrom; + + const fromMarketing = process.env.RESEND_FROM_MARKETING; + const fromSystem = process.env.RESEND_FROM_SYSTEM; + const fromDefault = process.env.RESEND_FROM_DEFAULT; + const fromTrustPortal = process.env.RESEND_FROM_TRUST_PORTAL; + + switch (channel) { + case 'trustPortal': + return fromTrustPortal ?? fromSystem; + case 'marketing': + return fromMarketing; + case 'system': + return fromSystem; + case 'default': + return fromDefault; + default: + return undefined; + } +} + +function resolveSmtpTransport() { + const host = process.env.SMTP_HOST!.trim(); + const port = Number(process.env.SMTP_PORT || 587); + const secure = + process.env.SMTP_SECURE === 'true' || + process.env.SMTP_SECURE === '1' || + port === 465; + const user = process.env.SMTP_USER?.trim(); + const pass = process.env.SMTP_PASS; + + return nodemailer.createTransport({ + host, + port, + secure, + auth: user ? { user, pass: pass ?? '' } : undefined, + }); +} + +function normalizeAttachments( + attachments?: EmailAttachment[], +): Mail.Attachment[] | undefined { + return attachments?.map((att) => ({ + filename: att.filename, + content: att.content, + contentType: att.contentType, + })); +} + +async function sendViaBunMail(params: { + from: string; + to: string; + subject: string; + html: string; + cc?: string | string[]; +}): Promise<{ id: string }> { + const apiUrl = process.env.BUNMAIL_API_URL!.trim().replace(/\/$/, ''); + const apiKey = process.env.BUNMAIL_API_KEY?.trim(); + if (!apiKey) { + throw new Error('BUNMAIL_API_KEY is required when BUNMAIL_API_URL is set'); + } + + const cc = Array.isArray(params.cc) ? params.cc.join(',') : params.cc; + + const response = await fetch(`${apiUrl}/api/v1/emails/send`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: params.from, + to: params.to, + cc, + subject: params.subject, + html: params.html, + }), + }); + + const body = (await response.json().catch(() => null)) as + | { success?: boolean; data?: { id?: string }; error?: string; message?: string } + | null; + + if (!response.ok || !body?.success) { + const message = + body?.error || body?.message || `BunMail API error (${response.status})`; + throw new Error(message); + } + + return { id: body.data?.id ?? 'bunmail' }; +} + +async function sendViaSmtp(params: { + from: string; + to: string; + subject: string; + html: string; + cc?: string | string[]; + headers?: Record; + attachments?: EmailAttachment[]; +}): Promise<{ id: string }> { + const transport = resolveSmtpTransport(); + const info = await transport.sendMail({ + from: params.from, + to: params.to, + cc: params.cc, + subject: params.subject, + html: params.html, + headers: params.headers, + attachments: normalizeAttachments(params.attachments), + }); + + return { id: info.messageId || 'smtp' }; +} + +async function sendViaResend(params: { + from: string; + to: string; + subject: string; + html: string; + cc?: string | string[]; + headers?: Record; + scheduledAt?: string; + attachments?: EmailAttachment[]; +}): Promise<{ id: string }> { + if (!resend) { + throw new Error( + 'Email not configured: set BUNMAIL_API_URL, SMTP_HOST, or RESEND_API_KEY in environment variables', + ); + } + + const { data, error } = await resend.emails.send({ + from: params.from, + to: params.to, + cc: params.cc, + subject: params.subject, + html: params.html, + headers: params.headers, + scheduledAt: params.scheduledAt, + attachments: params.attachments?.map((att) => ({ + filename: att.filename, + content: att.content, + contentType: att.contentType, + })), + }); + + if (error) { + console.error('Resend API error:', error); + throw new Error(`Failed to send email: ${error.message}`); + } + + return { id: data?.id ?? 'resend' }; +} + +export async function sendHtmlEmail(params: { + to: string; + subject: string; + html: string; + channel?: EmailChannel; + from?: string; + cc?: string | string[]; + headers?: Record; + scheduledAt?: string; + attachments?: EmailAttachment[]; +}): Promise<{ id: string }> { + const fromAddress = + params.from ?? + resolveFromAddressForChannel(params.channel) ?? + process.env.BUNMAIL_FROM ?? + process.env.SMTP_FROM ?? + process.env.RESEND_FROM_SYSTEM ?? + process.env.RESEND_FROM_DEFAULT; + const toAddress = process.env.RESEND_TO_TEST ?? params.to; + + if (!fromAddress) { + throw new Error( + 'Missing FROM address: set BUNMAIL_FROM, SMTP_FROM, or RESEND_FROM_DEFAULT in environment variables', + ); + } + if (!toAddress) { + throw new Error('Missing TO address in environment variables'); + } + + if (isBunMailConfigured()) { + return sendViaBunMail({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + }); + } + + if (isSmtpConfigured()) { + return sendViaSmtp({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + headers: params.headers, + attachments: params.attachments, + }); + } + + return sendViaResend({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + headers: params.headers, + scheduledAt: params.scheduledAt, + attachments: params.attachments, + }); +} diff --git a/apps/api/src/email/trigger-email.ts b/apps/api/src/email/trigger-email.ts index 79866968e2..f078e3528d 100644 --- a/apps/api/src/email/trigger-email.ts +++ b/apps/api/src/email/trigger-email.ts @@ -1,8 +1,10 @@ import { render } from '@react-email/render'; import { tasks } from '@trigger.dev/sdk'; import type { ReactElement } from 'react'; +import { generateUnsubscribeToken } from '@trycompai/email'; import type { EmailChannel, sendEmailTask } from '../trigger/email/send-email'; import type { EmailAttachment } from './resend'; +import { sendHtmlEmail } from './email-transport'; type TriggerEmailFlags = { marketing?: boolean; @@ -17,6 +19,34 @@ function resolveChannel(flags: TriggerEmailFlags): EmailChannel { return 'default'; } +async function sendEmailDirect(params: { + to: string; + subject: string; + html: string; + channel: EmailChannel; + cc?: string | string[]; + scheduledAt?: string; + attachments?: EmailAttachment[]; +}): Promise<{ id: string }> { + const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; + const token = generateUnsubscribeToken(params.to); + const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(params.to)}&token=${encodeURIComponent(token)}`; + + return sendHtmlEmail({ + to: params.to, + subject: params.subject, + html: params.html, + channel: params.channel, + cc: params.cc, + scheduledAt: params.scheduledAt, + attachments: params.attachments, + headers: { + 'List-Unsubscribe': `<${oneClickUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + }); +} + export async function triggerEmail(params: { to: string; subject: string; @@ -30,16 +60,26 @@ export async function triggerEmail(params: { }): Promise<{ id: string }> { try { const html = await render(params.react); - const channel = resolveChannel(params); - - const handle = await tasks.trigger('send-email', { + const payload = { to: params.to, subject: params.subject, html, channel, cc: params.cc, scheduledAt: params.scheduledAt, + attachments: params.attachments, + }; + + if (!process.env.TRIGGER_SECRET_KEY) { + console.log( + 'TRIGGER_SECRET_KEY not set; sending email directly via configured transport', + ); + return sendEmailDirect(payload); + } + + const handle = await tasks.trigger('send-email', { + ...payload, attachments: params.attachments?.map((att) => ({ filename: att.filename, content: @@ -52,7 +92,7 @@ export async function triggerEmail(params: { return { id: handle.id }; } catch (error) { - console.error('[triggerEmail] Failed to trigger email task', { + console.error('Failed to send/trigger email', { to: params.to, subject: params.subject, error: error instanceof Error ? error.message : String(error), diff --git a/apps/api/src/trigger/email/send-email.ts b/apps/api/src/trigger/email/send-email.ts index d01181cee2..51d27a960f 100644 --- a/apps/api/src/trigger/email/send-email.ts +++ b/apps/api/src/trigger/email/send-email.ts @@ -1,6 +1,6 @@ import { logger, queue, schemaTask } from '@trigger.dev/sdk'; import { z } from 'zod'; -import { resend } from '../../email/resend'; +import { sendHtmlEmail } from '../../email/email-transport'; import { generateUnsubscribeToken } from '@trycompai/email'; const emailQueue = queue({ @@ -16,28 +16,6 @@ export const emailChannelSchema = z.enum([ ]); export type EmailChannel = z.infer; -function resolveFromAddressForChannel( - channel: EmailChannel | undefined, -): string | undefined { - const fromMarketing = process.env.RESEND_FROM_MARKETING; - const fromSystem = process.env.RESEND_FROM_SYSTEM; - const fromDefault = process.env.RESEND_FROM_DEFAULT; - const fromTrustPortal = process.env.RESEND_FROM_TRUST_PORTAL; - - switch (channel) { - case 'trustPortal': - return fromTrustPortal ?? fromSystem; - case 'marketing': - return fromMarketing; - case 'system': - return fromSystem; - case 'default': - return fromDefault; - default: - return undefined; - } -} - export const sendEmailTask = schemaTask({ id: 'send-email', queue: emailQueue, @@ -63,31 +41,7 @@ export const sendEmailTask = schemaTask({ .optional(), }), run: async (params) => { - if (!resend) { - logger.error('Resend not initialized - missing RESEND_API_KEY', { - to: params.to, - subject: params.subject, - }); - throw new Error('Resend not initialized - missing API key'); - } - - const toTest = process.env.RESEND_TO_TEST; - const fromSystem = process.env.RESEND_FROM_SYSTEM; - const fromDefault = process.env.RESEND_FROM_DEFAULT; - - const fromAddress = - params.from ?? - resolveFromAddressForChannel(params.channel) ?? - fromSystem ?? - fromDefault; - const toAddress = toTest ?? params.to; - - if (!fromAddress) { - throw new Error('Missing FROM address in environment variables'); - } - try { - // Build List-Unsubscribe headers for Gmail/RFC 8058 one-click compliance const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; const token = generateUnsubscribeToken(params.to); @@ -97,14 +51,15 @@ export const sendEmailTask = schemaTask({ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', }; - const { data, error } = await resend.emails.send({ - from: fromAddress, - to: toAddress, - cc: params.cc, + const result = await sendHtmlEmail({ + to: params.to, subject: params.subject, html: params.html, - headers, + channel: params.channel, + from: params.from, + cc: params.cc, scheduledAt: params.scheduledAt, + headers, attachments: params.attachments?.map((att) => ({ filename: att.filename, content: att.content, @@ -112,21 +67,11 @@ export const sendEmailTask = schemaTask({ })), }); - if (error) { - logger.error('Resend API error', { - error, - to: params.to, - subject: params.subject, - }); - throw new Error(`Failed to send email: ${error.message}`); - } - - logger.info('Email sent', { to: params.to, id: data?.id }); + logger.info('Email sent', { to: params.to, id: result.id }); - // Throttle: hold the concurrency slot for 1s to space out sends await new Promise((r) => setTimeout(r, 1000)); - return { id: data?.id }; + return { id: result.id }; } catch (error) { logger.error('Email sending failed', { to: params.to, diff --git a/apps/app/prisma/client.ts b/apps/app/prisma/client.ts index 759a0d655f..6f6896a4f9 100644 --- a/apps/app/prisma/client.ts +++ b/apps/app/prisma/client.ts @@ -3,7 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; const globalForPrisma = global as unknown as { prisma?: PrismaClient }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); function stripSslMode(connectionString: string): string { const url = new URL(connectionString); @@ -46,7 +46,7 @@ function createPrismaClient(): PrismaClient { : { checkServerIdentity: () => undefined }; const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; - const adapter = new PrismaPg({ connectionString: url, ssl }); + const adapter = new PrismaPg({ connectionString: url, ssl: ssl ?? false }); return new PrismaClient({ adapter, transactionOptions: { diff --git a/apps/portal/prisma/client.ts b/apps/portal/prisma/client.ts index e00b91fae3..9074dfc496 100644 --- a/apps/portal/prisma/client.ts +++ b/apps/portal/prisma/client.ts @@ -3,7 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; const globalForPrisma = global as unknown as { prisma?: PrismaClient }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); function stripSslMode(connectionString: string): string { const url = new URL(connectionString); @@ -37,7 +37,7 @@ function createPrismaClient(): PrismaClient { : { checkServerIdentity: () => undefined }; const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; - const adapter = new PrismaPg({ connectionString: url, ssl }); + const adapter = new PrismaPg({ connectionString: url, ssl: ssl ?? false }); return new PrismaClient({ adapter, transactionOptions: { diff --git a/docker-compose.yml b/docker-compose.yml index 399879bafc..e7c382553d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: target: migrator env_file: - packages/db/.env - command: sh -lc "bunx prisma generate --schema=node_modules/@trycompai/db/dist/schema.prisma && bun packages/db/prisma/seed/seed.js" + command: sh -lc "tsx packages/db/prisma/seed/seed.ts" logging: *default-logging app: build: @@ -30,17 +30,43 @@ services: target: app args: NEXT_PUBLIC_BETTER_AUTH_URL: ${BETTER_AUTH_URL} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} ports: - - '3000:3000' + - '3001:3000' env_file: - apps/app/.env + environment: + PGSSLMODE: disable + DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable restart: unless-stopped healthcheck: - test: ['CMD-SHELL', 'curl -f http://localhost:3000/api/health || exit 1'] + test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/api/health || exit 1'] + interval: 30s + timeout: 10s + retries: 3 + command: sh -lc "node --max-old-space-size=8192 apps/app/server.js" + logging: *default-logging + api: + build: + context: . + dockerfile: apps/api/Dockerfile.multistage + target: production + ports: + - '3333:3333' + env_file: + - apps/api/.env + environment: + PGSSLMODE: disable + NODE_EXTRA_CA_CERTS: "" + DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable + volumes: + - ./apps/api/.env:/app/.env:ro + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', 'wget -qO- http://localhost:3333/v1/health || exit 1'] interval: 30s timeout: 10s retries: 3 - command: sh -lc "node apps/app/server.js" logging: *default-logging portal: build: @@ -49,14 +75,30 @@ services: target: portal args: NEXT_PUBLIC_BETTER_AUTH_URL: ${BETTER_AUTH_URL_PORTAL} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL_PORTAL} ports: - '3002:3000' env_file: - apps/portal/.env + environment: + PGSSLMODE: disable + DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable + MOCK_REDIS: "true" + FLEET_AGENT_BUCKET_NAME: comp + DEVICE_AGENT_S3_ENV: production + APP_AWS_ACCESS_KEY_ID: minioadmin + APP_AWS_SECRET_ACCESS_KEY: minioadmin + APP_AWS_BUCKET_NAME: comp + APP_AWS_REGION: us-east-1 + APP_AWS_ENDPOINT: http://minio:9000 restart: unless-stopped healthcheck: - test: ['CMD-SHELL', 'curl -f http://localhost:3000/ || exit 1'] + test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/ || exit 1'] interval: 30s timeout: 10s retries: 3 logging: *default-logging +networks: + default: + name: comp_network + external: true diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 43a9d130e2..146baeb0bd 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -17,7 +17,7 @@ function createPrismaClient(): PrismaClient { const rawUrl = process.env.DATABASE_URL!; const ssl = resolveSslConfig(rawUrl); const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; - const adapter = new PrismaPg({ connectionString: url, ssl }); + const adapter = new PrismaPg({ connectionString: url, ssl: ssl ?? false }); return new PrismaClient({ adapter, transactionOptions: { timeout: 60000 }, diff --git a/packages/db/src/ssl-config.ts b/packages/db/src/ssl-config.ts index 6c23d160d9..53508c6ba3 100644 --- a/packages/db/src/ssl-config.ts +++ b/packages/db/src/ssl-config.ts @@ -3,7 +3,7 @@ export type SslConfig = | { checkServerIdentity: () => undefined } | { rejectUnauthorized: false }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); function isLocalhostUrl(connectionString: string): boolean { try { From 7e51e612d8aff6da9bf1b276f22b286f63b08ba1 Mon Sep 17 00:00:00 2001 From: Raed Brahem Date: Mon, 3 Aug 2026 09:40:15 +0000 Subject: [PATCH 2/7] feat(self-host): SMTP/BunMail email transport, Docker stack, and portal URL fixes Add optional SMTP and BunMail email transports with direct send when Trigger.dev is unset, extend docker-compose with API and optional MinIO profile, improve local Postgres/Docker build compatibility, and centralize portal links for self-hosted installs. --- apps/api/.env.example | 1 + apps/api/package.json | 428 +++++++++--------- apps/api/src/people/people-invite.service.ts | 2 +- apps/app/src/app/(app)/no-access/page.tsx | 4 +- apps/portal/.env.example | 1 + .../src/app/api/download-agent/route.ts | 9 +- docker-compose.yml | 63 ++- .../email/emails/all-policy-notification.tsx | 3 +- .../emails/policy-acknowledgment-digest.tsx | 3 +- packages/email/emails/policy-notification.tsx | 3 +- packages/email/lib/get-portal-base-url.ts | 9 + 11 files changed, 277 insertions(+), 249 deletions(-) create mode 100644 packages/email/lib/get-portal-base-url.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 56c8f65a57..3e7eedcdf5 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -12,6 +12,7 @@ APP_AWS_ACCESS_KEY_ID= APP_AWS_SECRET_ACCESS_KEY= APP_AWS_ORG_ASSETS_BUCKET= APP_AWS_ENDPOINT="" # optional for using services like MinIO +APP_AWS_PUBLIC_ENDPOINT="" # Browser-reachable S3/MinIO endpoint for presigned URLs # Microsoft sign-in (Entra ID / Azure AD) AUTH_MICROSOFT_CLIENT_ID= diff --git a/apps/api/package.json b/apps/api/package.json index 92262cddd3..4af048a02b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,218 +1,218 @@ { - "name": "@trycompai/api", - "description": "", - "version": "0.0.1", - "author": "", - "dependencies": { - "@1password/sdk": "0.4.0", - "@ai-sdk/anthropic": "^3.0.75", - "@ai-sdk/groq": "^3.0.38", - "@ai-sdk/openai": "^3.0.62", - "@aws-sdk/client-acm": "^3.948.0", - "@aws-sdk/client-api-gateway": "^3.948.0", - "@aws-sdk/client-apigatewayv2": "^3.948.0", - "@aws-sdk/client-appflow": "^3.948.0", - "@aws-sdk/client-athena": "^3.948.0", - "@aws-sdk/client-backup": "^3.948.0", - "@aws-sdk/client-cloudfront": "^3.948.0", - "@aws-sdk/client-cloudtrail": "^3.948.0", - "@aws-sdk/client-cloudwatch": "^3.948.0", - "@aws-sdk/client-cloudwatch-logs": "^3.948.0", - "@aws-sdk/client-codebuild": "^3.948.0", - "@aws-sdk/client-cognito-identity-provider": "^3.948.0", - "@aws-sdk/client-config-service": "^3.948.0", - "@aws-sdk/client-cost-explorer": "^3.948.0", - "@aws-sdk/client-dynamodb": "^3.948.0", - "@aws-sdk/client-ec2": "^3.911.0", - "@aws-sdk/client-ecr": "^3.948.0", - "@aws-sdk/client-ecs": "^3.948.0", - "@aws-sdk/client-efs": "^3.948.0", - "@aws-sdk/client-eks": "^3.948.0", - "@aws-sdk/client-elastic-beanstalk": "^3.948.0", - "@aws-sdk/client-elastic-load-balancing-v2": "^3.948.0", - "@aws-sdk/client-elasticache": "^3.948.0", - "@aws-sdk/client-emr": "^3.948.0", - "@aws-sdk/client-eventbridge": "^3.948.0", - "@aws-sdk/client-glue": "^3.948.0", - "@aws-sdk/client-guardduty": "^3.948.0", - "@aws-sdk/client-iam": "^3.948.0", - "@aws-sdk/client-inspector2": "^3.948.0", - "@aws-sdk/client-kafka": "^3.948.0", - "@aws-sdk/client-kinesis": "^3.948.0", - "@aws-sdk/client-kms": "^3.948.0", - "@aws-sdk/client-lambda": "^3.948.0", - "@aws-sdk/client-macie2": "^3.948.0", - "@aws-sdk/client-network-firewall": "^3.948.0", - "@aws-sdk/client-opensearch": "^3.948.0", - "@aws-sdk/client-rds": "^3.948.0", - "@aws-sdk/client-redshift": "^3.948.0", - "@aws-sdk/client-route-53": "^3.948.0", - "@aws-sdk/client-s3": "3.1013.0", - "@aws-sdk/client-sagemaker": "^3.948.0", - "@aws-sdk/client-secrets-manager": "^3.948.0", - "@aws-sdk/client-securityhub": "^3.948.0", - "@aws-sdk/client-sfn": "^3.948.0", - "@aws-sdk/client-shield": "^3.948.0", - "@aws-sdk/client-sns": "^3.948.0", - "@aws-sdk/client-sqs": "^3.948.0", - "@aws-sdk/client-ssm": "^3.948.0", - "@aws-sdk/client-sts": "^3.948.0", - "@aws-sdk/client-transfer": "^3.948.0", - "@aws-sdk/client-wafv2": "^3.948.0", - "@aws-sdk/lib-storage": "3.1013.0", - "@aws-sdk/s3-request-presigner": "3.1013.0", - "@browserbasehq/sdk": "2.6.0", - "@browserbasehq/stagehand": "^3.7.0", - "@inference/tracing": "^0.0.21", - "@maced/api-client": "^0.9.2", - "@mendable/firecrawl-js": "^4.9.3", - "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.1.5", - "@nestjs/swagger": "^11.4.5", - "@nestjs/throttler": "^6.5.0", - "@prisma/adapter-pg": "7.6.0", - "@prisma/client": "7.6.0", - "@prisma/instrumentation": "7.6.0", - "@react-email/components": "^0.0.41", - "@react-email/render": "^2.0.4", - "@thallesp/nestjs-better-auth": "^2.4.0", - "@trigger.dev/build": "4.4.3", - "@trigger.dev/sdk": "4.4.3", - "@trycompai/auth": "workspace:*", - "@trycompai/billing": "workspace:*", - "@trycompai/company": "workspace:*", - "@trycompai/db": "workspace:*", - "@trycompai/email": "workspace:*", - "@trycompai/integration-platform": "workspace:*", - "@trycompai/utils": "workspace:*", - "@upstash/ratelimit": "^2.0.8", - "@upstash/redis": "^1.34.2", - "@upstash/vector": "^1.2.2", - "adm-zip": "^0.6.0", - "ai": "^6.0.175", - "archiver": "^7.0.1", - "axios": "^1.16.0", - "better-auth": "^1.6.13", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.2", - "docx": "^9.7.1", - "dotenv": "^17.2.3", - "esbuild": "^0.27.1", - "exceljs": "^4.4.0", - "express": "^4.21.2", - "helmet": "^8.1.0", - "jose": "^6.0.12", - "jspdf": "^4.2.0", - "jspdf-autotable": "^5.0.8", - "mammoth": "^1.8.0", - "nanoid": "^5.1.6", - "pdf-lib": "^1.17.1", - "playwright-core": "^1.57.0", - "posthog-node": "^5.29.2", - "prisma": "7.6.0", - "react": "^19.1.1", - "react-dom": "^19.1.0", - "reflect-metadata": "^0.2.2", - "resend": "^6.4.2", -"nodemailer": "^6.10.1", -"@types/nodemailer": "^6.4.17", - "rxjs": "^7.8.1", - "safe-stable-stringify": "^2.5.0", - "stripe": "^20.4.0", - "swagger-ui-express": "^5.0.1", - "zod": "^4.0.14" - }, - "devDependencies": { - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.18.0", - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", - "@types/adm-zip": "^0.5.7", - "@types/archiver": "^6.0.3", - "@types/express": "^5.0.0", - "@types/jest": "^30.0.0", - "@types/multer": "^1.4.12", - "@types/node": "^24.0.3", - "@types/supertest": "^6.0.2", - "@types/swagger-ui-express": "^4.1.8", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "globals": "^17.3.0", - "jest": "^30.0.0", - "prettier": "^3.5.3", - "source-map-support": "^0.5.21", - "supertest": "^7.0.0", - "trigger.dev": "4.4.3", - "ts-jest": "^29.2.5", - "ts-loader": "^9.5.2", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.8.3", - "typescript-eslint": "^8.20.0" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts", - "tsx" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)sx?$": "ts-jest" + "name": "@trycompai/api", + "description": "", + "version": "0.0.1", + "author": "", + "dependencies": { + "@1password/sdk": "0.4.0", + "@ai-sdk/anthropic": "^3.0.75", + "@ai-sdk/groq": "^3.0.38", + "@ai-sdk/openai": "^3.0.62", + "@aws-sdk/client-acm": "^3.948.0", + "@aws-sdk/client-api-gateway": "^3.948.0", + "@aws-sdk/client-apigatewayv2": "^3.948.0", + "@aws-sdk/client-appflow": "^3.948.0", + "@aws-sdk/client-athena": "^3.948.0", + "@aws-sdk/client-backup": "^3.948.0", + "@aws-sdk/client-cloudfront": "^3.948.0", + "@aws-sdk/client-cloudtrail": "^3.948.0", + "@aws-sdk/client-cloudwatch": "^3.948.0", + "@aws-sdk/client-cloudwatch-logs": "^3.948.0", + "@aws-sdk/client-codebuild": "^3.948.0", + "@aws-sdk/client-cognito-identity-provider": "^3.948.0", + "@aws-sdk/client-config-service": "^3.948.0", + "@aws-sdk/client-cost-explorer": "^3.948.0", + "@aws-sdk/client-dynamodb": "^3.948.0", + "@aws-sdk/client-ec2": "^3.911.0", + "@aws-sdk/client-ecr": "^3.948.0", + "@aws-sdk/client-ecs": "^3.948.0", + "@aws-sdk/client-efs": "^3.948.0", + "@aws-sdk/client-eks": "^3.948.0", + "@aws-sdk/client-elastic-beanstalk": "^3.948.0", + "@aws-sdk/client-elastic-load-balancing-v2": "^3.948.0", + "@aws-sdk/client-elasticache": "^3.948.0", + "@aws-sdk/client-emr": "^3.948.0", + "@aws-sdk/client-eventbridge": "^3.948.0", + "@aws-sdk/client-glue": "^3.948.0", + "@aws-sdk/client-guardduty": "^3.948.0", + "@aws-sdk/client-iam": "^3.948.0", + "@aws-sdk/client-inspector2": "^3.948.0", + "@aws-sdk/client-kafka": "^3.948.0", + "@aws-sdk/client-kinesis": "^3.948.0", + "@aws-sdk/client-kms": "^3.948.0", + "@aws-sdk/client-lambda": "^3.948.0", + "@aws-sdk/client-macie2": "^3.948.0", + "@aws-sdk/client-network-firewall": "^3.948.0", + "@aws-sdk/client-opensearch": "^3.948.0", + "@aws-sdk/client-rds": "^3.948.0", + "@aws-sdk/client-redshift": "^3.948.0", + "@aws-sdk/client-route-53": "^3.948.0", + "@aws-sdk/client-s3": "3.1013.0", + "@aws-sdk/client-sagemaker": "^3.948.0", + "@aws-sdk/client-secrets-manager": "^3.948.0", + "@aws-sdk/client-securityhub": "^3.948.0", + "@aws-sdk/client-sfn": "^3.948.0", + "@aws-sdk/client-shield": "^3.948.0", + "@aws-sdk/client-sns": "^3.948.0", + "@aws-sdk/client-sqs": "^3.948.0", + "@aws-sdk/client-ssm": "^3.948.0", + "@aws-sdk/client-sts": "^3.948.0", + "@aws-sdk/client-transfer": "^3.948.0", + "@aws-sdk/client-wafv2": "^3.948.0", + "@aws-sdk/lib-storage": "3.1013.0", + "@aws-sdk/s3-request-presigner": "3.1013.0", + "@browserbasehq/sdk": "2.6.0", + "@browserbasehq/stagehand": "^3.7.0", + "@inference/tracing": "^0.0.21", + "@maced/api-client": "^0.9.2", + "@mendable/firecrawl-js": "^4.9.3", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/platform-express": "^11.1.5", + "@nestjs/swagger": "^11.4.5", + "@nestjs/throttler": "^6.5.0", + "@prisma/adapter-pg": "7.6.0", + "@prisma/client": "7.6.0", + "@prisma/instrumentation": "7.6.0", + "@react-email/components": "^0.0.41", + "@react-email/render": "^2.0.4", + "@thallesp/nestjs-better-auth": "^2.4.0", + "@trigger.dev/build": "4.4.3", + "@trigger.dev/sdk": "4.4.3", + "@trycompai/auth": "workspace:*", + "@trycompai/billing": "workspace:*", + "@trycompai/company": "workspace:*", + "@trycompai/db": "workspace:*", + "@trycompai/email": "workspace:*", + "@trycompai/integration-platform": "workspace:*", + "@trycompai/utils": "workspace:*", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.34.2", + "@upstash/vector": "^1.2.2", + "adm-zip": "^0.6.0", + "ai": "^6.0.175", + "archiver": "^7.0.1", + "axios": "^1.16.0", + "better-auth": "^1.6.13", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "docx": "^9.7.1", + "dotenv": "^17.2.3", + "esbuild": "^0.27.1", + "exceljs": "^4.4.0", + "express": "^4.21.2", + "helmet": "^8.1.0", + "jose": "^6.0.12", + "jspdf": "^4.2.0", + "jspdf-autotable": "^5.0.8", + "mammoth": "^1.8.0", + "nanoid": "^5.1.6", + "pdf-lib": "^1.17.1", + "playwright-core": "^1.57.0", + "posthog-node": "^5.29.2", + "prisma": "7.6.0", + "react": "^19.1.1", + "react-dom": "^19.1.0", + "reflect-metadata": "^0.2.2", + "resend": "^6.4.2", + "nodemailer": "^6.10.1", + "@types/nodemailer": "^6.4.17", + "rxjs": "^7.8.1", + "safe-stable-stringify": "^2.5.0", + "stripe": "^20.4.0", + "swagger-ui-express": "^5.0.1", + "zod": "^4.0.14" }, - "transformIgnorePatterns": [ - "node_modules/(?!(@maced/api-client|better-auth)/)" - ], - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node", - "moduleNameMapper": { - "^@db$": "/../prisma/index", - "^@/(.*)$": "/$1", - "^\\./sku-definitions\\.js$": "/../../../packages/billing/src/sku-definitions.ts", - "^@trycompai/auth/participation$": "/../../../packages/auth/src/participation.ts", - "^@trycompai/auth$": "/../../../packages/auth/src/index.ts", - "^@trycompai/billing$": "/../../../packages/billing/src/index.ts", - "^@trycompai/company$": "/../../../packages/company/src/index.ts", - "^@trycompai/db$": "@prisma/client", - "^@trycompai/email$": "/../../../packages/email/index.ts", - "^@trycompai/integration-platform$": "/../../../packages/integration-platform/src/index.ts", - "^@trycompai/utils/(.*)$": "/../../../packages/utils/src/$1.ts" + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/adm-zip": "^0.5.7", + "@types/archiver": "^6.0.3", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/multer": "^1.4.12", + "@types/node": "^24.0.3", + "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.8", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^17.3.0", + "jest": "^30.0.0", + "prettier": "^3.5.3", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "trigger.dev": "4.4.3", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts", + "tsx" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)sx?$": "ts-jest" + }, + "transformIgnorePatterns": [ + "node_modules/(?!(@maced/api-client|better-auth)/)" + ], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node", + "moduleNameMapper": { + "^@db$": "/../prisma/index", + "^@/(.*)$": "/$1", + "^\\./sku-definitions\\.js$": "/../../../packages/billing/src/sku-definitions.ts", + "^@trycompai/auth/participation$": "/../../../packages/auth/src/participation.ts", + "^@trycompai/auth$": "/../../../packages/auth/src/index.ts", + "^@trycompai/billing$": "/../../../packages/billing/src/index.ts", + "^@trycompai/company$": "/../../../packages/company/src/index.ts", + "^@trycompai/db$": "@prisma/client", + "^@trycompai/email$": "/../../../packages/email/index.ts", + "^@trycompai/integration-platform$": "/../../../packages/integration-platform/src/index.ts", + "^@trycompai/utils/(.*)$": "/../../../packages/utils/src/$1.ts" + } + }, + "license": "UNLICENSED", + "private": true, + "scripts": { + "build": "nest build", + "build:docker": "bunx prisma generate --schema=prisma/schema && nest build", + "db:generate": "bun run db:getschema && bunx prisma generate --schema=prisma/schema", + "db:getschema": "find prisma/schema -name '*.prisma' ! -name 'schema.prisma' -delete && find ../../packages/db/prisma/schema -name '*.prisma' ! -name 'schema.prisma' -exec cp {} prisma/schema/ \\;", + "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/api", + "deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy", + "dev": "bunx concurrently --kill-others --names \"nest,trigger\" --prefix-colors \"green,blue\" \"nest start --watch\" \"trigger dev\"", + "dev:nest": "nest start --watch", + "dev:no-trigger": "nest start --watch", + "dev:trigger": "trigger dev", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "prebuild": "bun run db:generate", + "start": "nest start", + "start:debug": "nest start --debug --watch", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "test": "jest", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "test:watch": "jest --watch", + "typecheck": "tsc --noEmit" } - }, - "license": "UNLICENSED", - "private": true, - "scripts": { - "build": "nest build", - "build:docker": "bunx prisma generate --schema=prisma/schema && nest build", - "db:generate": "bun run db:getschema && bunx prisma generate --schema=prisma/schema", - "db:getschema": "find prisma/schema -name '*.prisma' ! -name 'schema.prisma' -delete && find ../../packages/db/prisma/schema -name '*.prisma' ! -name 'schema.prisma' -exec cp {} prisma/schema/ \\;", - "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/api", - "deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy", - "dev": "bunx concurrently --kill-others --names \"nest,trigger\" --prefix-colors \"green,blue\" \"nest start --watch\" \"trigger dev\"", - "dev:nest": "nest start --watch", - "dev:no-trigger": "nest start --watch", - "dev:trigger": "trigger dev", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "prebuild": "bun run db:generate", - "start": "nest start", - "start:debug": "nest start --debug --watch", - "start:dev": "nest start --watch", - "start:prod": "node dist/main", - "test": "jest", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", - "test:watch": "jest --watch", - "typecheck": "tsc --noEmit" - } } diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 37ce027843..06b9abf499 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -678,7 +678,7 @@ export class PeopleInviteService { private buildPortalUrl(organizationId: string): string { const portalUrl = - process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'; + process.env.PORTAL_URL ?? process.env.NEXT_PUBLIC_PORTAL_URL ?? process.env.TRUST_APP_URL ?? 'https://portal.trycomp.ai'; return `${portalUrl}/${organizationId}`; } diff --git a/apps/app/src/app/(app)/no-access/page.tsx b/apps/app/src/app/(app)/no-access/page.tsx index ceecef54da..50e16203fe 100644 --- a/apps/app/src/app/(app)/no-access/page.tsx +++ b/apps/app/src/app/(app)/no-access/page.tsx @@ -36,8 +36,8 @@ export default async function NoAccess() {

Your current role doesn't have access to the app. If you're looking for the employee portal, go to{' '} - - portal.trycomp.ai + + {process.env.NEXT_PUBLIC_PORTAL_URL?.replace(/^https?:\/\//, "") ?? "portal.trycomp.ai"} .

diff --git a/apps/portal/.env.example b/apps/portal/.env.example index b69f2b8c3f..fdbed9a4c8 100644 --- a/apps/portal/.env.example +++ b/apps/portal/.env.example @@ -16,6 +16,7 @@ APP_AWS_SECRET_ACCESS_KEY="" # AWS Secret Access Key APP_AWS_REGION="" # AWS Region APP_AWS_BUCKET_NAME="" # AWS Bucket Name APP_AWS_ENDPOINT="" # optional for using services like MinIO +APP_AWS_PUBLIC_ENDPOINT="" # Browser-reachable S3/MinIO endpoint for presigned URLs # Microsoft sign-in AUTH_MICROSOFT_CLIENT_ID= diff --git a/apps/portal/src/app/api/download-agent/route.ts b/apps/portal/src/app/api/download-agent/route.ts index a300982dc7..70c31705f8 100644 --- a/apps/portal/src/app/api/download-agent/route.ts +++ b/apps/portal/src/app/api/download-agent/route.ts @@ -3,8 +3,6 @@ import { s3Client } from '@/utils/s3'; import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { client as kv } from '@trycompai/kv'; import { type NextRequest, NextResponse } from 'next/server'; -import { Readable } from 'stream'; - import { DOWNLOAD_TARGETS } from './constants'; import type { SupportedOS } from './types'; @@ -109,11 +107,10 @@ const handleDownload = async (req: NextRequest, isHead: boolean) => { await kv.del(`download:${token}`); - const s3Stream = s3Response.Body as Readable; - const webStream = Readable.toWeb(s3Stream) as unknown as ReadableStream; + const bytes = await s3Response.Body.transformToByteArray(); - return new NextResponse(webStream, { - headers: buildResponseHeaders(target, s3Response.ContentLength ?? null), + return new NextResponse(Buffer.from(bytes), { + headers: buildResponseHeaders(target, bytes.length), }); } catch (error) { logger('Error serving device agent download', { diff --git a/docker-compose.yml b/docker-compose.yml index e7c382553d..b878e4e01c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,13 +31,11 @@ services: args: NEXT_PUBLIC_BETTER_AUTH_URL: ${BETTER_AUTH_URL} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_PORTAL_URL: ${BETTER_AUTH_URL_PORTAL} ports: - - '3001:3000' + - '3000:3000' env_file: - apps/app/.env - environment: - PGSSLMODE: disable - DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable restart: unless-stopped healthcheck: test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/api/health || exit 1'] @@ -55,10 +53,6 @@ services: - '3333:3333' env_file: - apps/api/.env - environment: - PGSSLMODE: disable - NODE_EXTRA_CA_CERTS: "" - DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable volumes: - ./apps/api/.env:/app/.env:ro restart: unless-stopped @@ -80,17 +74,6 @@ services: - '3002:3000' env_file: - apps/portal/.env - environment: - PGSSLMODE: disable - DATABASE_URL: postgresql://comp:comp@comp-postgres:5432/comp?sslmode=disable - MOCK_REDIS: "true" - FLEET_AGENT_BUCKET_NAME: comp - DEVICE_AGENT_S3_ENV: production - APP_AWS_ACCESS_KEY_ID: minioadmin - APP_AWS_SECRET_ACCESS_KEY: minioadmin - APP_AWS_BUCKET_NAME: comp - APP_AWS_REGION: us-east-1 - APP_AWS_ENDPOINT: http://minio:9000 restart: unless-stopped healthcheck: test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/ || exit 1'] @@ -98,7 +81,41 @@ services: timeout: 10s retries: 3 logging: *default-logging -networks: - default: - name: comp_network - external: true + minio: + image: minio/minio:latest + profiles: + - minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + ports: + - '9000:9000' + - '9001:9001' + volumes: + - minio_data:/data + restart: unless-stopped + logging: *default-logging + minio-init: + image: minio/mc:latest + profiles: + - minio + depends_on: + - minio + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + entrypoint: > + /bin/sh -c " + for i in 1 2 3 4 5 6 7 8 9 10; do + mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && break; + sleep 2; + done && + mc mb -p local/$${APP_AWS_BUCKET_NAME:-comp} || true && + echo bucket ready + " + restart: 'no' + logging: *default-logging + +volumes: + minio_data: diff --git a/packages/email/emails/all-policy-notification.tsx b/packages/email/emails/all-policy-notification.tsx index cc2852ddf6..415cebbfe6 100644 --- a/packages/email/emails/all-policy-notification.tsx +++ b/packages/email/emails/all-policy-notification.tsx @@ -1,3 +1,4 @@ +import { getPortalBaseUrl } from '../lib/get-portal-base-url'; import { Body, Button, @@ -28,7 +29,7 @@ export const AllPolicyNotificationEmail = ({ organizationName, organizationId, }: Props) => { - const link = `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${organizationId}`; + const link = `${getPortalBaseUrl()}/${organizationId}`; const subjectText = 'Please review and accept the policies'; return ( diff --git a/packages/email/emails/policy-acknowledgment-digest.tsx b/packages/email/emails/policy-acknowledgment-digest.tsx index c7b6b5aa82..52d3c144ac 100644 --- a/packages/email/emails/policy-acknowledgment-digest.tsx +++ b/packages/email/emails/policy-acknowledgment-digest.tsx @@ -1,3 +1,4 @@ +import { getPortalBaseUrl } from '../lib/get-portal-base-url'; import { Body, Button, @@ -61,7 +62,7 @@ export const PolicyAcknowledgmentDigestEmail = ({ if (!firstOrg) return null; const portalBase = ( - process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai' + getPortalBaseUrl() ).replace(/\/+$/, ''); const subjectText = computePolicyAcknowledgmentDigestSubject(orgsWithPolicies); const isMultiOrg = orgsWithPolicies.length > 1; diff --git a/packages/email/emails/policy-notification.tsx b/packages/email/emails/policy-notification.tsx index 2c5315e5fe..a5a00c7f80 100644 --- a/packages/email/emails/policy-notification.tsx +++ b/packages/email/emails/policy-notification.tsx @@ -1,3 +1,4 @@ +import { getPortalBaseUrl } from '../lib/get-portal-base-url'; import { Body, Button, @@ -32,7 +33,7 @@ export const PolicyNotificationEmail = ({ organizationId, notificationType, }: Props) => { - const link = `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${organizationId}`; + const link = `${getPortalBaseUrl()}/${organizationId}`; const subjectText = 'Please review and accept this policy'; const getBodyText = () => { diff --git a/packages/email/lib/get-portal-base-url.ts b/packages/email/lib/get-portal-base-url.ts new file mode 100644 index 0000000000..8c82f1d602 --- /dev/null +++ b/packages/email/lib/get-portal-base-url.ts @@ -0,0 +1,9 @@ +/** Self-hosted installs set PORTAL_URL; cloud uses NEXT_PUBLIC_PORTAL_URL. */ +export function getPortalBaseUrl(): string { + return ( + process.env.PORTAL_URL ?? + process.env.NEXT_PUBLIC_PORTAL_URL ?? + process.env.TRUST_APP_URL ?? + 'https://portal.trycomp.ai' + ).replace(/\/+$/, ''); +} From 108b85d727e68930ff67b4ffbd60066d9dbea6c0 Mon Sep 17 00:00:00 2001 From: Raed Brahem Date: Mon, 3 Aug 2026 09:41:22 +0000 Subject: [PATCH 3/7] fix(s3): support APP_AWS_PUBLIC_ENDPOINT for MinIO presigned URLs When S3/MinIO runs on an internal Docker hostname, presigned attachment URLs need a browser-reachable host. Use APP_AWS_PUBLIC_ENDPOINT for signing while keeping APP_AWS_ENDPOINT for server-side object access. --- apps/api/src/app/s3.ts | 25 ++++++++++++++++++++++++- apps/portal/src/utils/s3.ts | 20 +++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/s3.ts b/apps/api/src/app/s3.ts index cf0b43350f..1a303b0501 100644 --- a/apps/api/src/app/s3.ts +++ b/apps/api/src/app/s3.ts @@ -15,18 +15,29 @@ import '../config/load-env'; * and @aws-sdk/s3-request-presigner even when pinned to the same version. * The runtime types are fully compatible — only the TypeScript class identity differs. */ -export const getSignedUrl = _getSignedUrl as unknown as ( +const _getSignedUrlTyped = _getSignedUrl as unknown as ( client: S3Client, command: GetObjectCommand | PutObjectCommand, options?: { expiresIn?: number }, ) => Promise; +/** Use public-endpoint client for presigned URLs when configured. */ +export const getSignedUrl = ( + client: S3Client, + command: GetObjectCommand | PutObjectCommand, + options?: { expiresIn?: number }, +): Promise => + _getSignedUrlTyped(s3SigningClientInstance ?? client, command, options); + const logger = new Logger('S3'); const APP_AWS_REGION = process.env.APP_AWS_REGION; const APP_AWS_ACCESS_KEY_ID = process.env.APP_AWS_ACCESS_KEY_ID; const APP_AWS_SECRET_ACCESS_KEY = process.env.APP_AWS_SECRET_ACCESS_KEY; const APP_AWS_ENDPOINT = process.env.APP_AWS_ENDPOINT; +/** Browser-reachable MinIO/S3 URL for presigned URLs. Falls back to APP_AWS_ENDPOINT. */ +const APP_AWS_PUBLIC_ENDPOINT = + process.env.APP_AWS_PUBLIC_ENDPOINT || process.env.APP_AWS_ENDPOINT; export const BUCKET_NAME = process.env.APP_AWS_BUCKET_NAME; export const APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET = @@ -36,6 +47,7 @@ export const APP_AWS_KNOWLEDGE_BASE_BUCKET = export const APP_AWS_ORG_ASSETS_BUCKET = process.env.APP_AWS_ORG_ASSETS_BUCKET; let s3ClientInstance: S3Client | null = null; +let s3SigningClientInstance: S3Client | null = null; try { if ( @@ -61,12 +73,23 @@ try { }, forcePathStyle: !!APP_AWS_ENDPOINT, }); + + s3SigningClientInstance = new S3Client({ + endpoint: APP_AWS_PUBLIC_ENDPOINT || undefined, + region: APP_AWS_REGION, + credentials: { + accessKeyId: APP_AWS_ACCESS_KEY_ID, + secretAccessKey: APP_AWS_SECRET_ACCESS_KEY, + }, + forcePathStyle: !!APP_AWS_PUBLIC_ENDPOINT, + }); } catch (error) { logger.error( 'FAILED TO INITIALIZE S3 CLIENT', error instanceof Error ? error.stack : error, ); s3ClientInstance = null; + s3SigningClientInstance = null; logger.error( '[S3] Creating dummy S3 client - file uploads will fail until credentials are fixed', ); diff --git a/apps/portal/src/utils/s3.ts b/apps/portal/src/utils/s3.ts index dd2bc18e26..7d6357e959 100644 --- a/apps/portal/src/utils/s3.ts +++ b/apps/portal/src/utils/s3.ts @@ -8,7 +8,7 @@ import { getSignedUrl as _getSignedUrl } from '@aws-sdk/s3-request-presigner'; * and @aws-sdk/s3-request-presigner even when pinned to the same version. * The runtime types are fully compatible — only the TypeScript class identity differs. */ -export const getSignedUrl = _getSignedUrl as unknown as ( +const _getSignedUrlTyped = _getSignedUrl as unknown as ( client: S3Client, command: GetObjectCommand | PutObjectCommand, options?: { expiresIn?: number }, @@ -18,6 +18,8 @@ const APP_AWS_REGION = process.env.APP_AWS_REGION; const APP_AWS_ACCESS_KEY_ID = process.env.APP_AWS_ACCESS_KEY_ID; const APP_AWS_SECRET_ACCESS_KEY = process.env.APP_AWS_SECRET_ACCESS_KEY; const APP_AWS_ENDPOINT = process.env.APP_AWS_ENDPOINT; +const APP_AWS_PUBLIC_ENDPOINT = + process.env.APP_AWS_PUBLIC_ENDPOINT || process.env.APP_AWS_ENDPOINT; export const BUCKET_NAME = process.env.APP_AWS_BUCKET_NAME; export const APP_AWS_ORG_ASSETS_BUCKET = process.env.APP_AWS_ORG_ASSETS_BUCKET; @@ -40,6 +42,22 @@ export const s3Client = new S3Client({ forcePathStyle: !!APP_AWS_ENDPOINT, }); +const s3SigningClient = new S3Client({ + endpoint: APP_AWS_PUBLIC_ENDPOINT || undefined, + region: APP_AWS_REGION!, + credentials: { + accessKeyId: APP_AWS_ACCESS_KEY_ID!, + secretAccessKey: APP_AWS_SECRET_ACCESS_KEY!, + }, + forcePathStyle: !!APP_AWS_PUBLIC_ENDPOINT, +}); + +export const getSignedUrl = ( + client: S3Client, + command: GetObjectCommand | PutObjectCommand, + options?: { expiresIn?: number }, +): Promise => _getSignedUrlTyped(s3SigningClient, command, options); + // Ensure BUCKET_NAME is exported and non-null checked if needed elsewhere explicitly if (!BUCKET_NAME && process.env.NODE_ENV === 'production') { console.error('AWS_BUCKET_NAME is not defined.'); From 0df2e5445425bb17c82d7e426c2e5291112844a9 Mon Sep 17 00:00:00 2001 From: Raed Brahem Date: Mon, 3 Aug 2026 10:04:57 +0000 Subject: [PATCH 4/7] fix(self-host): address PR review feedback - Fix SMTP attachment base64 decoding for Trigger task payloads - Remove global RESEND_TO_TEST redirect; reject scheduled sends without Trigger.dev - Fall back from BunMail when attachments/headers/scheduling are required - Share unsubscribe header builder; reuse getPortalBaseUrl in app/API - Stream device agent downloads via transformToWebStream - Require explicit MinIO credentials and bucket name; pin image tags - Use sslmode=disable in DATABASE_URL instead of hardcoded comp-postgres host - Install pinned tsx via Bun in migrator image --- Dockerfile | 6 +- apps/api/prisma/client.ts | 10 +-- apps/api/src/email/email-transport.ts | 62 +++++++++++++++++-- apps/api/src/email/trigger-email.ts | 16 +++-- apps/api/src/email/unsubscribe-headers.ts | 12 ++++ apps/api/src/people/people-invite.service.ts | 4 +- apps/api/src/trigger/email/send-email.ts | 11 +--- apps/app/prisma/client.ts | 2 +- apps/app/src/app/(app)/no-access/page.tsx | 7 ++- apps/portal/prisma/client.ts | 2 +- .../src/app/api/download-agent/route.ts | 6 +- docker-compose.yml | 23 ++++--- packages/db/src/ssl-config.ts | 19 ++---- .../emails/policy-acknowledgment-digest.tsx | 4 +- 14 files changed, 117 insertions(+), 67 deletions(-) create mode 100644 apps/api/src/email/unsubscribe-headers.ts diff --git a/Dockerfile b/Dockerfile index 38a8ca8500..40d98259c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,11 +47,7 @@ RUN cd packages/db && bun run build # Install Node.js (Bun's WASM engine has a known crash bug with Prisma 7's # query compiler - see https://github.com/prisma/prisma/issues/28805 and # https://github.com/oven-sh/bun/issues/17146). Run seed under Node instead. -RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ - && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y nodejs \ - && rm -rf /var/lib/apt/lists/* \ - && npm install -g tsx +RUN bun add -g tsx@4.19.3 CMD ["sh", "-lc", "cd packages/db && bunx prisma migrate deploy"] diff --git a/apps/api/prisma/client.ts b/apps/api/prisma/client.ts index 3c43ad10e5..c582179664 100644 --- a/apps/api/prisma/client.ts +++ b/apps/api/prisma/client.ts @@ -3,7 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; const globalForPrisma = global as unknown as { prisma?: PrismaClient }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); function stripSslMode(connectionString: string): string { const url = new URL(connectionString); @@ -13,9 +13,11 @@ function stripSslMode(connectionString: string): string { function isLocalhostUrl(connectionString: string): boolean { try { - const { hostname } = new URL(connectionString); - // Strip square brackets from IPv6 host form (e.g. [::1] → ::1) - const stripped = hostname.replace(/^\[/, '').replace(/\]$/, ''); + const url = new URL(connectionString); + if (url.searchParams.get('sslmode') === 'disable') { + return true; + } + const stripped = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); return LOCAL_HOSTNAMES.has(stripped); } catch { // Malformed URL — be conservative and treat as remote so we don't diff --git a/apps/api/src/email/email-transport.ts b/apps/api/src/email/email-transport.ts index b36510c940..3efc4778a7 100644 --- a/apps/api/src/email/email-transport.ts +++ b/apps/api/src/email/email-transport.ts @@ -64,12 +64,20 @@ function resolveSmtpTransport() { }); } +/** Trigger.dev serializes attachment bytes as base64 strings in task payloads. */ +function attachmentContent(content: Buffer | string): Buffer { + if (Buffer.isBuffer(content)) { + return content; + } + return Buffer.from(content, 'base64'); +} + function normalizeAttachments( attachments?: EmailAttachment[], ): Mail.Attachment[] | undefined { return attachments?.map((att) => ({ filename: att.filename, - content: att.content, + content: attachmentContent(att.content), contentType: att.contentType, })); } @@ -105,7 +113,12 @@ async function sendViaBunMail(params: { }); const body = (await response.json().catch(() => null)) as - | { success?: boolean; data?: { id?: string }; error?: string; message?: string } + | { + success?: boolean; + data?: { id?: string }; + error?: string; + message?: string; + } | null; if (!response.ok || !body?.success) { @@ -179,6 +192,16 @@ async function sendViaResend(params: { return { id: data?.id ?? 'resend' }; } +function needsRichEmailFeatures(params: { + attachments?: EmailAttachment[]; + scheduledAt?: string; + headers?: Record; +}): boolean { + return Boolean( + params.attachments?.length || params.scheduledAt || params.headers, + ); +} + export async function sendHtmlEmail(params: { to: string; subject: string; @@ -197,7 +220,7 @@ export async function sendHtmlEmail(params: { process.env.SMTP_FROM ?? process.env.RESEND_FROM_SYSTEM ?? process.env.RESEND_FROM_DEFAULT; - const toAddress = process.env.RESEND_TO_TEST ?? params.to; + const toAddress = params.to; if (!fromAddress) { throw new Error( @@ -208,7 +231,9 @@ export async function sendHtmlEmail(params: { throw new Error('Missing TO address in environment variables'); } - if (isBunMailConfigured()) { + const richFeatures = needsRichEmailFeatures(params); + + if (isBunMailConfigured() && !richFeatures) { return sendViaBunMail({ from: fromAddress, to: toAddress, @@ -218,6 +243,35 @@ export async function sendHtmlEmail(params: { }); } + if (isBunMailConfigured() && richFeatures) { + if (isSmtpConfigured()) { + return sendViaSmtp({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + headers: params.headers, + attachments: params.attachments, + }); + } + if (resend) { + return sendViaResend({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + headers: params.headers, + scheduledAt: params.scheduledAt, + attachments: params.attachments, + }); + } + throw new Error( + 'BunMail cannot send attachments, scheduled delivery, or custom headers; configure SMTP_HOST or RESEND_API_KEY', + ); + } + if (isSmtpConfigured()) { return sendViaSmtp({ from: fromAddress, diff --git a/apps/api/src/email/trigger-email.ts b/apps/api/src/email/trigger-email.ts index f078e3528d..83ad70b3b7 100644 --- a/apps/api/src/email/trigger-email.ts +++ b/apps/api/src/email/trigger-email.ts @@ -1,7 +1,7 @@ import { render } from '@react-email/render'; import { tasks } from '@trigger.dev/sdk'; import type { ReactElement } from 'react'; -import { generateUnsubscribeToken } from '@trycompai/email'; +import { buildUnsubscribeHeaders } from './unsubscribe-headers'; import type { EmailChannel, sendEmailTask } from '../trigger/email/send-email'; import type { EmailAttachment } from './resend'; import { sendHtmlEmail } from './email-transport'; @@ -28,9 +28,11 @@ async function sendEmailDirect(params: { scheduledAt?: string; attachments?: EmailAttachment[]; }): Promise<{ id: string }> { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; - const token = generateUnsubscribeToken(params.to); - const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(params.to)}&token=${encodeURIComponent(token)}`; + if (params.scheduledAt) { + throw new Error( + 'Scheduled email delivery requires Trigger.dev (TRIGGER_SECRET_KEY)', + ); + } return sendHtmlEmail({ to: params.to, @@ -38,12 +40,8 @@ async function sendEmailDirect(params: { html: params.html, channel: params.channel, cc: params.cc, - scheduledAt: params.scheduledAt, attachments: params.attachments, - headers: { - 'List-Unsubscribe': `<${oneClickUrl}>`, - 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', - }, + headers: buildUnsubscribeHeaders(params.to), }); } diff --git a/apps/api/src/email/unsubscribe-headers.ts b/apps/api/src/email/unsubscribe-headers.ts new file mode 100644 index 0000000000..1cfed7dc2e --- /dev/null +++ b/apps/api/src/email/unsubscribe-headers.ts @@ -0,0 +1,12 @@ +import { generateUnsubscribeToken } from '@trycompai/email'; + +export function buildUnsubscribeHeaders(to: string): Record { + const apiBaseUrl = + process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; + const token = generateUnsubscribeToken(to); + const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(to)}&token=${encodeURIComponent(token)}`; + return { + 'List-Unsubscribe': `<${oneClickUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }; +} diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 06b9abf499..945ec2789a 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -677,9 +677,7 @@ export class PeopleInviteService { } private buildPortalUrl(organizationId: string): string { - const portalUrl = - process.env.PORTAL_URL ?? process.env.NEXT_PUBLIC_PORTAL_URL ?? process.env.TRUST_APP_URL ?? 'https://portal.trycomp.ai'; - return `${portalUrl}/${organizationId}`; + return `${getPortalBaseUrl()}/${organizationId}`; } private buildInviteLink(invitationId: string): string { diff --git a/apps/api/src/trigger/email/send-email.ts b/apps/api/src/trigger/email/send-email.ts index 51d27a960f..830d6ad8a0 100644 --- a/apps/api/src/trigger/email/send-email.ts +++ b/apps/api/src/trigger/email/send-email.ts @@ -1,7 +1,7 @@ import { logger, queue, schemaTask } from '@trigger.dev/sdk'; import { z } from 'zod'; import { sendHtmlEmail } from '../../email/email-transport'; -import { generateUnsubscribeToken } from '@trycompai/email'; +import { buildUnsubscribeHeaders } from '../../email/unsubscribe-headers'; const emailQueue = queue({ name: 'send-email', @@ -42,14 +42,7 @@ export const sendEmailTask = schemaTask({ }), run: async (params) => { try { - const apiBaseUrl = - process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; - const token = generateUnsubscribeToken(params.to); - const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(params.to)}&token=${encodeURIComponent(token)}`; - const headers: Record = { - 'List-Unsubscribe': `<${oneClickUrl}>`, - 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', - }; + const headers = buildUnsubscribeHeaders(params.to); const result = await sendHtmlEmail({ to: params.to, diff --git a/apps/app/prisma/client.ts b/apps/app/prisma/client.ts index 6f6896a4f9..ad14bb1085 100644 --- a/apps/app/prisma/client.ts +++ b/apps/app/prisma/client.ts @@ -3,7 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; const globalForPrisma = global as unknown as { prisma?: PrismaClient }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); function stripSslMode(connectionString: string): string { const url = new URL(connectionString); diff --git a/apps/app/src/app/(app)/no-access/page.tsx b/apps/app/src/app/(app)/no-access/page.tsx index 50e16203fe..7a79c11513 100644 --- a/apps/app/src/app/(app)/no-access/page.tsx +++ b/apps/app/src/app/(app)/no-access/page.tsx @@ -4,6 +4,7 @@ import { serverApi } from '@/lib/api-server'; import type { OrganizationFromMe } from '@/types'; import { auth } from '@/utils/auth'; import { headers } from 'next/headers'; +import { getPortalBaseUrl } from '@trycompai/email/lib/get-portal-base-url'; import Link from 'next/link'; import { redirect } from 'next/navigation'; @@ -26,6 +27,8 @@ export default async function NoAccess() { ]); const organizations = meRes.data?.organizations ?? []; + const portalBase = getPortalBaseUrl(); + const portalLabel = portalBase.replace(/^https?:\/\//, ''); const currentOrg = orgRes.data ?? null; return ( @@ -36,8 +39,8 @@ export default async function NoAccess() {

Your current role doesn't have access to the app. If you're looking for the employee portal, go to{' '} - - {process.env.NEXT_PUBLIC_PORTAL_URL?.replace(/^https?:\/\//, "") ?? "portal.trycomp.ai"} + + {portalLabel} .

diff --git a/apps/portal/prisma/client.ts b/apps/portal/prisma/client.ts index 9074dfc496..10fbaae19c 100644 --- a/apps/portal/prisma/client.ts +++ b/apps/portal/prisma/client.ts @@ -3,7 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; const globalForPrisma = global as unknown as { prisma?: PrismaClient }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); function stripSslMode(connectionString: string): string { const url = new URL(connectionString); diff --git a/apps/portal/src/app/api/download-agent/route.ts b/apps/portal/src/app/api/download-agent/route.ts index 70c31705f8..cccd63076b 100644 --- a/apps/portal/src/app/api/download-agent/route.ts +++ b/apps/portal/src/app/api/download-agent/route.ts @@ -107,10 +107,10 @@ const handleDownload = async (req: NextRequest, isHead: boolean) => { await kv.del(`download:${token}`); - const bytes = await s3Response.Body.transformToByteArray(); + const webStream = s3Response.Body.transformToWebStream(); - return new NextResponse(Buffer.from(bytes), { - headers: buildResponseHeaders(target, bytes.length), + return new NextResponse(webStream, { + headers: buildResponseHeaders(target, s3Response.ContentLength ?? null), }); } catch (error) { logger('Error serving device agent download', { diff --git a/docker-compose.yml b/docker-compose.yml index b878e4e01c..5f1bd43ff1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,7 +69,7 @@ services: target: portal args: NEXT_PUBLIC_BETTER_AUTH_URL: ${BETTER_AUTH_URL_PORTAL} - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL_PORTAL} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} ports: - '3002:3000' env_file: @@ -82,13 +82,13 @@ services: retries: 3 logging: *default-logging minio: - image: minio/minio:latest + image: minio/minio:RELEASE.2025-01-20T14-49-07Z profiles: - minio command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER for the minio profile} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD for the minio profile} ports: - '9000:9000' - '9001:9001' @@ -97,22 +97,25 @@ services: restart: unless-stopped logging: *default-logging minio-init: - image: minio/mc:latest + image: minio/mc:RELEASE.2025-01-17T23-25-50Z profiles: - minio depends_on: - minio environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER for the minio profile} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD for the minio profile} + APP_AWS_BUCKET_NAME: ${APP_AWS_BUCKET_NAME:?Set APP_AWS_BUCKET_NAME for the minio profile} entrypoint: > /bin/sh -c " + set -e; for i in 1 2 3 4 5 6 7 8 9 10; do mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && break; sleep 2; - done && - mc mb -p local/$${APP_AWS_BUCKET_NAME:-comp} || true && - echo bucket ready + done; + mc mb -p local/$$APP_AWS_BUCKET_NAME 2>/dev/null || true; + mc stat local/$$APP_AWS_BUCKET_NAME >/dev/null; + echo bucket $$APP_AWS_BUCKET_NAME ready " restart: 'no' logging: *default-logging diff --git a/packages/db/src/ssl-config.ts b/packages/db/src/ssl-config.ts index 53508c6ba3..90768a2cb8 100644 --- a/packages/db/src/ssl-config.ts +++ b/packages/db/src/ssl-config.ts @@ -3,12 +3,15 @@ export type SslConfig = | { checkServerIdentity: () => undefined } | { rejectUnauthorized: false }; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', 'comp-postgres']); +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); function isLocalhostUrl(connectionString: string): boolean { try { - const { hostname } = new URL(connectionString); - const stripped = hostname.replace(/^\[/, '').replace(/\]$/, ''); + const url = new URL(connectionString); + if (url.searchParams.get('sslmode') === 'disable') { + return true; + } + const stripped = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); return LOCAL_HOSTNAMES.has(stripped); } catch { // Malformed URL — be conservative and treat as remote so we don't @@ -23,15 +26,5 @@ export function resolveSslConfig( ): SslConfig { if (isLocalhostUrl(databaseUrl)) return undefined; if (env.PRISMA_ALLOW_INSECURE_TLS === '1') return { rejectUnauthorized: false }; - // Verified TLS via Node's default trust store, which includes Amazon Root - // CA 1 — where AWS RDS Proxy chains terminate. Hostname check is skipped - // because connections traverse an AWS NLB whose hostname isn't in the RDS - // Proxy cert's SAN list; the chain check still rejects forged or wrong-CA - // certs. - // - // Previously this returned `{ ca: RDS_CA_BUNDLE, ... }` — but `ssl.ca` - // *replaces* Node's trust store rather than augmenting it, and the bundle - // only contains regional RDS CAs (not Amazon Root CA 1), so RDS Proxy - // chain validation failed at runtime (P1011 / TlsConnectionError). return { checkServerIdentity: () => undefined }; } diff --git a/packages/email/emails/policy-acknowledgment-digest.tsx b/packages/email/emails/policy-acknowledgment-digest.tsx index 52d3c144ac..c8c3da0d8c 100644 --- a/packages/email/emails/policy-acknowledgment-digest.tsx +++ b/packages/email/emails/policy-acknowledgment-digest.tsx @@ -61,9 +61,7 @@ export const PolicyAcknowledgmentDigestEmail = ({ const [firstOrg] = orgsWithPolicies; if (!firstOrg) return null; - const portalBase = ( - getPortalBaseUrl() - ).replace(/\/+$/, ''); + const portalBase = getPortalBaseUrl(); const subjectText = computePolicyAcknowledgmentDigestSubject(orgsWithPolicies); const isMultiOrg = orgsWithPolicies.length > 1; From ccdbdc0f3f7b2cf60d3c468553ac7aa5faf23353 Mon Sep 17 00:00:00 2001 From: Raed Brahem Date: Mon, 3 Aug 2026 10:06:00 +0000 Subject: [PATCH 5/7] fix(db): honor sslmode=disable in app and portal Prisma clients --- apps/app/prisma/client.ts | 7 +++++-- apps/portal/prisma/client.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/app/prisma/client.ts b/apps/app/prisma/client.ts index ad14bb1085..df82930d08 100644 --- a/apps/app/prisma/client.ts +++ b/apps/app/prisma/client.ts @@ -13,8 +13,11 @@ function stripSslMode(connectionString: string): string { function isLocalhostUrl(connectionString: string): boolean { try { - const { hostname } = new URL(connectionString); - const stripped = hostname.replace(/^\[/, '').replace(/\]$/, ''); + const url = new URL(connectionString); + if (url.searchParams.get('sslmode') === 'disable') { + return true; + } + const stripped = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); return LOCAL_HOSTNAMES.has(stripped); } catch { return false; diff --git a/apps/portal/prisma/client.ts b/apps/portal/prisma/client.ts index 10fbaae19c..ef589f1711 100644 --- a/apps/portal/prisma/client.ts +++ b/apps/portal/prisma/client.ts @@ -13,8 +13,11 @@ function stripSslMode(connectionString: string): string { function isLocalhostUrl(connectionString: string): boolean { try { - const { hostname } = new URL(connectionString); - const stripped = hostname.replace(/^\[/, '').replace(/\]$/, ''); + const url = new URL(connectionString); + if (url.searchParams.get('sslmode') === 'disable') { + return true; + } + const stripped = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); return LOCAL_HOSTNAMES.has(stripped); } catch { return false; From 30114013d8c040ad72f44780f357899b44347c91 Mon Sep 17 00:00:00 2001 From: Raed Brahem Date: Mon, 3 Aug 2026 10:24:57 +0000 Subject: [PATCH 6/7] fix(self-host): address second round of PR review feedback - Allow BunMail for standard triggerEmail flows (headers no longer block it) - Route scheduled sends to Resend only; reject when Resend is unavailable - Only override S3 presigning client when APP_AWS_PUBLIC_ENDPOINT is set - Validate MinIO env vars at container start, not compose parse time - Restore Node.js in migrator image for tsx/Prisma seed compatibility - Sync sslmode=disable handling in framework-editor and combine-schemas - Trim API base URL in unsubscribe helper; reuse in batch email task --- Dockerfile | 6 ++- apps/api/src/app/s3.ts | 33 ++++++++++------- apps/api/src/email/email-transport.ts | 37 +++++++++++-------- apps/api/src/email/unsubscribe-headers.ts | 5 ++- .../api/src/trigger/email/send-batch-email.ts | 28 +++++--------- apps/framework-editor/prisma/client.ts | 7 +++- apps/portal/src/utils/s3.ts | 26 +++++++------ docker-compose.yml | 21 ++++++++--- packages/db/scripts/combine-schemas.js | 12 +++++- 9 files changed, 105 insertions(+), 70 deletions(-) diff --git a/Dockerfile b/Dockerfile index 40d98259c2..fd2498399e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,11 @@ RUN cd packages/db && bun run build # Install Node.js (Bun's WASM engine has a known crash bug with Prisma 7's # query compiler - see https://github.com/prisma/prisma/issues/28805 and # https://github.com/oven-sh/bun/issues/17146). Run seed under Node instead. -RUN bun add -g tsx@4.19.3 +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* \ + && bun add -g tsx@4.19.3 CMD ["sh", "-lc", "cd packages/db && bunx prisma migrate deploy"] diff --git a/apps/api/src/app/s3.ts b/apps/api/src/app/s3.ts index 1a303b0501..a9080280ee 100644 --- a/apps/api/src/app/s3.ts +++ b/apps/api/src/app/s3.ts @@ -21,13 +21,19 @@ const _getSignedUrlTyped = _getSignedUrl as unknown as ( options?: { expiresIn?: number }, ) => Promise; -/** Use public-endpoint client for presigned URLs when configured. */ +/** Use public-endpoint client for presigned URLs only when explicitly configured. */ export const getSignedUrl = ( client: S3Client, command: GetObjectCommand | PutObjectCommand, options?: { expiresIn?: number }, ): Promise => - _getSignedUrlTyped(s3SigningClientInstance ?? client, command, options); + _getSignedUrlTyped( + APP_AWS_PUBLIC_ENDPOINT && s3SigningClientInstance + ? s3SigningClientInstance + : client, + command, + options, + ); const logger = new Logger('S3'); @@ -36,8 +42,7 @@ const APP_AWS_ACCESS_KEY_ID = process.env.APP_AWS_ACCESS_KEY_ID; const APP_AWS_SECRET_ACCESS_KEY = process.env.APP_AWS_SECRET_ACCESS_KEY; const APP_AWS_ENDPOINT = process.env.APP_AWS_ENDPOINT; /** Browser-reachable MinIO/S3 URL for presigned URLs. Falls back to APP_AWS_ENDPOINT. */ -const APP_AWS_PUBLIC_ENDPOINT = - process.env.APP_AWS_PUBLIC_ENDPOINT || process.env.APP_AWS_ENDPOINT; +const APP_AWS_PUBLIC_ENDPOINT = process.env.APP_AWS_PUBLIC_ENDPOINT?.trim() || undefined; export const BUCKET_NAME = process.env.APP_AWS_BUCKET_NAME; export const APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET = @@ -74,15 +79,17 @@ try { forcePathStyle: !!APP_AWS_ENDPOINT, }); - s3SigningClientInstance = new S3Client({ - endpoint: APP_AWS_PUBLIC_ENDPOINT || undefined, - region: APP_AWS_REGION, - credentials: { - accessKeyId: APP_AWS_ACCESS_KEY_ID, - secretAccessKey: APP_AWS_SECRET_ACCESS_KEY, - }, - forcePathStyle: !!APP_AWS_PUBLIC_ENDPOINT, - }); + if (APP_AWS_PUBLIC_ENDPOINT) { + s3SigningClientInstance = new S3Client({ + endpoint: APP_AWS_PUBLIC_ENDPOINT, + region: APP_AWS_REGION, + credentials: { + accessKeyId: APP_AWS_ACCESS_KEY_ID, + secretAccessKey: APP_AWS_SECRET_ACCESS_KEY, + }, + forcePathStyle: true, + }); + } } catch (error) { logger.error( 'FAILED TO INITIALIZE S3 CLIENT', diff --git a/apps/api/src/email/email-transport.ts b/apps/api/src/email/email-transport.ts index 3efc4778a7..7c461b752c 100644 --- a/apps/api/src/email/email-transport.ts +++ b/apps/api/src/email/email-transport.ts @@ -192,16 +192,6 @@ async function sendViaResend(params: { return { id: data?.id ?? 'resend' }; } -function needsRichEmailFeatures(params: { - attachments?: EmailAttachment[]; - scheduledAt?: string; - headers?: Record; -}): boolean { - return Boolean( - params.attachments?.length || params.scheduledAt || params.headers, - ); -} - export async function sendHtmlEmail(params: { to: string; subject: string; @@ -231,9 +221,27 @@ export async function sendHtmlEmail(params: { throw new Error('Missing TO address in environment variables'); } - const richFeatures = needsRichEmailFeatures(params); + if (params.scheduledAt) { + if (resend) { + return sendViaResend({ + from: fromAddress, + to: toAddress, + subject: params.subject, + html: params.html, + cc: params.cc, + headers: params.headers, + scheduledAt: params.scheduledAt, + attachments: params.attachments, + }); + } + throw new Error( + 'Scheduled email delivery requires RESEND_API_KEY or Trigger.dev', + ); + } + + const hasAttachments = Boolean(params.attachments?.length); - if (isBunMailConfigured() && !richFeatures) { + if (isBunMailConfigured() && !hasAttachments) { return sendViaBunMail({ from: fromAddress, to: toAddress, @@ -243,7 +251,7 @@ export async function sendHtmlEmail(params: { }); } - if (isBunMailConfigured() && richFeatures) { + if (isBunMailConfigured() && hasAttachments) { if (isSmtpConfigured()) { return sendViaSmtp({ from: fromAddress, @@ -263,12 +271,11 @@ export async function sendHtmlEmail(params: { html: params.html, cc: params.cc, headers: params.headers, - scheduledAt: params.scheduledAt, attachments: params.attachments, }); } throw new Error( - 'BunMail cannot send attachments, scheduled delivery, or custom headers; configure SMTP_HOST or RESEND_API_KEY', + 'BunMail cannot send attachments; configure SMTP_HOST or RESEND_API_KEY', ); } diff --git a/apps/api/src/email/unsubscribe-headers.ts b/apps/api/src/email/unsubscribe-headers.ts index 1cfed7dc2e..1116af8590 100644 --- a/apps/api/src/email/unsubscribe-headers.ts +++ b/apps/api/src/email/unsubscribe-headers.ts @@ -1,8 +1,9 @@ import { generateUnsubscribeToken } from '@trycompai/email'; export function buildUnsubscribeHeaders(to: string): Record { - const apiBaseUrl = - process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; + const apiBaseUrl = ( + process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai' + ).replace(/\/+$/, ''); const token = generateUnsubscribeToken(to); const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(to)}&token=${encodeURIComponent(token)}`; return { diff --git a/apps/api/src/trigger/email/send-batch-email.ts b/apps/api/src/trigger/email/send-batch-email.ts index e095e5a2ca..266ed20c67 100644 --- a/apps/api/src/trigger/email/send-batch-email.ts +++ b/apps/api/src/trigger/email/send-batch-email.ts @@ -1,7 +1,7 @@ import { logger, queue, schemaTask } from '@trigger.dev/sdk'; import { z } from 'zod'; import { resend } from '../../email/resend'; -import { generateUnsubscribeToken } from '@trycompai/email'; +import { buildUnsubscribeHeaders } from '../../email/unsubscribe-headers'; const RESEND_BATCH_LIMIT = 100; @@ -41,8 +41,6 @@ export const sendBatchEmailTask = schemaTask({ } const toTest = process.env.RESEND_TO_TEST; - const apiBaseUrl = - process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai'; let totalSent = 0; let totalFailed = 0; @@ -50,22 +48,14 @@ export const sendBatchEmailTask = schemaTask({ for (let i = 0; i < params.emails.length; i += RESEND_BATCH_LIMIT) { const chunk = params.emails.slice(i, i + RESEND_BATCH_LIMIT); - const payload = chunk.map((email) => { - const token = generateUnsubscribeToken(email.to); - const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(email.to)}&token=${encodeURIComponent(token)}`; - - return { - from: email.from ?? fromDefault, - to: toTest ?? email.to, - cc: email.cc, - subject: email.subject, - html: email.html, - headers: { - 'List-Unsubscribe': `<${oneClickUrl}>`, - 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', - }, - }; - }); + const payload = chunk.map((email) => ({ + from: email.from ?? fromDefault, + to: toTest ?? email.to, + cc: email.cc, + subject: email.subject, + html: email.html, + headers: buildUnsubscribeHeaders(email.to), + })); const { data, error } = await resend.batch.send(payload, { batchValidation: 'permissive', diff --git a/apps/framework-editor/prisma/client.ts b/apps/framework-editor/prisma/client.ts index fa5986368b..9395fb2fce 100644 --- a/apps/framework-editor/prisma/client.ts +++ b/apps/framework-editor/prisma/client.ts @@ -13,8 +13,11 @@ function stripSslMode(connectionString: string): string { function isLocalhostUrl(connectionString: string): boolean { try { - const { hostname } = new URL(connectionString); - const stripped = hostname.replace(/^\[/, '').replace(/\]$/, ''); + const url = new URL(connectionString); + if (url.searchParams.get('sslmode') === 'disable') { + return true; + } + const stripped = url.hostname.replace(/^\[/, '').replace(/\]$/, ''); return LOCAL_HOSTNAMES.has(stripped); } catch { return false; diff --git a/apps/portal/src/utils/s3.ts b/apps/portal/src/utils/s3.ts index 7d6357e959..5eedd4274b 100644 --- a/apps/portal/src/utils/s3.ts +++ b/apps/portal/src/utils/s3.ts @@ -18,8 +18,7 @@ const APP_AWS_REGION = process.env.APP_AWS_REGION; const APP_AWS_ACCESS_KEY_ID = process.env.APP_AWS_ACCESS_KEY_ID; const APP_AWS_SECRET_ACCESS_KEY = process.env.APP_AWS_SECRET_ACCESS_KEY; const APP_AWS_ENDPOINT = process.env.APP_AWS_ENDPOINT; -const APP_AWS_PUBLIC_ENDPOINT = - process.env.APP_AWS_PUBLIC_ENDPOINT || process.env.APP_AWS_ENDPOINT; +const APP_AWS_PUBLIC_ENDPOINT = process.env.APP_AWS_PUBLIC_ENDPOINT?.trim() || undefined; export const BUCKET_NAME = process.env.APP_AWS_BUCKET_NAME; export const APP_AWS_ORG_ASSETS_BUCKET = process.env.APP_AWS_ORG_ASSETS_BUCKET; @@ -42,21 +41,24 @@ export const s3Client = new S3Client({ forcePathStyle: !!APP_AWS_ENDPOINT, }); -const s3SigningClient = new S3Client({ - endpoint: APP_AWS_PUBLIC_ENDPOINT || undefined, - region: APP_AWS_REGION!, - credentials: { - accessKeyId: APP_AWS_ACCESS_KEY_ID!, - secretAccessKey: APP_AWS_SECRET_ACCESS_KEY!, - }, - forcePathStyle: !!APP_AWS_PUBLIC_ENDPOINT, -}); +const s3SigningClient = APP_AWS_PUBLIC_ENDPOINT + ? new S3Client({ + endpoint: APP_AWS_PUBLIC_ENDPOINT, + region: APP_AWS_REGION!, + credentials: { + accessKeyId: APP_AWS_ACCESS_KEY_ID!, + secretAccessKey: APP_AWS_SECRET_ACCESS_KEY!, + }, + forcePathStyle: true, + }) + : null; export const getSignedUrl = ( client: S3Client, command: GetObjectCommand | PutObjectCommand, options?: { expiresIn?: number }, -): Promise => _getSignedUrlTyped(s3SigningClient, command, options); +): Promise => + _getSignedUrlTyped(s3SigningClient ?? client, command, options); // Ensure BUCKET_NAME is exported and non-null checked if needed elsewhere explicitly if (!BUCKET_NAME && process.env.NODE_ENV === 'production') { diff --git a/docker-compose.yml b/docker-compose.yml index 5f1bd43ff1..6cf303d548 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,10 +85,17 @@ services: image: minio/minio:RELEASE.2025-01-20T14-49-07Z profiles: - minio - command: server /data --console-address ":9001" + command: > + /bin/sh -c " + if [ -z "$$MINIO_ROOT_USER" ] || [ -z "$$MINIO_ROOT_PASSWORD" ]; then + echo 'MINIO_ROOT_USER and MINIO_ROOT_PASSWORD are required for the minio profile' >&2; + exit 1; + fi; + exec minio server /data --console-address ':9001' + " environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER for the minio profile} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD for the minio profile} + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} ports: - '9000:9000' - '9001:9001' @@ -103,12 +110,16 @@ services: depends_on: - minio environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER for the minio profile} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD for the minio profile} + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} APP_AWS_BUCKET_NAME: ${APP_AWS_BUCKET_NAME:?Set APP_AWS_BUCKET_NAME for the minio profile} entrypoint: > /bin/sh -c " set -e; + if [ -z "$$MINIO_ROOT_USER" ] || [ -z "$$MINIO_ROOT_PASSWORD" ] || [ -z "$$APP_AWS_BUCKET_NAME" ]; then + echo 'MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, and APP_AWS_BUCKET_NAME are required for the minio profile' >&2; + exit 1; + fi; for i in 1 2 3 4 5 6 7 8 9 10; do mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && break; sleep 2; diff --git a/packages/db/scripts/combine-schemas.js b/packages/db/scripts/combine-schemas.js index 1b7212a41c..ce6c505321 100755 --- a/packages/db/scripts/combine-schemas.js +++ b/packages/db/scripts/combine-schemas.js @@ -62,7 +62,17 @@ function stripSslMode(connectionString: string): string { function createPrismaClient(): PrismaClient { const rawUrl = process.env.DATABASE_URL!; - const isLocalhost = /localhost|127\\.0\\.0\\.1|::1/.test(rawUrl); + let isLocalhost = false; + try { + const dbUrl = new URL(rawUrl); + isLocalhost = + dbUrl.searchParams.get('sslmode') === 'disable' || + /^(localhost|127\\.0\\.0\\.1|::1)$/.test( + dbUrl.hostname.replace(/^\\[/, '').replace(/\\]$/, ''), + ); + } catch { + isLocalhost = /localhost|127\\.0\\.0\\.1|::1/.test(rawUrl); + } const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS; const ssl = isLocalhost ? undefined : hasCABundle ? true : { rejectUnauthorized: false }; const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; From 7af047bfddadc5db50487df01e1cccee49d7f544 Mon Sep 17 00:00:00 2001 From: Raed Brahem Date: Mon, 3 Aug 2026 10:35:09 +0000 Subject: [PATCH 7/7] fix(db): conservative TLS fallback in combine-schemas client template Co-authored-by: Cursor --- Dockerfile | 12 ++++-------- apps/api/src/app/s3.ts | 2 +- docker-compose.yml | 2 +- packages/db/scripts/combine-schemas.js | 2 +- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index fd2498399e..06aa8805e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,14 +44,10 @@ COPY packages/db ./packages/db # AND builds the combined dist/schema.prisma - both from source, not npm RUN cd packages/db && bun run build -# Install Node.js (Bun's WASM engine has a known crash bug with Prisma 7's -# query compiler - see https://github.com/prisma/prisma/issues/28805 and -# https://github.com/oven-sh/bun/issues/17146). Run seed under Node instead. -RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ - && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y nodejs \ - && rm -rf /var/lib/apt/lists/* \ - && bun add -g tsx@4.19.3 +# Real Node.js for tsx seed (Prisma 7 query compiler crashes under Bun WASM). +# Copy a pinned runtime from the official Node image instead of curl|bash installers. +COPY --from=node:22.13.1-bookworm-slim /usr/local/bin/node /usr/local/bin/node +RUN bun add -g tsx@4.19.3 CMD ["sh", "-lc", "cd packages/db && bunx prisma migrate deploy"] diff --git a/apps/api/src/app/s3.ts b/apps/api/src/app/s3.ts index a9080280ee..9cdddd7dd9 100644 --- a/apps/api/src/app/s3.ts +++ b/apps/api/src/app/s3.ts @@ -41,7 +41,7 @@ const APP_AWS_REGION = process.env.APP_AWS_REGION; const APP_AWS_ACCESS_KEY_ID = process.env.APP_AWS_ACCESS_KEY_ID; const APP_AWS_SECRET_ACCESS_KEY = process.env.APP_AWS_SECRET_ACCESS_KEY; const APP_AWS_ENDPOINT = process.env.APP_AWS_ENDPOINT; -/** Browser-reachable MinIO/S3 URL for presigned URLs. Falls back to APP_AWS_ENDPOINT. */ +/** Optional browser-reachable URL used to sign presigned URLs; when unset, presigning uses the caller-provided S3 client. */ const APP_AWS_PUBLIC_ENDPOINT = process.env.APP_AWS_PUBLIC_ENDPOINT?.trim() || undefined; export const BUCKET_NAME = process.env.APP_AWS_BUCKET_NAME; diff --git a/docker-compose.yml b/docker-compose.yml index 6cf303d548..a6aa45c69d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,7 +112,7 @@ services: environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} - APP_AWS_BUCKET_NAME: ${APP_AWS_BUCKET_NAME:?Set APP_AWS_BUCKET_NAME for the minio profile} + APP_AWS_BUCKET_NAME: ${APP_AWS_BUCKET_NAME} entrypoint: > /bin/sh -c " set -e; diff --git a/packages/db/scripts/combine-schemas.js b/packages/db/scripts/combine-schemas.js index ce6c505321..ad09a12551 100755 --- a/packages/db/scripts/combine-schemas.js +++ b/packages/db/scripts/combine-schemas.js @@ -71,7 +71,7 @@ function createPrismaClient(): PrismaClient { dbUrl.hostname.replace(/^\\[/, '').replace(/\\]$/, ''), ); } catch { - isLocalhost = /localhost|127\\.0\\.0\\.1|::1/.test(rawUrl); + // Malformed URL — treat as remote so TLS is not accidentally disabled. } const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS; const ssl = isLocalhost ? undefined : hasCABundle ? true : { rejectUnauthorized: false };