diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 1c5d805..06da952 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -56,6 +56,12 @@ WORKDIR /app COPY --from=installer --chown=outpost:outpost /app/apps/web/.next/standalone ./ COPY --from=installer --chown=outpost:outpost /app/apps/web/.next/static ./apps/web/.next/static COPY --from=installer --chown=outpost:outpost /app/apps/web/public ./apps/web/public +# Email templates live at the repo root, outside every workspace package, so turbo +# prune leaves them out of out/full/ and Next standalone tracing cannot see the +# runtime-computed readFileSync path. Copied from the pruner, which has the whole +# build context. The standalone server chdirs to /app/apps/web, and the loader finds +# /app/templates via its ../../templates candidate (outpost#253). +COPY --from=pruner --chown=outpost:outpost /app/templates ./templates # Prisma query engine for linux-musl (required by Next.js standalone) COPY --from=installer --chown=outpost:outpost /app/node_modules/.pnpm/@prisma+client*/node_modules/.prisma/client/*.node ./node_modules/.prisma/client/ COPY --from=installer --chown=outpost:outpost /app/node_modules/.pnpm/@prisma+client*/node_modules/.prisma/client/schema.prisma ./node_modules/.prisma/client/ diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index a6a259e..970e965 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -4,17 +4,25 @@ const nextConfig: NextConfig = { output: 'standalone', transpilePackages: ['@copilotkit/outpost'], async headers() { - return [{ - source: '/(.*)', - headers: [ - { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'X-Frame-Options', value: 'DENY' }, - { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, - { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, - { key: 'X-XSS-Protection', value: '1; mode=block' }, - { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, - ], - }]; + return [ + { + source: '/(.*)', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { + key: 'Strict-Transport-Security', + value: 'max-age=31536000; includeSubDomains', + }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'X-XSS-Protection', value: '1; mode=block' }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + ], + }, + ]; }, webpack: (config, { isServer }) => { if (isServer) { @@ -22,10 +30,20 @@ const nextConfig: NextConfig = { // but get pulled in transitively via the queue/shared barrel exports. // Replace with empty modules so webpack doesn't try to require them. const nullPkgs = [ - 'discord.js', '@discordjs/rest', '@discordjs/ws', '@discordjs/collection', - 'zlib-sync', 'bufferutil', 'utf-8-validate', - '@slack/web-api', '@slack/bolt', - 'postmark', 'nodemailer', + 'discord.js', + '@discordjs/rest', + '@discordjs/ws', + '@discordjs/collection', + 'zlib-sync', + 'bufferutil', + 'utf-8-validate', + '@slack/web-api', + '@slack/bolt', + // nodemailer stays nulled: it is not installed, and the SMTP transport + // only matters for self-hosted setups. postmark is NOT here: sendEmail + // (transpiled from @copilotkit/outpost) loads it in this server bundle, + // and nulling it made every Postmark send fail (outpost#253). + 'nodemailer', ]; for (const pkg of nullPkgs) { config.resolve.alias[pkg] = false; diff --git a/apps/web/src/__tests__/production-email-image.test.ts b/apps/web/src/__tests__/production-email-image.test.ts new file mode 100644 index 0000000..0d84373 --- /dev/null +++ b/apps/web/src/__tests__/production-email-image.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment node +/** + * Guards for outpost#253: email could not work in the production web image. + * + * The template tests pass from a checkout because the loader finds the repo-root + * templates/ by walking up from cwd. The image has no checkout, so the runner stage + * has to copy templates/ in itself. And the server bundle has to be able to load + * postmark, because sendEmail is transpiled into it. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'fs'; +import path from 'path'; +import nextConfig from '../../next.config'; + +const repoRoot = path.resolve(__dirname, '../../../..'); + +function runnerStage(dockerfile: string): string[] { + const lines = dockerfile.split('\n').map((l) => l.trim()); + const start = lines.findIndex((l) => /^FROM\s+\S+\s+AS\s+runner$/i.test(l)); + expect(start, 'apps/web/Dockerfile has no `AS runner` stage').toBeGreaterThanOrEqual(0); + const rest = lines.slice(start + 1); + const next = rest.findIndex((l) => /^FROM\s/i.test(l)); + return next === -1 ? rest : rest.slice(0, next); +} + +describe('production web image: email', () => { + it('copies templates/ into /app/templates in the runner stage', () => { + const stage = runnerStage( + readFileSync(path.join(repoRoot, 'apps/web/Dockerfile'), 'utf-8'), + ); + + const workdir = stage.find((l) => /^WORKDIR\s/i.test(l))?.split(/\s+/)[1]; + expect(workdir).toBe('/app'); + + const copy = stage.find( + (l) => /^COPY\s/i.test(l) && /\s\/app\/templates\/?\s/.test(`${l} `), + ); + expect(copy, 'runner stage never copies /app/templates').toBeDefined(); + const dest = copy!.split(/\s+/).at(-1)!; + expect(path.posix.resolve(workdir!, dest)).toBe('/app/templates'); + }); + + it('has templates in the repo-root templates/ directory that the image copies', () => { + const slugs = readdirSync(path.join(repoRoot, 'templates')).filter((f) => + f.endsWith('.md'), + ); + expect(slugs).toContain('invite.md'); + }); + + it('does not replace postmark with an empty module in the server bundle', () => { + const config = { resolve: { alias: {} as Record } }; + const webpack = nextConfig.webpack as ( + c: typeof config, + o: { isServer: boolean }, + ) => typeof config; + const out = webpack(config, { isServer: true }); + expect(out.resolve.alias.postmark).toBeUndefined(); + }); +});