From fa7a42a56ea7b2f94f9142b9496ae625d45effbf Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 30 Aug 2026 20:57:59 +0200 Subject: [PATCH 01/14] fix(android): enforce disabled entitlement and download flows --- .env.example | 1 + src/server/api/routers/entitlements.test.ts | 52 +++++++++++++++++ src/server/api/routers/entitlements.ts | 4 ++ src/server/api/routers/releases.test.ts | 64 +++++++++++++++++++++ src/server/api/routers/releases.ts | 7 +++ 5 files changed, 128 insertions(+) create mode 100644 src/server/api/routers/entitlements.test.ts create mode 100644 src/server/api/routers/releases.test.ts diff --git a/.env.example b/.env.example index 5f565192d..410f54eab 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,7 @@ NEXT_IMAGE_UNOPTIMIZED=false # Android Downloads (feature flag + public endpoints) NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS=true +ENABLE_ANDROID_ENTITLEMENT_VERIFICATION=false NEXT_PUBLIC_ANDROID_LATEST_JSON_URL="https://cdn.emuready.com/xxx/xxx/xxx.json" NEXT_PUBLIC_ANDROID_LATEST_APK_URL="https://cdn.emuready.com/xxx/xxx/xxx-latest.apk" diff --git a/src/server/api/routers/entitlements.test.ts b/src/server/api/routers/entitlements.test.ts new file mode 100644 index 000000000..b41e74737 --- /dev/null +++ b/src/server/api/routers/entitlements.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Role } from '@orm' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') +vi.unmock('@orm') +vi.unmock('@orm/client') + +const mockFetchPlayOrder = vi.fn() + +vi.mock('@/server/services/googlePlayOrders.service', () => ({ + fetchPlayOrder: (...args: unknown[]) => mockFetchPlayOrder(...args), + isPaidAppOrder: vi.fn(), +})) + +const { entitlementsRouter } = await import('./entitlements') + +function createCaller() { + return entitlementsRouter.createCaller({ + session: { + user: { + id: '00000000-0000-4000-a000-000000000001', + email: 'test@test.com', + name: 'Test User', + role: Role.USER, + permissions: [], + showNsfw: false, + }, + }, + prisma: {} as never, + headers: new Headers(), + }) +} + +describe('entitlements router', () => { + afterEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + }) + + it('rejects Google Play claims when Android entitlement verification is disabled', async () => { + vi.stubEnv('ENABLE_ANDROID_ENTITLEMENT_VERIFICATION', 'false') + + await expect( + createCaller().claimPlayOrder({ orderId: 'GPA.1234-5678-9012-34567' }), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Operation not allowed: Android entitlement verification is disabled', + }) + expect(mockFetchPlayOrder).not.toHaveBeenCalled() + }) +}) diff --git a/src/server/api/routers/entitlements.ts b/src/server/api/routers/entitlements.ts index bc0584473..1f124d44a 100644 --- a/src/server/api/routers/entitlements.ts +++ b/src/server/api/routers/entitlements.ts @@ -32,6 +32,10 @@ export const entitlementsRouter = createTRPCRouter({ claimPlayOrder: protectedProcedure .input(ClaimPlayOrderSchema) .mutation(async ({ ctx, input }) => { + if (process.env.ENABLE_ANDROID_ENTITLEMENT_VERIFICATION !== 'true') { + return AppError.operationNotAllowed('Android entitlement verification is disabled') + } + const packageName = process.env.ANDROID_PACKAGE_NAME if (!packageName) return AppError.internalError('ANDROID_PACKAGE_NAME missing') const order = await fetchPlayOrder(packageName, input.orderId) diff --git a/src/server/api/routers/releases.test.ts b/src/server/api/routers/releases.test.ts new file mode 100644 index 000000000..cd78a5aa7 --- /dev/null +++ b/src/server/api/routers/releases.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Role } from '@orm' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') +vi.unmock('@orm') +vi.unmock('@orm/client') + +const prismaMocks = { + releaseFindFirst: vi.fn(), + entitlementCount: vi.fn(), +} + +const { releasesRouter } = await import('./releases') + +function createCaller() { + return releasesRouter.createCaller({ + session: { + user: { + id: '00000000-0000-4000-a000-000000000001', + email: 'test@test.com', + name: 'Test User', + role: Role.USER, + permissions: [], + showNsfw: false, + }, + }, + prisma: { + release: { + findFirst: prismaMocks.releaseFindFirst, + }, + entitlement: { + count: prismaMocks.entitlementCount, + }, + } as never, + headers: new Headers(), + }) +} + +describe('releases router', () => { + afterEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + }) + + it('does not expose release metadata when Android downloads are disabled', async () => { + vi.stubEnv('NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS', 'false') + + await expect(createCaller().latest({})).resolves.toBeUndefined() + expect(prismaMocks.releaseFindFirst).not.toHaveBeenCalled() + }) + + it('does not sign downloads when Android downloads are disabled', async () => { + vi.stubEnv('NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS', 'false') + + await expect( + createCaller().signDownload({ releaseId: '00000000-0000-4000-a000-000000000002' }), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Operation not allowed: Android downloads are disabled', + }) + expect(prismaMocks.entitlementCount).not.toHaveBeenCalled() + }) +}) diff --git a/src/server/api/routers/releases.ts b/src/server/api/routers/releases.ts index 5d83442ca..6c6ac0afb 100644 --- a/src/server/api/routers/releases.ts +++ b/src/server/api/routers/releases.ts @@ -1,4 +1,5 @@ import { env } from '@/lib/env' +import { AppError } from '@/lib/errors' import { GetLatestReleaseSchema, SignDownloadSchema } from '@/schemas/releases' import { createTRPCRouter, publicProcedure, protectedProcedure } from '@/server/api/trpc' import { EntitlementsRepository } from '@/server/repositories/entitlements.repository' @@ -7,6 +8,8 @@ import { presignGetObject } from '@/server/services/r2.service' export const releasesRouter = createTRPCRouter({ latest: publicProcedure.input(GetLatestReleaseSchema).query(async ({ ctx, input }) => { + if (!env.ENABLE_ANDROID_DOWNLOADS) return undefined + const repo = new ReleasesRepository(ctx.prisma) // Try selected channel if provided; otherwise prefer stable, then beta const tryChannels = input?.channel ? [input.channel] : (['stable', 'beta'] as const) @@ -39,6 +42,10 @@ export const releasesRouter = createTRPCRouter({ // Records a download (and could issue a signed URL in the future) signDownload: protectedProcedure.input(SignDownloadSchema).mutation(async ({ ctx, input }) => { + if (!env.ENABLE_ANDROID_DOWNLOADS) { + return AppError.operationNotAllowed('Android downloads are disabled') + } + const entRepo = new EntitlementsRepository(ctx.prisma) const eligible = await entRepo.eligible(ctx.session.user.id) if (!eligible) return { url: env.ANDROID_LATEST_APK_URL } From 04f05b776cc3a3ad29124c387492fc5a0a12a63d Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 30 Aug 2026 21:51:04 +0200 Subject: [PATCH 02/14] feat(uploads): store user uploads in R2 --- config/image-hosts.ts | 14 +++ docker-compose.yml | 4 - docs/DOCKER.md | 1 - src/app/api/upload/route.ts | 55 +++--------- src/lib/upload.ts | 33 ++----- src/server/services/uploads.service.test.ts | 98 +++++++++++++++++++++ src/server/services/uploads.service.ts | 73 +++++++++++++++ src/utils/imageUrls.test.ts | 16 +++- 8 files changed, 219 insertions(+), 75 deletions(-) create mode 100644 src/server/services/uploads.service.test.ts create mode 100644 src/server/services/uploads.service.ts diff --git a/config/image-hosts.ts b/config/image-hosts.ts index eb8746aee..2c493e6b7 100644 --- a/config/image-hosts.ts +++ b/config/image-hosts.ts @@ -9,6 +9,19 @@ export const GAME_IMAGE_PROVIDER_HOST_PATTERNS = [ 'images.gog-statics.com', ] as const +function r2UploadsHost(): string | null { + const base = + process.env.NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL || process.env.NEXT_PUBLIC_R2_PUBLIC_BASE_URL + if (!base) return null + try { + return new URL(base).hostname + } catch { + return null + } +} + +const R2_UPLOADS_HOST = r2UploadsHost() + export const NEXT_IMAGE_REMOTE_HOST_PATTERNS = [ 'placehold.co', '*.clerk.com', @@ -16,6 +29,7 @@ export const NEXT_IMAGE_REMOTE_HOST_PATTERNS = [ 'storage.ko-fi.com', 'ko-fi.com', ...GAME_IMAGE_PROVIDER_HOST_PATTERNS, + ...(R2_UPLOADS_HOST ? [R2_UPLOADS_HOST] : []), ] as const export const NEXT_IMAGE_REMOTE_PATTERNS = NEXT_IMAGE_REMOTE_HOST_PATTERNS.map((hostname) => ({ diff --git a/docker-compose.yml b/docker-compose.yml index a3e897c98..273b29ac6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,8 +50,6 @@ services: # Source code for hot reload - .:/app - /app/node_modules - # Persistent uploads - - uploads_data:/app/public/uploads # Flag file to track if initial setup has been done - setup_data:/app/.setup depends_on: @@ -145,8 +143,6 @@ services: volumes: postgres_data: driver: local - uploads_data: - driver: local pgadmin_data: driver: local setup_data: diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 9d6afed6f..9ad7ae8b6 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -340,7 +340,6 @@ Docker creates the following persistent volumes: ``` emuready/ -├── public/uploads/ # File uploads (persistent) ├── .env.docker # Your environment config ├── docker-compose.yml # Service configuration ├── Dockerfile # App container definition diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 0e583de88..ff4c4ea91 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,63 +1,32 @@ -import { writeFile, mkdir } from 'fs/promises' -import { join } from 'path' import { auth } from '@clerk/nextjs/server' -import { NextResponse, type NextRequest } from 'next/server' -import { prisma } from '@/server/db' +import { NextResponse } from 'next/server' +import { handleFileUpload } from '@/lib/upload' import getErrorMessage from '@/utils/getErrorMessage' -import { hasRolePermission } from '@/utils/permissions' -import { Role } from '@orm' - -function isImage(file: File) { - return file.type.startsWith('image/') -} +import type { NextRequest } from 'next/server' export async function POST(request: NextRequest) { try { const { userId } = await auth() - if (!userId) { return NextResponse.json({ error: 'Unauthorized access' }, { status: 401 }) } - // Check user role for upload permissions - const user = await prisma.user.findUnique({ - where: { clerkId: userId }, - select: { role: true }, - }) - - if (!user || !hasRolePermission(user.role, Role.USER)) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - const formData = await request.formData() - const file = formData.get('file') as File | null - if (!file) { - return NextResponse.json({ error: 'No file uploaded' }, { status: 400 }) - } + const result = await handleFileUpload(formData, userId, 'games') - if (!isImage(file)) { - return NextResponse.json({ error: 'Uploaded file is not an image' }, { status: 400 }) + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: result.status }) } - // Create unique filename - const fileExtension = file.name.split('.').pop() - const timestamp = Date.now() - const fileName = `game-${timestamp}.${fileExtension}` - - // Create directory if it doesn't exist - const publicDir = join(process.cwd(), 'public') - const uploadDir = join(publicDir, 'uploads', 'games') - await mkdir(uploadDir, { recursive: true }) - - // Write file to disk - const filePath = join(uploadDir, fileName) - const buffer = Buffer.from(await file.arrayBuffer()) - await writeFile(filePath, buffer) + const response = NextResponse.json({ + success: true, + imageUrl: result.imageUrl, + }) - const imageUrl = `/uploads/games/${fileName}` + response.headers.set('Cache-Control', 'no-store, max-age=0') - return NextResponse.json({ success: true, imageUrl }) + return response } catch (error) { console.error('Error uploading file:', error) const errorMessage = getErrorMessage(error, 'An error occurred during upload') diff --git a/src/lib/upload.ts b/src/lib/upload.ts index 73f6cc10e..26d0e50b0 100644 --- a/src/lib/upload.ts +++ b/src/lib/upload.ts @@ -1,6 +1,5 @@ -import { writeFile, mkdir } from 'fs/promises' -import { join } from 'path' import { prisma } from '@/server/db' +import { putUpload } from '@/server/services/uploads.service' import { IMAGE_EXTENSIONS, type ImageExtension } from '@/utils/imageValidation' import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' @@ -16,7 +15,6 @@ export const ALLOWED_EXTENSIONS = IMAGE_EXTENSIONS // Upload configuration types export interface UploadConfig { directory: string - filenamePrefix: string requiredRole?: Role updateUserProfile?: boolean } @@ -25,23 +23,16 @@ export interface UploadConfig { export const UPLOAD_CONFIGS: Record = { games: { directory: 'games', - filenamePrefix: 'game', requiredRole: Role.USER, }, profiles: { directory: 'profiles', - filenamePrefix: 'profile', updateUserProfile: true, }, } as const export type UploadType = keyof typeof UPLOAD_CONFIGS -// Sanitize userId to prevent path traversal and filesystem issues -function sanitizeUserId(userId: string): string { - return userId.replace(/[^a-zA-Z0-9_-]/g, '_') -} - // Validation functions export function isValidImage(file: File): boolean { if (!file) return false @@ -101,25 +92,15 @@ export async function uploadFile( config: UploadConfig, ): Promise { try { - // Generate unique filename const fileExtension = getFileExtension(file.name) - const timestamp = Date.now() - const randomString = Math.random().toString(36).substring(2, 10) - const sanitizedUserId = sanitizeUserId(userId) - const fileName = `${config.filenamePrefix}-${sanitizedUserId}-${timestamp}-${randomString}.${fileExtension}` - - // Create directory if it doesn't exist - const publicDir = join(process.cwd(), 'public') - const uploadDir = join(publicDir, 'uploads', config.directory) - await mkdir(uploadDir, { recursive: true }) - - // Write file to disk - const filePath = join(uploadDir, fileName) const buffer = Buffer.from(await file.arrayBuffer()) - await writeFile(filePath, buffer) - // Generate public URL - const imageUrl = `/uploads/${config.directory}/${fileName}` + const { url: imageUrl } = await putUpload({ + directory: config.directory, + body: buffer, + contentType: file.type, + ext: fileExtension, + }) // Update user profile if configured if (config.updateUserProfile) { diff --git a/src/server/services/uploads.service.test.ts b/src/server/services/uploads.service.test.ts new file mode 100644 index 000000000..eb8fbb41c --- /dev/null +++ b/src/server/services/uploads.service.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { putUpload } from './uploads.service' +const r2Mocks = vi.hoisted(() => ({ + send: vi.fn(), +})) + +vi.mock('@/server/services/r2.service', () => ({ + r2Client: () => ({ send: r2Mocks.send }), +})) + +describe('putUpload', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + vi.stubEnv('R2_BUCKET', 'uploads-test') + vi.stubEnv('R2_PUBLIC_BASE_URL', 'https://media.example.com/') + }) + + it('validates the public base URL before writing an object', async () => { + vi.stubEnv('R2_PUBLIC_BASE_URL', '') + + await expect( + putUpload({ + directory: 'games', + body: Buffer.from('image'), + contentType: 'image/png', + ext: 'png', + }), + ).rejects.toThrow('R2_UPLOADS_PUBLIC_BASE_URL (or R2_PUBLIC_BASE_URL) is required') + expect(r2Mocks.send).not.toHaveBeenCalled() + }) + + it.each(['not-a-url', 'http://media.example.com', 'https://user:pass@media.example.com'])( + 'rejects an unsafe public base URL: %s', + async (publicBaseUrl) => { + vi.stubEnv('R2_PUBLIC_BASE_URL', publicBaseUrl) + + await expect( + putUpload({ + directory: 'games', + body: Buffer.from('image'), + contentType: 'image/png', + ext: 'png', + }), + ).rejects.toThrow('R2 uploads public base URL must be a valid HTTPS URL') + expect(r2Mocks.send).not.toHaveBeenCalled() + }, + ) + + it.each([ + ['R2_UPLOADS_BUCKET', 'dedicated-uploads'], + ['R2_UPLOADS_PUBLIC_BASE_URL', 'https://uploads.example.com'], + ])('rejects a partial uploads override when only %s is set', async (name, value) => { + vi.stubEnv(name, value) + + await expect( + putUpload({ + directory: 'games', + body: Buffer.from('image'), + contentType: 'image/png', + ext: 'png', + }), + ).rejects.toThrow('R2_UPLOADS_BUCKET and R2_UPLOADS_PUBLIC_BASE_URL must be set together') + expect(r2Mocks.send).not.toHaveBeenCalled() + }) + + it('uses the dedicated uploads bucket and public base when both are set', async () => { + vi.stubEnv('R2_UPLOADS_BUCKET', 'dedicated-uploads') + vi.stubEnv('R2_UPLOADS_PUBLIC_BASE_URL', 'https://uploads.example.com') + r2Mocks.send.mockResolvedValueOnce({}) + + const result = await putUpload({ + directory: 'profiles', + body: Buffer.from('image'), + contentType: 'image/webp', + ext: 'webp', + }) + + expect(result.bucket).toBe('dedicated-uploads') + expect(result.url).toBe(`https://uploads.example.com/${result.key}`) + }) + + it('writes the object and returns its public URL', async () => { + r2Mocks.send.mockResolvedValueOnce({}) + + const result = await putUpload({ + directory: 'games', + body: Buffer.from('image'), + contentType: 'image/png', + ext: 'png', + }) + + expect(r2Mocks.send).toHaveBeenCalledOnce() + expect(result.bucket).toBe('uploads-test') + expect(result.key).toMatch(/^uploads\/games\/[0-9a-f-]+\.png$/) + expect(result.url).toBe(`https://media.example.com/${result.key}`) + }) +}) diff --git a/src/server/services/uploads.service.ts b/src/server/services/uploads.service.ts new file mode 100644 index 000000000..c8bd7e7ac --- /dev/null +++ b/src/server/services/uploads.service.ts @@ -0,0 +1,73 @@ +import { randomUUID } from 'node:crypto' +import { PutObjectCommand } from '@aws-sdk/client-s3' +import { r2Client } from '@/server/services/r2.service' +import type { ImageExtension } from '@/utils/imageValidation' + +const UPLOAD_PREFIX = 'uploads' + +export interface StoredUpload { + url: string + key: string + bucket: string +} + +interface UploadsConfig { + bucket: string + publicBase: string +} + +function getUploadsConfig(): UploadsConfig { + const uploadsBucket = process.env.R2_UPLOADS_BUCKET + const uploadsPublicBase = process.env.R2_UPLOADS_PUBLIC_BASE_URL + + if (Boolean(uploadsBucket) !== Boolean(uploadsPublicBase)) { + throw new Error('R2_UPLOADS_BUCKET and R2_UPLOADS_PUBLIC_BASE_URL must be set together') + } + + const bucket = uploadsBucket || process.env.R2_BUCKET + const publicBase = uploadsPublicBase || process.env.R2_PUBLIC_BASE_URL + + if (!bucket) throw new Error('R2_UPLOADS_BUCKET (or R2_BUCKET) is required for uploads') + if (!publicBase) { + throw new Error('R2_UPLOADS_PUBLIC_BASE_URL (or R2_PUBLIC_BASE_URL) is required for uploads') + } + + return { bucket, publicBase: validatePublicBase(publicBase) } +} + +function validatePublicBase(value: string): string { + let base: URL + try { + base = new URL(value) + } catch { + throw new Error('R2 uploads public base URL must be a valid HTTPS URL') + } + + if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) { + throw new Error('R2 uploads public base URL must be a valid HTTPS URL') + } + + return base.toString().replace(/\/$/, '') +} + +export async function putUpload(params: { + directory: string + body: Buffer + contentType: string + ext: ImageExtension +}): Promise { + const config = getUploadsConfig() + + const key = `${UPLOAD_PREFIX}/${params.directory}/${randomUUID()}.${params.ext}` + await r2Client().send( + new PutObjectCommand({ + Bucket: config.bucket, + Key: key, + Body: params.body, + ContentType: params.contentType, + CacheControl: 'public, max-age=31536000, immutable', + }), + ) + + return { url: `${config.publicBase}/${key}`, key, bucket: config.bucket } +} diff --git a/src/utils/imageUrls.test.ts b/src/utils/imageUrls.test.ts index b6417fe59..4e5cbb5bd 100644 --- a/src/utils/imageUrls.test.ts +++ b/src/utils/imageUrls.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getGameImageUrlValidationError, getImageRenderMode, @@ -11,6 +11,20 @@ describe('imageUrls', () => { expect(getImageRenderMode('https://media.rawg.io/media/games/example.jpg')).toBe('next-image') }) + it('treats the configured R2 uploads host as a next-image host', async () => { + vi.stubEnv('NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL', 'https://media.test.emuready.com') + vi.resetModules() + try { + const { getImageRenderMode } = await import('./imageUrls') + expect(getImageRenderMode('https://media.test.emuready.com/uploads/games/abc.jpg')).toBe( + 'next-image', + ) + } finally { + vi.unstubAllEnvs() + vi.resetModules() + } + }) + it('uses native browser image rendering for arbitrary HTTPS hosts', () => { expect(getImageRenderMode('https://example.com/image.jpg')).toBe('external-img') }) From 72fca3472267fffbbcc893ab4ac403fba36a3a19 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 30 Aug 2026 21:51:40 +0200 Subject: [PATCH 03/14] feat(deployment): prepare app for Coolify self-hosting --- .dockerignore | 3 +- .env.example | 25 ++- Dockerfile | 146 ++++++++++------ README.md | 2 +- docs/SELF_HOSTING.md | 43 +++++ next.config.ts | 9 +- playwright.config.ts | 2 +- src/app/api/health/live/route.test.ts | 36 ++++ src/app/api/health/live/route.ts | 48 +++++ src/app/api/health/ready/route.test.ts | 82 +++++++++ src/app/api/health/ready/route.ts | 231 +++++++++++++++++++++++++ src/app/api/health/route.ts | 183 +------------------- src/proxy.test.ts | 43 +++++ src/proxy.ts | 20 ++- src/server/prisma-client.test.ts | 50 ++++++ src/server/prisma-client.ts | 12 +- 16 files changed, 684 insertions(+), 251 deletions(-) create mode 100644 docs/SELF_HOSTING.md create mode 100644 src/app/api/health/live/route.test.ts create mode 100644 src/app/api/health/live/route.ts create mode 100644 src/app/api/health/ready/route.test.ts create mode 100644 src/app/api/health/ready/route.ts create mode 100644 src/server/prisma-client.test.ts diff --git a/.dockerignore b/.dockerignore index 2f923b7e0..813779bb6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,7 +15,6 @@ out/ # Environment files .env* -!.env.docker # Development files .git @@ -38,6 +37,8 @@ coverage/ # Documentation docs/ *.md +# Read at build/runtime by the API reference page (src/app/docs/api/reference). +!docs/MOBILE_API.md # Logs logs diff --git a/.env.example b/.env.example index 410f54eab..63dd9e1fd 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ #DATABASE_URL="file:./dev.db" # SQLite database file -DATABASE_URL="postgres://postgres.url:pooler.supabase.com:6543/postgres?pgbouncer=true" -DATABASE_DIRECT_URL="postgres://postgres:pooler.supabase.com:5432/postgres" +# Persistent servers should use the Supabase session pooler on port 5432. +DATABASE_URL="postgresql://postgres.PROJECT_REF:PASSWORD@aws-0-us-east-1.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true&connection_limit=5" +DATABASE_DIRECT_URL="postgresql://postgres.PROJECT_REF:PASSWORD@aws-0-us-east-1.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true" # Clerk NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" @@ -12,7 +13,6 @@ CLERK_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" STEAM_API_KEY="your-steam-web-api-key-here" RAWG_API_KEY="RAWG-API-KEY" THE_GAMES_DB_API_KEY="The-Games-DB-API-KEY" -NEXT_PUBLIC_THE_GAMES_DB_API_KEY="The-Games-DB-Public-API-KEY" NEXT_PUBLIC_IGDB_CLIENT_ID="IGDB-Client-ID" IGDB_CLIENT_KEY="IGDB-Client-Secret" @@ -47,12 +47,16 @@ NEXT_PUBLIC_PATREON_LINK="https://www.patreon.com/Producdevity" NEXT_PUBLIC_KOFI_LINK="https://ko-fi.com/producdevity" NEXT_PUBLIC_EMUREADY_EMAIL="info@emuready.com" NEXT_PUBLIC_GITHUB_URL="https://github.com/Producdevity/EmuReady" +NEXT_PUBLIC_TWITTER_URL="" NEXT_PUBLIC_EMUREADY_LITE_GITHUB_URL="https://github.com/Producdevity/EmuReadyLite/releases" NEXT_PUBLIC_APP_URL="http://localhost:3000" # Make sure to change this if you are using a tunnel NEXT_PUBLIC_ENABLE_SW=false NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION=true +NEXT_PUBLIC_DISABLE_COOKIE_BANNER=false NEXT_TELEMETRY_DISABLED=1 NEXT_IMAGE_UNOPTIMIZED=false +# Set this to the source commit SHA for production images. +NEXT_BUILD_ID="" # Android Downloads (feature flag + public endpoints) NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS=true @@ -76,6 +80,11 @@ NEXT_PUBLIC_ANDROID_LATEST_APK_URL="https://cdn.emuready.com/xxx/xxx/xxx-latest. # Internal API + App URL (used by release script and admin upload) APP_URL="http://localhost:3000" INTERNAL_API_KEY="dev-internal-api-key" +# Set to "true" ONLY when the origin is reachable exclusively through Cloudflare +# (origin firewall restricted to Cloudflare IPs). Makes the rate limiter prefer +# the trustworthy cf-connecting-ip header. Leave unset on Vercel or any origin +# reachable without Cloudflare, where that header is client-forgeable. +# TRUST_CF_CONNECTING_IP="" # Cloudflare R2 (Android releases) R2_ACCOUNT_ID="" @@ -83,6 +92,16 @@ R2_ACCESS_KEY_ID="" R2_SECRET_ACCESS_KEY="" R2_BUCKET="emuready-app-downloads" R2_PUBLIC_BASE_URL="https://cdn.emuready.com" +# Same public URL exposed to browser code at build time. +NEXT_PUBLIC_R2_PUBLIC_BASE_URL="https://cdn.emuready.com" + +# Cloudflare R2 (user uploads). Optional: when unset, uploads fall back to +# R2_BUCKET + R2_PUBLIC_BASE_URL (shared with Android releases). Set both to +# isolate uploads in their own bucket with a dedicated public hostname. The +# current implementation uses the shared R2 credentials above. +# R2_UPLOADS_BUCKET="" +# R2_UPLOADS_PUBLIC_BASE_URL="https://media.emuready.com" +# NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL="https://media.emuready.com" # Google Play Orders (purchase claim) ANDROID_PACKAGE_NAME="com.producdevity.emureadyapp" diff --git a/Dockerfile b/Dockerfile index 81daeefe2..e4035048f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,71 +1,103 @@ -FROM node:22.17-alpine AS base +# syntax=docker/dockerfile:1 -RUN apk add --no-cache libc6-compat -WORKDIR /app +# glibc base — the repo pins linux-x64-gnu native binaries (sharp, rollup, +# tailwindcss/oxide, lightningcss) that do not resolve on Alpine/musl. +ARG NODE_IMAGE=node:22-bookworm-slim +FROM ${NODE_IMAGE} AS base +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && corepack enable +WORKDIR /app ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +# Pin pnpm to match package.json#packageManager (pnpm@11.5.2). +RUN corepack prepare pnpm@11.5.2 --activate -RUN corepack enable pnpm && corepack prepare pnpm@11.1.0 --activate - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY prisma/ ./prisma/ - -# Install production dependencies for runtime layers. -RUN pnpm install --prod --frozen-lockfile --prefer-offline --ignore-scripts - -# Development stage +# Development: retained for docker-compose.yml and scripts/docker-dev.sh. FROM base AS dev - -# Install all dependencies including devDependencies for development -RUN pnpm install --frozen-lockfile --prefer-offline --ignore-scripts - +ARG DATABASE_URL=postgresql://docker-build.invalid/emuready +ARG DATABASE_DIRECT_URL +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts COPY . . - -# Ensure Prisma client is generated for the current environment -RUN pnpm exec prisma generate - -# Expose port +RUN DATABASE_URL="${DATABASE_URL}" DATABASE_DIRECT_URL="${DATABASE_DIRECT_URL:-${DATABASE_URL}}" \ + pnpm exec prisma generate EXPOSE 3000 - -# Start development server CMD ["pnpm", "dev"] -# Build stage for production +# The build database must be migrated and disposable. Prisma TypedSQL inspects it. FROM base AS builder - -# Install all dependencies for building -RUN pnpm install --frozen-lockfile --prefer-offline --ignore-scripts - -# Copy source code +ARG DATABASE_URL +ARG DATABASE_DIRECT_URL +ARG NEXT_IMAGE_UNOPTIMIZED +ARG NEXT_PUBLIC_ALLOWED_ORIGINS +ARG NEXT_PUBLIC_ANDROID_LATEST_APK_URL +ARG NEXT_PUBLIC_ANDROID_LATEST_JSON_URL +ARG NEXT_PUBLIC_APP_ENV +ARG NEXT_PUBLIC_APP_URL +ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY +ARG NEXT_PUBLIC_DISABLE_COOKIE_BANNER +ARG NEXT_PUBLIC_DISCORD_LINK +ARG NEXT_PUBLIC_EMUREADY_BETA_URL +ARG NEXT_PUBLIC_EMUREADY_EMAIL +ARG NEXT_PUBLIC_EMUREADY_LITE_GITHUB_URL +ARG NEXT_PUBLIC_ENABLE_ANALYTICS +ARG NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS +ARG NEXT_PUBLIC_ENABLE_KOFI_WIDGET +ARG NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION +ARG NEXT_PUBLIC_ENABLE_SENTRY +ARG NEXT_PUBLIC_ENABLE_SW +ARG NEXT_PUBLIC_GA_ID +ARG NEXT_PUBLIC_GITHUB_URL +ARG NEXT_PUBLIC_IGDB_CLIENT_ID +ARG NEXT_PUBLIC_KOFI_LINK +ARG NEXT_PUBLIC_LOCAL_STORAGE_PREFIX +ARG NEXT_PUBLIC_PATREON_LINK +ARG NEXT_PUBLIC_R2_PUBLIC_BASE_URL +ARG NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL +ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY +ARG NEXT_PUBLIC_TWITTER_URL +ARG NEXT_BUILD_ID +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts COPY . . - -# Generate Prisma client for building -RUN pnpm exec prisma generate - -# Build the application -RUN pnpm build - -# Production stage -FROM base AS production - -# Copy built application -COPY --from=builder /app/.next ./.next -COPY --from=builder /app/public ./public -COPY --from=builder /app/next.config.ts ./ -COPY --from=builder /app/prisma/generated ./prisma/generated - -# Create non-root user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs - -# Change ownership of the app directory -RUN chown -R nextjs:nodejs /app +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm version:sync +RUN DATABASE_URL="${DATABASE_URL}" DATABASE_DIRECT_URL="${DATABASE_DIRECT_URL:-${DATABASE_URL}}" \ + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}" \ + NEXT_PUBLIC_R2_PUBLIC_BASE_URL="${NEXT_PUBLIC_R2_PUBLIC_BASE_URL}" \ + NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL="${NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL}" \ + NEXT_IMAGE_UNOPTIMIZED="${NEXT_IMAGE_UNOPTIMIZED}" \ + NEXT_BUILD_ID="${NEXT_BUILD_ID}" \ + pnpm build + +# One-shot migration image. DATABASE_DIRECT_URL is supplied at runtime. +FROM base AS migrator +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts +COPY prisma.config.ts ./ +COPY prisma ./prisma +CMD ["pnpm", "exec", "prisma", "migrate", "deploy"] + +# Standalone Next.js runtime. +FROM ${NODE_IMAGE} AS app +ARG NEXT_BUILD_ID +WORKDIR /app +ENV NODE_ENV=production \ + HOSTNAME=0.0.0.0 \ + PORT=3000 \ + APP_VERSION=${NEXT_BUILD_ID} \ + NEXT_TELEMETRY_DISABLED=1 +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid 1001 --create-home nextjs +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/docs/MOBILE_API.md ./docs/MOBILE_API.md USER nextjs - -# Expose port EXPOSE 3000 - -# Start production server -CMD ["pnpm", "start"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 27978f107..afef37465 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ pnpm dev Then open [http://localhost:3000](http://localhost:3000). Environment setup is documented in [docs/DEVELOPMENT_SETUP.md](docs/DEVELOPMENT_SETUP.md). Docker-specific setup is documented in -[docs/DOCKER.md](docs/DOCKER.md). +[docs/DOCKER.md](docs/DOCKER.md), and the production container contract is in [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md). ## Common Commands diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md new file mode 100644 index 000000000..dca77d634 --- /dev/null +++ b/docs/SELF_HOSTING.md @@ -0,0 +1,43 @@ +# Self-hosting + +EmuReady runs as a standalone Next.js container behind Coolify and Cloudflare. Supabase, Clerk, R2, Sentry, and email remain managed services. + +## Build and release contract + +- Build the `app` target from `Dockerfile`; run the resulting immutable image in Coolify. +- Supply all `NEXT_PUBLIC_*` values while building. Runtime values cannot change the browser bundle. +- Use a migrated, disposable Postgres database while building because Prisma TypedSQL generation introspects the schema. Never use production for this. +- In Coolify, mark the build database URLs as build variables and enable **Use Docker Build Secrets**. Ordinary Docker build arguments expose their values in image metadata. +- Keep runtime-only secrets, such as `CLERK_SECRET_KEY`, out of the build phase. +- For a release containing migrations, build the `migrator` target from the same commit and run it with `DATABASE_DIRECT_URL` before deploying the `app` image. + +The first VPS deployment is manual. The repository does not yet publish images or trigger Coolify automatically; add that workflow after the staging path has been verified. + +## Coolify application + +- Use the Dockerfile build pack, target `app`, and exposed port `3000`. +- Set `NEXT_BUILD_ID=$SOURCE_COMMIT` and enable **Include Source Commit in Build**. +- Use `/api/health/ready` for deployment health checks and `/api/health/live` for process liveness. + +## Production configuration + +- Use the Supabase session pooler on port 5432. The app defaults to five database connections; override with `connection_limit` in `DATABASE_URL`. +- Store production user uploads in R2. Set `R2_UPLOADS_BUCKET`, `R2_UPLOADS_PUBLIC_BASE_URL`, and the matching `NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL` together. Keep R2 credentials unset in staging for now so it cannot access production assets. +- Set `TRUST_CF_CONNECTING_IP=true` only after the origin accepts web traffic exclusively through Cloudflare. + +## Deferred follow-ups + +- Provision an isolated staging upload bucket, scoped token, and hostname before enabling upload testing in staging. +- Make APK objects private so entitlement checks cannot be bypassed with a known public R2 URL. +- Publish immutable images and trigger verified Coolify deployments from CI. +- Consolidate the duplicate mobile tRPC paths and remove the unused transport. +- Audit the stale TransIP, FTP, and mail DNS records, then add DMARC after confirming the mail policy. + +## Verification and cutover + +1. Deploy with staging Clerk and Supabase credentials under a temporary hostname. +2. Verify `/api/health/live`, `/api/health/ready`, public pages, authentication, API routes, uploads, and image optimization. +3. Deploy the production configuration while the production domain still points to Vercel. +4. Point Cloudflare at the VPS and keep the previous Vercel deployment available for rollback. + +Do not run an upload backfill unless a read-only production database inventory confirms that `/uploads/...` references still exist and the matching source files have been recovered. diff --git a/next.config.ts b/next.config.ts index 2d2dbc6d3..9bee1bcb4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,7 @@ type Header = Awaited>>[number] const isVercelBuild = process.env.VERCEL === '1' const isSentryEnabled = process.env.NEXT_PUBLIC_ENABLE_SENTRY === 'true' +const nextBuildId = process.env.NEXT_BUILD_ID const contentSecurityPolicyDirectives = [ { @@ -147,6 +148,12 @@ function createContentSecurityPolicy(): string { } const nextConfig: NextConfig = { + output: 'standalone', + + // Keep build identity stable and protect clients from version skew while + // Coolify briefly overlaps the old and new containers during deployment. + ...(nextBuildId ? { deploymentId: nextBuildId, generateBuildId: () => nextBuildId } : {}), + images: { unoptimized: process.env.NEXT_IMAGE_UNOPTIMIZED === 'true', qualities: [50, 75, 85, 100], @@ -225,7 +232,7 @@ const nextConfig: NextConfig = { serverExternalPackages: ['@prisma/client', 'jsdom', 'markdown-it', 'dompurify'], outputFileTracingIncludes: { - '/*': ['docs/**/*.md'], + '/*': ['docs/**/*.md', 'prisma/generated/client/**'], }, outputFileTracingExcludes: { diff --git a/playwright.config.ts b/playwright.config.ts index ac11fb179..b09627df8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -49,7 +49,7 @@ export default defineConfig({ globalSetup: path.resolve(currentDir, './tests/global.setup.ts'), use: { - baseURL: 'http://localhost:3000', + baseURL: process.env.PW_BASE_URL ?? 'http://localhost:3000', actionTimeout: 10 * 1000, navigationTimeout: 30 * 1000, trace: 'on-first-retry', diff --git a/src/app/api/health/live/route.test.ts b/src/app/api/health/live/route.test.ts new file mode 100644 index 000000000..bef71e733 --- /dev/null +++ b/src/app/api/health/live/route.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { GET } from './route' + +const healthMocks = vi.hoisted(() => ({ + connection: vi.fn(), +})) + +vi.mock('next/server', async () => { + const actual = await vi.importActual>('next/server') + return { ...actual, connection: healthMocks.connection } +}) + +describe('GET /api/health/live', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + }) + + it('reports process liveness without checking dependencies', async () => { + vi.stubEnv('APP_VERSION', 'test-deployment') + + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.status).toBe('alive') + expect(body.version).toBe('test-deployment') + }) + + it('uses an explicit fallback when no build version is available', async () => { + const response = await GET() + const body = await response.json() + + expect(body.version).toBe('unknown') + }) +}) diff --git a/src/app/api/health/live/route.ts b/src/app/api/health/live/route.ts new file mode 100644 index 000000000..8db4dc430 --- /dev/null +++ b/src/app/api/health/live/route.ts @@ -0,0 +1,48 @@ +import { connection, NextResponse } from 'next/server' + +/** + * Liveness probe — confirms the process is up and serving HTTP. Performs no + * dependency I/O (no database query) so it stays fast and independent of + * downstream health. Use this for container/orchestrator liveness gates; use + * /api/health/ready for a dependency-aware readiness check. + * @openapi + * /api/health/live: + * get: + * tags: + * - Health + * summary: Liveness probe + * description: Lightweight process liveness check with no dependency I/O + * responses: + * 200: + * description: Process is alive + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [alive] + * uptime: + * type: number + * version: + * type: string + * environment: + * type: string + */ +export async function GET() { + await connection() + + return NextResponse.json( + { + status: 'alive', + uptime: Math.floor(process.uptime()), + version: process.env.APP_VERSION || 'unknown', + environment: process.env.NODE_ENV || 'unknown', + }, + { + status: 200, + headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }, + }, + ) +} diff --git a/src/app/api/health/ready/route.test.ts b/src/app/api/health/ready/route.test.ts new file mode 100644 index 000000000..633eb1a19 --- /dev/null +++ b/src/app/api/health/ready/route.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { calculateMemoryStats, GET } from './route' +const healthMocks = vi.hoisted(() => ({ + connection: vi.fn(), + query: vi.fn(), +})) + +vi.mock('next/server', async () => { + const actual = await vi.importActual>('next/server') + return { ...actual, connection: healthMocks.connection } +}) + +vi.mock('@/server/db', () => ({ + prisma: { $queryRaw: healthMocks.query }, +})) + +describe('GET /api/health/ready', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + healthMocks.query.mockResolvedValue([{ '?column?': 1 }]) + vi.stubEnv('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', 'pk_test') + vi.stubEnv('CLERK_SECRET_KEY', 'sk_test') + vi.stubEnv('APP_VERSION', 'test-deployment') + }) + + it('reports ready when the database and auth configuration are available', async () => { + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.status).toBe('healthy') + expect(body.version).toBe('test-deployment') + expect(body.services.database.status).toBe('connected') + expect(body.services.auth.status).toBe('available') + }) + + it('calculates container memory against the cgroup limit', () => { + expect( + calculateMemoryStats({ + cgroupUsed: 512 * 1024 * 1024, + cgroupLimit: 4 * 1024 * 1024 * 1024, + hostTotal: 24 * 1024 * 1024 * 1024, + hostFree: 12 * 1024 * 1024 * 1024, + }), + ).toEqual({ used: 512, total: 4096, percentage: 13 }) + }) + + it('falls back to host memory when there is no cgroup limit', () => { + expect( + calculateMemoryStats({ + cgroupUsed: null, + cgroupLimit: null, + hostTotal: 4 * 1024 * 1024 * 1024, + hostFree: 3 * 1024 * 1024 * 1024, + }), + ).toEqual({ used: 1024, total: 4096, percentage: 25 }) + }) + + it('reports not ready when auth configuration is missing', async () => { + vi.stubEnv('CLERK_SECRET_KEY', '') + + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(503) + expect(body.status).toBe('unhealthy') + expect(body.services.database.status).toBe('connected') + expect(body.services.auth.status).toBe('unavailable') + }) + + it('reports not ready when the database cannot be reached', async () => { + healthMocks.query.mockRejectedValueOnce(new Error('database unavailable')) + + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(503) + expect(body.status).toBe('unhealthy') + expect(body.error).toBe('Health check failed') + }) +}) diff --git a/src/app/api/health/ready/route.ts b/src/app/api/health/ready/route.ts new file mode 100644 index 000000000..bd4bd30e7 --- /dev/null +++ b/src/app/api/health/ready/route.ts @@ -0,0 +1,231 @@ +import { readFile } from 'node:fs/promises' +import { freemem, totalmem } from 'node:os' +import { connection, NextResponse } from 'next/server' +import { prisma } from '@/server/db' + +interface HealthResponse { + status: 'healthy' | 'unhealthy' + timestamp: string + uptime: number + version: string + environment: string + services: { + database: { + status: 'connected' | 'disconnected' + latency?: number + } + auth: { + status: 'available' | 'unavailable' + } + } + system: { + memory: { + used: number + total: number + percentage: number + } + nodeVersion: string + } +} + +interface MemoryStats { + used: number + total: number + percentage: number +} + +interface MemoryValues { + cgroupUsed: number | null + cgroupLimit: number | null + hostTotal: number + hostFree: number +} + +async function readMemoryValue(path: string): Promise { + try { + const value = Number.parseInt((await readFile(path, 'utf8')).trim(), 10) + return Number.isFinite(value) ? value : null + } catch { + return null + } +} + +export function calculateMemoryStats(values: MemoryValues): MemoryStats { + const hasCgroupLimit = + values.cgroupUsed !== null && + values.cgroupLimit !== null && + values.cgroupLimit > 0 && + values.cgroupLimit <= values.hostTotal + let usedBytes = values.hostTotal - values.hostFree + let totalBytes = values.hostTotal + + if (hasCgroupLimit && values.cgroupUsed !== null && values.cgroupLimit !== null) { + usedBytes = values.cgroupUsed + totalBytes = values.cgroupLimit + } + + return { + used: Math.round(usedBytes / 1024 / 1024), + total: Math.round(totalBytes / 1024 / 1024), + percentage: totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0, + } +} + +async function getMemoryStats(): Promise { + const [cgroupUsed, cgroupLimit] = await Promise.all([ + readMemoryValue('/sys/fs/cgroup/memory.current'), + readMemoryValue('/sys/fs/cgroup/memory.max'), + ]) + return calculateMemoryStats({ + cgroupUsed, + cgroupLimit, + hostTotal: totalmem(), + hostFree: freemem(), + }) +} + +/** + * Readiness probe — confirms the process is up AND its dependencies (database, + * auth configuration) are reachable. Use for load-balancer/Coolify readiness + * gates, not for a fast liveness check (use /api/health/live for that). + * @openapi + * /api/health/ready: + * get: + * tags: + * - Health + * summary: Server readiness check + * description: Returns the current health status of the server and its dependencies + * responses: + * 200: + * description: Server is healthy and dependencies are reachable + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [healthy, unhealthy] + * timestamp: + * type: string + * format: date-time + * uptime: + * type: number + * description: Server uptime in seconds + * version: + * type: string + * description: Application version + * environment: + * type: string + * description: Current environment + * services: + * type: object + * properties: + * database: + * type: object + * properties: + * status: + * type: string + * enum: [connected, disconnected] + * latency: + * type: number + * description: Database response time in ms + * auth: + * type: object + * properties: + * status: + * type: string + * enum: [available, unavailable] + * system: + * type: object + * properties: + * memory: + * type: object + * properties: + * used: + * type: number + * total: + * type: number + * percentage: + * type: number + * nodeVersion: + * type: string + * 503: + * description: Server is unhealthy + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [unhealthy] + * timestamp: + * type: string + * format: date-time + * error: + * type: string + * description: Error message + */ +export async function GET() { + await connection() + + try { + const dbStart = Date.now() + await prisma.$queryRaw`SELECT 1` + const dbLatency = Date.now() - dbStart + + const memory = await getMemoryStats() + + const authAvailable = !!( + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY && process.env.CLERK_SECRET_KEY + ) + + const healthData: HealthResponse = { + status: authAvailable ? 'healthy' : 'unhealthy', + timestamp: new Date().toISOString(), + uptime: Math.floor(process.uptime()), + version: process.env.APP_VERSION || 'unknown', + environment: process.env.NODE_ENV || 'unknown', + services: { + database: { + status: 'connected', + latency: dbLatency, + }, + auth: { + status: authAvailable ? 'available' : 'unavailable', + }, + }, + system: { + memory, + nodeVersion: process.version, + }, + } + + return NextResponse.json(healthData, { + status: authAvailable ? 200 : 503, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }, + }) + } catch (error) { + console.error('Health check failed:', error) + + const unhealthyResponse = { + status: 'unhealthy' as const, + timestamp: new Date().toISOString(), + error: 'Health check failed', + } + + return NextResponse.json(unhealthyResponse, { + status: 503, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }, + }) + } +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 32b647bdb..01e8e1974 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,179 +1,4 @@ -import { connection, NextResponse } from 'next/server' -import { prisma } from '@/server/db' -import type { NextRequest } from 'next/server' - -interface HealthResponse { - status: 'healthy' | 'unhealthy' - timestamp: string - uptime: number - version: string - environment: string - services: { - database: { - status: 'connected' | 'disconnected' - latency?: number - } - auth: { - status: 'available' | 'unavailable' - } - } - system: { - memory: { - used: number - total: number - percentage: number - } - nodeVersion: string - } -} - -/** - * Health check endpoint for monitoring and load balancers - * @openapi - * /api/health: - * get: - * tags: - * - Health - * summary: Server health check - * description: Returns the current health status of the server and its dependencies - * responses: - * 200: - * description: Server is healthy - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [healthy, unhealthy] - * timestamp: - * type: string - * format: date-time - * uptime: - * type: number - * description: Server uptime in seconds - * version: - * type: string - * description: Application version - * environment: - * type: string - * description: Current environment - * services: - * type: object - * properties: - * database: - * type: object - * properties: - * status: - * type: string - * enum: [connected, disconnected] - * latency: - * type: number - * description: Database response time in ms - * auth: - * type: object - * properties: - * status: - * type: string - * enum: [available, unavailable] - * system: - * type: object - * properties: - * memory: - * type: object - * properties: - * used: - * type: number - * total: - * type: number - * percentage: - * type: number - * nodeVersion: - * type: string - * 503: - * description: Server is unhealthy - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [unhealthy] - * timestamp: - * type: string - * format: date-time - * error: - * type: string - * description: Error message - */ -export async function GET(_request: NextRequest) { - await connection() - - try { - const dbStart = Date.now() - await prisma.$queryRaw`SELECT 1` - const dbLatency = Date.now() - dbStart - - const memUsage = process.memoryUsage() - const memoryUsed = memUsage.rss - const memoryTotal = memUsage.rss + memUsage.external - const memoryPercentage = Math.round((memoryUsed / memoryTotal) * 100) - - const authAvailable = !!( - process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY && process.env.CLERK_SECRET_KEY - ) - - const healthData: HealthResponse = { - status: 'healthy', - timestamp: new Date().toISOString(), - uptime: Math.floor(process.uptime()), - version: process.env.npm_package_version || '0.0.0', - environment: process.env.NODE_ENV || 'unknown', - services: { - database: { - status: 'connected', - latency: dbLatency, - }, - auth: { - status: authAvailable ? 'available' : 'unavailable', - }, - }, - system: { - memory: { - used: Math.round(memoryUsed / 1024 / 1024), - total: Math.round(memoryTotal / 1024 / 1024), - percentage: memoryPercentage, - }, - nodeVersion: process.version, - }, - } - - return NextResponse.json(healthData, { - status: 200, - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache', - Expires: '0', - }, - }) - } catch (error) { - console.error('Health check failed:', error) - - const unhealthyResponse = { - status: 'unhealthy' as const, - timestamp: new Date().toISOString(), - error: 'Health check failed', - } - - return NextResponse.json(unhealthyResponse, { - status: 503, - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache', - Expires: '0', - }, - }) - } -} +// Backward-compatible alias: /api/health behaves as the readiness check. +// Prefer /api/health/live (liveness) and /api/health/ready (readiness) for new +// consumers. +export { GET } from './ready/route' diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 7783cb718..08dc8edd5 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -71,3 +71,46 @@ describe('proxy mobile tRPC origin handling', () => { expect(response.status).toBe(200) }) }) + +describe('getClientIdentifier', () => { + it('prefers cf-connecting-ip when TRUST_CF_CONNECTING_IP is true', async () => { + vi.stubEnv('TRUST_CF_CONNECTING_IP', 'true') + const { getClientIdentifier } = await loadProxy() + + const req = new NextRequest('https://emuready.com/x', { + headers: { + 'cf-connecting-ip': '203.0.113.10', + 'x-forwarded-for': '198.51.100.20', + }, + }) + + expect(getClientIdentifier(req)).toBe('203.0.113.10') + }) + + it('ignores forgeable cf-connecting-ip by default and uses x-forwarded-for (Vercel-safe)', async () => { + vi.stubEnv('TRUST_CF_CONNECTING_IP', '') + const { getClientIdentifier } = await loadProxy() + + const req = new NextRequest('https://emuready.com/x', { + headers: { + 'cf-connecting-ip': '203.0.113.10', + 'x-forwarded-for': '198.51.100.20, 10.0.0.1', + }, + }) + + expect(getClientIdentifier(req)).toBe('198.51.100.20') + }) + + it('falls back to x-real-ip then unknown when no trusted header is present', async () => { + vi.stubEnv('TRUST_CF_CONNECTING_IP', '') + const { getClientIdentifier } = await loadProxy() + + const withRealIp = new NextRequest('https://emuready.com/x', { + headers: { 'x-real-ip': '198.51.100.99' }, + }) + expect(getClientIdentifier(withRealIp)).toBe('198.51.100.99') + + const empty = new NextRequest('https://emuready.com/x') + expect(getClientIdentifier(empty)).toBe('unknown') + }) +}) diff --git a/src/proxy.ts b/src/proxy.ts index 2618f564a..51e6b3d01 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -33,14 +33,24 @@ function applyDevNoStoreHeader( return response } -function getClientIdentifier(req: NextRequest): string { - const forwarded = req.headers.get('x-forwarded-for') - const realIp = req.headers.get('x-real-ip') - const cfConnectingIp = req.headers.get('cf-connecting-ip') +export function getClientIdentifier(req: NextRequest): string { + // Prefer cf-connecting-ip only when the origin is reachable exclusively + // through Cloudflare, signaled by TRUST_CF_CONNECTING_IP=true. On deployments + // without that restriction (e.g. Vercel) the header is client-settable and + // forgeable, so the default order is x-forwarded-for (populated by the + // platform) first. + if (process.env.TRUST_CF_CONNECTING_IP === 'true') { + const cfConnectingIp = req.headers.get('cf-connecting-ip') + if (cfConnectingIp) return cfConnectingIp.trim() + } + const forwarded = req.headers.get('x-forwarded-for') if (forwarded) return forwarded.split(',')[0].trim() - return realIp || cfConnectingIp || 'unknown' + const realIp = req.headers.get('x-real-ip') + if (realIp) return realIp.trim() + + return 'unknown' } function shouldBypassRateLimit(identifier: string): boolean { diff --git a/src/server/prisma-client.test.ts b/src/server/prisma-client.test.ts new file mode 100644 index 000000000..84e81397a --- /dev/null +++ b/src/server/prisma-client.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Keep the test free of the generated Prisma client and driver adapter. +vi.mock('@orm/client', () => ({ PrismaClient: class {} })) +vi.mock('@prisma/adapter-pg', () => ({ PrismaPg: class {} })) + +const { getPoolMax } = await import('./prisma-client') + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('getPoolMax', () => { + it('returns undefined for local hosts (Prisma default pool)', () => { + expect(getPoolMax('postgres://u:p@localhost:5432/db')).toBeUndefined() + expect(getPoolMax('postgres://u:p@127.0.0.1:5432/db')).toBeUndefined() + }) + + it('returns 1 on Vercel (one connection per ephemeral instance)', () => { + vi.stubEnv('VERCEL', '1') + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres')).toBe(1) + }) + + it('returns 5 for a remote persistent server when not on Vercel', () => { + vi.stubEnv('VERCEL', '') + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres')).toBe(5) + }) + + it('honors an explicit connection_limit over the default', () => { + vi.stubEnv('VERCEL', '') + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres?connection_limit=10')).toBe(10) + // still honored on Vercel + vi.stubEnv('VERCEL', '1') + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres?connection_limit=3')).toBe(3) + }) + + it('ignores invalid connection_limit values and falls back to the default', () => { + vi.stubEnv('VERCEL', '') + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres?connection_limit=abc')).toBe(5) + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres?connection_limit=0')).toBe(5) + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres?connection_limit=-2')).toBe(5) + }) + + it('falls back when the connection string is not parseable', () => { + vi.stubEnv('VERCEL', '') + expect(getPoolMax('not-a-url')).toBe(5) + vi.stubEnv('VERCEL', '1') + expect(getPoolMax('not-a-url')).toBe(1) + }) +}) diff --git a/src/server/prisma-client.ts b/src/server/prisma-client.ts index 90eeae8ee..db537b449 100644 --- a/src/server/prisma-client.ts +++ b/src/server/prisma-client.ts @@ -15,7 +15,7 @@ function getDatabaseUrl() { return connectionString } -function getPoolMax(connectionString: string): number | undefined { +export function getPoolMax(connectionString: string): number | undefined { try { const url = new URL(connectionString) const raw = url.searchParams.get('connection_limit') @@ -24,9 +24,15 @@ function getPoolMax(connectionString: string): number | undefined { if (Number.isInteger(parsed) && parsed > 0) return parsed } - return LOCAL_DATABASE_HOSTS.has(url.hostname.toLowerCase()) ? undefined : 1 + if (LOCAL_DATABASE_HOSTS.has(url.hostname.toLowerCase())) return undefined + + // Vercel serverless instances are short-lived and each constructs its own + // pool, so one connection per instance avoids exhausting the database budget. + // A persistent self-hosted server holds a small pool instead. Override either + // via the `connection_limit` query param on DATABASE_URL. + return process.env.VERCEL === '1' ? 1 : 5 } catch { - return 1 + return process.env.VERCEL === '1' ? 1 : 5 } } From 4a121770f6451be36c606576b1df7676385f0314 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 30 Aug 2026 22:07:54 +0200 Subject: [PATCH 04/14] docs(deployment): record release flow follow-up --- docs/SELF_HOSTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index dca77d634..1e478b259 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -30,6 +30,7 @@ The first VPS deployment is manual. The repository does not yet publish images o - Provision an isolated staging upload bucket, scoped token, and hostname before enabling upload testing in staging. - Make APK objects private so entitlement checks cannot be bypassed with a known public R2 URL. - Publish immutable images and trigger verified Coolify deployments from CI. +- Replace the current `staging` default branch and `master` production convention with a documented release and promotion flow. - Consolidate the duplicate mobile tRPC paths and remove the unused transport. - Audit the stale TransIP, FTP, and mail DNS records, then add DMARC after confirming the mail policy. From b0000adab7569ab395e340339fe65e7df9ca63fb Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 31 Aug 2026 00:04:49 +0200 Subject: [PATCH 05/14] fix(health): report configured app environment --- src/app/api/health/live/route.test.ts | 1 + src/app/api/health/live/route.ts | 3 ++- src/app/api/health/ready/route.test.ts | 1 + src/app/api/health/ready/route.ts | 3 ++- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/api/health/live/route.test.ts b/src/app/api/health/live/route.test.ts index bef71e733..bce4c61d4 100644 --- a/src/app/api/health/live/route.test.ts +++ b/src/app/api/health/live/route.test.ts @@ -25,6 +25,7 @@ describe('GET /api/health/live', () => { expect(response.status).toBe(200) expect(body.status).toBe('alive') expect(body.version).toBe('test-deployment') + expect(body.environment).toBe('test') }) it('uses an explicit fallback when no build version is available', async () => { diff --git a/src/app/api/health/live/route.ts b/src/app/api/health/live/route.ts index 8db4dc430..c1ac14e15 100644 --- a/src/app/api/health/live/route.ts +++ b/src/app/api/health/live/route.ts @@ -1,4 +1,5 @@ import { connection, NextResponse } from 'next/server' +import { env } from '@/lib/env' /** * Liveness probe — confirms the process is up and serving HTTP. Performs no @@ -38,7 +39,7 @@ export async function GET() { status: 'alive', uptime: Math.floor(process.uptime()), version: process.env.APP_VERSION || 'unknown', - environment: process.env.NODE_ENV || 'unknown', + environment: env.APP_ENV, }, { status: 200, diff --git a/src/app/api/health/ready/route.test.ts b/src/app/api/health/ready/route.test.ts index 633eb1a19..b16be780a 100644 --- a/src/app/api/health/ready/route.test.ts +++ b/src/app/api/health/ready/route.test.ts @@ -31,6 +31,7 @@ describe('GET /api/health/ready', () => { expect(response.status).toBe(200) expect(body.status).toBe('healthy') expect(body.version).toBe('test-deployment') + expect(body.environment).toBe('test') expect(body.services.database.status).toBe('connected') expect(body.services.auth.status).toBe('available') }) diff --git a/src/app/api/health/ready/route.ts b/src/app/api/health/ready/route.ts index bd4bd30e7..f76f47396 100644 --- a/src/app/api/health/ready/route.ts +++ b/src/app/api/health/ready/route.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' import { freemem, totalmem } from 'node:os' import { connection, NextResponse } from 'next/server' +import { env } from '@/lib/env' import { prisma } from '@/server/db' interface HealthResponse { @@ -186,7 +187,7 @@ export async function GET() { timestamp: new Date().toISOString(), uptime: Math.floor(process.uptime()), version: process.env.APP_VERSION || 'unknown', - environment: process.env.NODE_ENV || 'unknown', + environment: env.APP_ENV, services: { database: { status: 'connected', From cc0472f2fd39a7499575c5e3a8fed9035e535ff6 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 31 Aug 2026 10:35:13 +0200 Subject: [PATCH 06/14] fix(test): isolate app environment --- src/lib/env.test.ts | 11 +++++++++++ src/lib/env.ts | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts index e4064e4ff..be54cd674 100644 --- a/src/lib/env.test.ts +++ b/src/lib/env.test.ts @@ -79,6 +79,17 @@ describe('env', () => { expect(env.IS_TEST_BUILD).toBe(true) }) + it('uses the test app env when a test run inherits a deployment app env', async () => { + const { env } = await loadEnv({ + [ENV_KEYS.nodeEnv]: ENV_VALUES.test, + [ENV_KEYS.appEnv]: ENV_VALUES.production, + }) + + expect(env.APP_ENV).toBe(ENV_VALUES.test) + expect(env.IS_PUBLIC_PRODUCTION).toBe(false) + expect(env.IS_TEST_BUILD).toBe(true) + }) + it('only enables optional browser services when their public flags are true', async () => { const { env } = await loadEnv({ [ENV_KEYS.nodeEnv]: ENV_VALUES.production, diff --git a/src/lib/env.ts b/src/lib/env.ts index 8b7d4ef90..6bd90a702 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -35,9 +35,10 @@ interface Env { } function resolveAppEnv(): AppEnv { + if (process.env.NODE_ENV === 'test') return 'test' + const appEnv = process.env.NEXT_PUBLIC_APP_ENV if (APP_ENV_VALUES.includes(appEnv as AppEnv)) return appEnv as AppEnv - if (process.env.NODE_ENV === 'test') return 'test' if (process.env.NODE_ENV === 'development') return 'local' return 'local' } From 3c88e816c275d827696c2d273fb4b2c01de71da9 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 31 Aug 2026 11:47:32 +0200 Subject: [PATCH 07/14] fix(database): keep a warm VPS connection --- src/server/prisma-client.test.ts | 14 +++++++++++++- src/server/prisma-client.ts | 5 +++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/server/prisma-client.test.ts b/src/server/prisma-client.test.ts index 84e81397a..7eb545374 100644 --- a/src/server/prisma-client.test.ts +++ b/src/server/prisma-client.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@orm/client', () => ({ PrismaClient: class {} })) vi.mock('@prisma/adapter-pg', () => ({ PrismaPg: class {} })) -const { getPoolMax } = await import('./prisma-client') +const { getPoolMax, getPoolMin } = await import('./prisma-client') afterEach(() => { vi.unstubAllEnvs() @@ -48,3 +48,15 @@ describe('getPoolMax', () => { expect(getPoolMax('not-a-url')).toBe(1) }) }) + +describe('getPoolMin', () => { + it('keeps one connection warm on a persistent server', () => { + vi.stubEnv('VERCEL', '') + expect(getPoolMin()).toBe(1) + }) + + it('does not retain a connection in a Vercel instance', () => { + vi.stubEnv('VERCEL', '1') + expect(getPoolMin()).toBe(0) + }) +}) diff --git a/src/server/prisma-client.ts b/src/server/prisma-client.ts index db537b449..23b26c03c 100644 --- a/src/server/prisma-client.ts +++ b/src/server/prisma-client.ts @@ -36,6 +36,10 @@ export function getPoolMax(connectionString: string): number | undefined { } } +export function getPoolMin(): number { + return process.env.VERCEL === '1' ? 0 : 1 +} + export function createPrismaClient(options?: PrismaClientConfig) { const connectionString = getDatabaseUrl() const poolMax = getPoolMax(connectionString) @@ -43,6 +47,7 @@ export function createPrismaClient(options?: PrismaClientConfig) { const adapter = new PrismaPg({ connectionString, ...(poolMax ? { max: poolMax } : {}), + min: getPoolMin(), connectionTimeoutMillis: 5_000, idleTimeoutMillis: 10_000, }) From adce729db1406abd09668832ea15e46bff7df1ef Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 31 Aug 2026 11:47:59 +0200 Subject: [PATCH 08/14] docs(deployment): record staging verification --- docs/SELF_HOSTING.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 1e478b259..159c47295 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -11,7 +11,7 @@ EmuReady runs as a standalone Next.js container behind Coolify and Cloudflare. S - Keep runtime-only secrets, such as `CLERK_SECRET_KEY`, out of the build phase. - For a release containing migrations, build the `migrator` target from the same commit and run it with `DATABASE_DIRECT_URL` before deploying the `app` image. -The first VPS deployment is manual. The repository does not yet publish images or trigger Coolify automatically; add that workflow after the staging path has been verified. +The VPS currently builds from source in Coolify. A verified GitHub App webhook automatically deploys pushes to the configured branch. Publishing prebuilt immutable images remains deferred. ## Coolify application @@ -21,7 +21,7 @@ The first VPS deployment is manual. The repository does not yet publish images o ## Production configuration -- Use the Supabase session pooler on port 5432. The app defaults to five database connections; override with `connection_limit` in `DATABASE_URL`. +- Use the Supabase session pooler on port 5432. Outside Vercel, the app retains one warm connection and allows at most five by default; override the maximum with `connection_limit` in `DATABASE_URL`. - Store production user uploads in R2. Set `R2_UPLOADS_BUCKET`, `R2_UPLOADS_PUBLIC_BASE_URL`, and the matching `NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL` together. Keep R2 credentials unset in staging for now so it cannot access production assets. - Set `TRUST_CF_CONNECTING_IP=true` only after the origin accepts web traffic exclusively through Cloudflare. @@ -29,7 +29,7 @@ The first VPS deployment is manual. The repository does not yet publish images o - Provision an isolated staging upload bucket, scoped token, and hostname before enabling upload testing in staging. - Make APK objects private so entitlement checks cannot be bypassed with a known public R2 URL. -- Publish immutable images and trigger verified Coolify deployments from CI. +- Publish immutable images from CI and deploy them by digest instead of rebuilding source in Coolify. - Replace the current `staging` default branch and `master` production convention with a documented release and promotion flow. - Consolidate the duplicate mobile tRPC paths and remove the unused transport. - Audit the stale TransIP, FTP, and mail DNS records, then add DMARC after confirming the mail policy. @@ -37,8 +37,9 @@ The first VPS deployment is manual. The repository does not yet publish images o ## Verification and cutover 1. Deploy with staging Clerk and Supabase credentials under a temporary hostname. -2. Verify `/api/health/live`, `/api/health/ready`, public pages, authentication, API routes, uploads, and image optimization. -3. Deploy the production configuration while the production domain still points to Vercel. -4. Point Cloudflare at the VPS and keep the previous Vercel deployment available for rollback. +2. Verify `/api/health/live`, `/api/health/ready`, public pages, authentication, API routes, and image optimization. +3. Measure baseline and burst performance against staging, including p95 latency, errors, CPU, memory, image processing, disk use, and Supabase pool usage. +4. Deploy the production configuration while the production domain still points to Vercel. +5. Point Cloudflare at the VPS and keep the previous Vercel deployment available for rollback. Do not run an upload backfill unless a read-only production database inventory confirms that `/uploads/...` references still exist and the matching source files have been recovered. From 1e8c45628d1ef5cb0f93986768bed9175045b540 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 31 Aug 2026 16:19:36 +0200 Subject: [PATCH 09/14] docs: update self-hosting build and deployment strategy --- docs/SELF_HOSTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 159c47295..84ae9a291 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -29,7 +29,7 @@ The VPS currently builds from source in Coolify. A verified GitHub App webhook a - Provision an isolated staging upload bucket, scoped token, and hostname before enabling upload testing in staging. - Make APK objects private so entitlement checks cannot be bypassed with a known public R2 URL. -- Publish immutable images from CI and deploy them by digest instead of rebuilding source in Coolify. +- Move builds to GitHub-hosted Actions, publish immutable images to GHCR, and have Coolify deploy them by digest. Do not run the build runner on the application VPS. - Replace the current `staging` default branch and `master` production convention with a documented release and promotion flow. - Consolidate the duplicate mobile tRPC paths and remove the unused transport. - Audit the stale TransIP, FTP, and mail DNS records, then add DMARC after confirming the mail policy. From 62b707e96ad284ec57ac5b5bb1b0e71d8b1187c0 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 31 Aug 2026 17:02:26 +0200 Subject: [PATCH 10/14] fix(health): harden container health checks --- Dockerfile | 2 +- src/app/api/health/live/route.test.ts | 15 +- src/app/api/health/live/route.ts | 10 - src/app/api/health/ready/route.test.ts | 55 ++--- src/app/api/health/ready/route.ts | 228 ++---------------- src/app/api/health/route.ts | 2 +- .../health/server/health.repository.ts | 14 ++ src/features/health/server/health.service.ts | 14 ++ 8 files changed, 72 insertions(+), 268 deletions(-) create mode 100644 src/features/health/server/health.repository.ts create mode 100644 src/features/health/server/health.service.ts diff --git a/Dockerfile b/Dockerfile index e4035048f..4303bdb89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,5 +99,5 @@ COPY --from=builder --chown=nextjs:nodejs /app/docs/MOBILE_API.md ./docs/MOBILE_ USER nextjs EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:3000/api/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + CMD node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" CMD ["node", "server.js"] diff --git a/src/app/api/health/live/route.test.ts b/src/app/api/health/live/route.test.ts index bce4c61d4..788bba759 100644 --- a/src/app/api/health/live/route.test.ts +++ b/src/app/api/health/live/route.test.ts @@ -16,22 +16,11 @@ describe('GET /api/health/live', () => { vi.unstubAllEnvs() }) - it('reports process liveness without checking dependencies', async () => { - vi.stubEnv('APP_VERSION', 'test-deployment') - + it('reports process liveness without exposing diagnostics', async () => { const response = await GET() const body = await response.json() expect(response.status).toBe(200) - expect(body.status).toBe('alive') - expect(body.version).toBe('test-deployment') - expect(body.environment).toBe('test') - }) - - it('uses an explicit fallback when no build version is available', async () => { - const response = await GET() - const body = await response.json() - - expect(body.version).toBe('unknown') + expect(body).toEqual({ status: 'alive' }) }) }) diff --git a/src/app/api/health/live/route.ts b/src/app/api/health/live/route.ts index c1ac14e15..fb1895e63 100644 --- a/src/app/api/health/live/route.ts +++ b/src/app/api/health/live/route.ts @@ -1,5 +1,4 @@ import { connection, NextResponse } from 'next/server' -import { env } from '@/lib/env' /** * Liveness probe — confirms the process is up and serving HTTP. Performs no @@ -24,12 +23,6 @@ import { env } from '@/lib/env' * status: * type: string * enum: [alive] - * uptime: - * type: number - * version: - * type: string - * environment: - * type: string */ export async function GET() { await connection() @@ -37,9 +30,6 @@ export async function GET() { return NextResponse.json( { status: 'alive', - uptime: Math.floor(process.uptime()), - version: process.env.APP_VERSION || 'unknown', - environment: env.APP_ENV, }, { status: 200, diff --git a/src/app/api/health/ready/route.test.ts b/src/app/api/health/ready/route.test.ts index b16be780a..7faa111df 100644 --- a/src/app/api/health/ready/route.test.ts +++ b/src/app/api/health/ready/route.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { calculateMemoryStats, GET } from './route' + const healthMocks = vi.hoisted(() => ({ connection: vi.fn(), - query: vi.fn(), + checkDatabase: vi.fn(), })) vi.mock('next/server', async () => { @@ -10,52 +10,30 @@ vi.mock('next/server', async () => { return { ...actual, connection: healthMocks.connection } }) -vi.mock('@/server/db', () => ({ - prisma: { $queryRaw: healthMocks.query }, +vi.mock('@/features/health/server/health.service', () => ({ + createHealthService: () => ({ checkDatabase: healthMocks.checkDatabase }), })) +vi.mock('@/server/db', () => ({ prisma: {} })) + +const { GET } = await import('./route') + describe('GET /api/health/ready', () => { beforeEach(() => { vi.clearAllMocks() vi.unstubAllEnvs() - healthMocks.query.mockResolvedValue([{ '?column?': 1 }]) + healthMocks.checkDatabase.mockResolvedValue(undefined) vi.stubEnv('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', 'pk_test') vi.stubEnv('CLERK_SECRET_KEY', 'sk_test') - vi.stubEnv('APP_VERSION', 'test-deployment') }) - it('reports ready when the database and auth configuration are available', async () => { + it('reports ready without exposing dependency diagnostics', async () => { const response = await GET() const body = await response.json() expect(response.status).toBe(200) - expect(body.status).toBe('healthy') - expect(body.version).toBe('test-deployment') - expect(body.environment).toBe('test') - expect(body.services.database.status).toBe('connected') - expect(body.services.auth.status).toBe('available') - }) - - it('calculates container memory against the cgroup limit', () => { - expect( - calculateMemoryStats({ - cgroupUsed: 512 * 1024 * 1024, - cgroupLimit: 4 * 1024 * 1024 * 1024, - hostTotal: 24 * 1024 * 1024 * 1024, - hostFree: 12 * 1024 * 1024 * 1024, - }), - ).toEqual({ used: 512, total: 4096, percentage: 13 }) - }) - - it('falls back to host memory when there is no cgroup limit', () => { - expect( - calculateMemoryStats({ - cgroupUsed: null, - cgroupLimit: null, - hostTotal: 4 * 1024 * 1024 * 1024, - hostFree: 3 * 1024 * 1024 * 1024, - }), - ).toEqual({ used: 1024, total: 4096, percentage: 25 }) + expect(body).toEqual({ status: 'healthy' }) + expect(healthMocks.checkDatabase).toHaveBeenCalledOnce() }) it('reports not ready when auth configuration is missing', async () => { @@ -65,19 +43,16 @@ describe('GET /api/health/ready', () => { const body = await response.json() expect(response.status).toBe(503) - expect(body.status).toBe('unhealthy') - expect(body.services.database.status).toBe('connected') - expect(body.services.auth.status).toBe('unavailable') + expect(body).toEqual({ status: 'unhealthy' }) }) it('reports not ready when the database cannot be reached', async () => { - healthMocks.query.mockRejectedValueOnce(new Error('database unavailable')) + healthMocks.checkDatabase.mockRejectedValueOnce(new Error('database unavailable')) const response = await GET() const body = await response.json() expect(response.status).toBe(503) - expect(body.status).toBe('unhealthy') - expect(body.error).toBe('Health check failed') + expect(body).toEqual({ status: 'unhealthy' }) }) }) diff --git a/src/app/api/health/ready/route.ts b/src/app/api/health/ready/route.ts index f76f47396..36babfe68 100644 --- a/src/app/api/health/ready/route.ts +++ b/src/app/api/health/ready/route.ts @@ -1,232 +1,54 @@ -import { readFile } from 'node:fs/promises' -import { freemem, totalmem } from 'node:os' import { connection, NextResponse } from 'next/server' -import { env } from '@/lib/env' +import { createHealthService } from '@/features/health/server/health.service' import { prisma } from '@/server/db' -interface HealthResponse { - status: 'healthy' | 'unhealthy' - timestamp: string - uptime: number - version: string - environment: string - services: { - database: { - status: 'connected' | 'disconnected' - latency?: number - } - auth: { - status: 'available' | 'unavailable' - } - } - system: { - memory: { - used: number - total: number - percentage: number - } - nodeVersion: string - } -} - -interface MemoryStats { - used: number - total: number - percentage: number -} - -interface MemoryValues { - cgroupUsed: number | null - cgroupLimit: number | null - hostTotal: number - hostFree: number -} - -async function readMemoryValue(path: string): Promise { - try { - const value = Number.parseInt((await readFile(path, 'utf8')).trim(), 10) - return Number.isFinite(value) ? value : null - } catch { - return null - } -} - -export function calculateMemoryStats(values: MemoryValues): MemoryStats { - const hasCgroupLimit = - values.cgroupUsed !== null && - values.cgroupLimit !== null && - values.cgroupLimit > 0 && - values.cgroupLimit <= values.hostTotal - let usedBytes = values.hostTotal - values.hostFree - let totalBytes = values.hostTotal - - if (hasCgroupLimit && values.cgroupUsed !== null && values.cgroupLimit !== null) { - usedBytes = values.cgroupUsed - totalBytes = values.cgroupLimit - } - - return { - used: Math.round(usedBytes / 1024 / 1024), - total: Math.round(totalBytes / 1024 / 1024), - percentage: totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0, - } -} - -async function getMemoryStats(): Promise { - const [cgroupUsed, cgroupLimit] = await Promise.all([ - readMemoryValue('/sys/fs/cgroup/memory.current'), - readMemoryValue('/sys/fs/cgroup/memory.max'), - ]) - return calculateMemoryStats({ - cgroupUsed, - cgroupLimit, - hostTotal: totalmem(), - hostFree: freemem(), - }) -} +const NO_CACHE_HEADERS = { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', +} as const /** - * Readiness probe — confirms the process is up AND its dependencies (database, - * auth configuration) are reachable. Use for load-balancer/Coolify readiness - * gates, not for a fast liveness check (use /api/health/live for that). + * Dependency-aware readiness probe for container and load-balancer health + * checks. Public responses intentionally expose only the aggregate status. * @openapi * /api/health/ready: * get: * tags: * - Health * summary: Server readiness check - * description: Returns the current health status of the server and its dependencies * responses: * 200: - * description: Server is healthy and dependencies are reachable - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [healthy, unhealthy] - * timestamp: - * type: string - * format: date-time - * uptime: - * type: number - * description: Server uptime in seconds - * version: - * type: string - * description: Application version - * environment: - * type: string - * description: Current environment - * services: - * type: object - * properties: - * database: - * type: object - * properties: - * status: - * type: string - * enum: [connected, disconnected] - * latency: - * type: number - * description: Database response time in ms - * auth: - * type: object - * properties: - * status: - * type: string - * enum: [available, unavailable] - * system: - * type: object - * properties: - * memory: - * type: object - * properties: - * used: - * type: number - * total: - * type: number - * percentage: - * type: number - * nodeVersion: - * type: string + * description: Server is ready * 503: - * description: Server is unhealthy - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [unhealthy] - * timestamp: - * type: string - * format: date-time - * error: - * type: string - * description: Error message + * description: Server is not ready */ export async function GET() { await connection() try { - const dbStart = Date.now() - await prisma.$queryRaw`SELECT 1` - const dbLatency = Date.now() - dbStart - - const memory = await getMemoryStats() + await createHealthService(prisma).checkDatabase() - const authAvailable = !!( - process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY && process.env.CLERK_SECRET_KEY + const authAvailable = Boolean( + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY && process.env.CLERK_SECRET_KEY, ) - const healthData: HealthResponse = { - status: authAvailable ? 'healthy' : 'unhealthy', - timestamp: new Date().toISOString(), - uptime: Math.floor(process.uptime()), - version: process.env.APP_VERSION || 'unknown', - environment: env.APP_ENV, - services: { - database: { - status: 'connected', - latency: dbLatency, - }, - auth: { - status: authAvailable ? 'available' : 'unavailable', - }, + return NextResponse.json( + { status: authAvailable ? 'healthy' : 'unhealthy' }, + { + status: authAvailable ? 200 : 503, + headers: NO_CACHE_HEADERS, }, - system: { - memory, - nodeVersion: process.version, - }, - } - - return NextResponse.json(healthData, { - status: authAvailable ? 200 : 503, - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache', - Expires: '0', - }, - }) + ) } catch (error) { console.error('Health check failed:', error) - const unhealthyResponse = { - status: 'unhealthy' as const, - timestamp: new Date().toISOString(), - error: 'Health check failed', - } - - return NextResponse.json(unhealthyResponse, { - status: 503, - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache', - Expires: '0', + return NextResponse.json( + { status: 'unhealthy' }, + { + status: 503, + headers: NO_CACHE_HEADERS, }, - }) + ) } } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 01e8e1974..47e451d66 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,4 +1,4 @@ -// Backward-compatible alias: /api/health behaves as the readiness check. +// Legacy alias: /api/health behaves as the readiness check. // Prefer /api/health/live (liveness) and /api/health/ready (readiness) for new // consumers. export { GET } from './ready/route' diff --git a/src/features/health/server/health.repository.ts b/src/features/health/server/health.repository.ts new file mode 100644 index 000000000..e39e532d1 --- /dev/null +++ b/src/features/health/server/health.repository.ts @@ -0,0 +1,14 @@ +import { + PrismaRepository, + type PrismaRepositoryClient, +} from '@/server/persistence/prisma.repository' + +export class HealthRepository extends PrismaRepository { + constructor(prisma: PrismaRepositoryClient) { + super(prisma) + } + + async checkDatabase(): Promise { + await this.prisma.$queryRaw`SELECT 1` + } +} diff --git a/src/features/health/server/health.service.ts b/src/features/health/server/health.service.ts new file mode 100644 index 000000000..b99cffd21 --- /dev/null +++ b/src/features/health/server/health.service.ts @@ -0,0 +1,14 @@ +import { HealthRepository } from './health.repository' +import type { PrismaRepositoryClient } from '@/server/persistence/prisma.repository' + +export class HealthService { + constructor(private readonly repository: HealthRepository) {} + + async checkDatabase(): Promise { + await this.repository.checkDatabase() + } +} + +export function createHealthService(prisma: PrismaRepositoryClient): HealthService { + return new HealthService(new HealthRepository(prisma)) +} From 3d3469b85ae85b0794789e30ee5f01fd5a6a3a11 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 31 Aug 2026 17:03:03 +0200 Subject: [PATCH 11/14] fix(uploads): validate R2 upload configuration --- src/server/services/uploads.service.test.ts | 14 ++++++++++++++ src/server/services/uploads.service.ts | 19 +++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/server/services/uploads.service.test.ts b/src/server/services/uploads.service.test.ts index eb8fbb41c..6c49f4fe5 100644 --- a/src/server/services/uploads.service.test.ts +++ b/src/server/services/uploads.service.test.ts @@ -95,4 +95,18 @@ describe('putUpload', () => { expect(result.key).toMatch(/^uploads\/games\/[0-9a-f-]+\.png$/) expect(result.url).toBe(`https://media.example.com/${result.key}`) }) + + it('normalizes multiple trailing slashes in the public base URL', async () => { + vi.stubEnv('R2_PUBLIC_BASE_URL', 'https://media.example.com///') + r2Mocks.send.mockResolvedValueOnce({}) + + const result = await putUpload({ + directory: 'games', + body: Buffer.from('image'), + contentType: 'image/png', + ext: 'png', + }) + + expect(result.url).toBe(`https://media.example.com/${result.key}`) + }) }) diff --git a/src/server/services/uploads.service.ts b/src/server/services/uploads.service.ts index c8bd7e7ac..bdb69b2ab 100644 --- a/src/server/services/uploads.service.ts +++ b/src/server/services/uploads.service.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { PutObjectCommand } from '@aws-sdk/client-s3' +import { AppError } from '@/lib/errors' import { r2Client } from '@/server/services/r2.service' import type { ImageExtension } from '@/utils/imageValidation' @@ -21,15 +22,21 @@ function getUploadsConfig(): UploadsConfig { const uploadsPublicBase = process.env.R2_UPLOADS_PUBLIC_BASE_URL if (Boolean(uploadsBucket) !== Boolean(uploadsPublicBase)) { - throw new Error('R2_UPLOADS_BUCKET and R2_UPLOADS_PUBLIC_BASE_URL must be set together') + return AppError.internalError( + 'R2_UPLOADS_BUCKET and R2_UPLOADS_PUBLIC_BASE_URL must be set together', + ) } const bucket = uploadsBucket || process.env.R2_BUCKET const publicBase = uploadsPublicBase || process.env.R2_PUBLIC_BASE_URL - if (!bucket) throw new Error('R2_UPLOADS_BUCKET (or R2_BUCKET) is required for uploads') + if (!bucket) { + return AppError.internalError('R2_UPLOADS_BUCKET (or R2_BUCKET) is required for uploads') + } if (!publicBase) { - throw new Error('R2_UPLOADS_PUBLIC_BASE_URL (or R2_PUBLIC_BASE_URL) is required for uploads') + return AppError.internalError( + 'R2_UPLOADS_PUBLIC_BASE_URL (or R2_PUBLIC_BASE_URL) is required for uploads', + ) } return { bucket, publicBase: validatePublicBase(publicBase) } @@ -40,14 +47,14 @@ function validatePublicBase(value: string): string { try { base = new URL(value) } catch { - throw new Error('R2 uploads public base URL must be a valid HTTPS URL') + return AppError.internalError('R2 uploads public base URL must be a valid HTTPS URL') } if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) { - throw new Error('R2 uploads public base URL must be a valid HTTPS URL') + return AppError.internalError('R2 uploads public base URL must be a valid HTTPS URL') } - return base.toString().replace(/\/$/, '') + return base.toString().replace(/\/+$/, '') } export async function putUpload(params: { From d03a0ddc840f3fdbc5cdd189c3efca8ce74be44e Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 31 Aug 2026 17:03:57 +0200 Subject: [PATCH 12/14] test(deployment): harden migration guard coverage --- playwright.config.ts | 2 +- src/server/api/routers/entitlements.test.ts | 75 +++++++++++++++++---- src/server/api/routers/releases.test.ts | 50 +++++++------- src/server/prisma-client.test.ts | 12 ++-- 4 files changed, 93 insertions(+), 46 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index b09627df8..9e7350776 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -49,7 +49,7 @@ export default defineConfig({ globalSetup: path.resolve(currentDir, './tests/global.setup.ts'), use: { - baseURL: process.env.PW_BASE_URL ?? 'http://localhost:3000', + baseURL: process.env.PW_BASE_URL || 'http://localhost:3000', actionTimeout: 10 * 1000, navigationTimeout: 30 * 1000, trace: 'on-first-retry', diff --git a/src/server/api/routers/entitlements.test.ts b/src/server/api/routers/entitlements.test.ts index b41e74737..c74223419 100644 --- a/src/server/api/routers/entitlements.test.ts +++ b/src/server/api/routers/entitlements.test.ts @@ -1,16 +1,37 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Role } from '@orm' +import { prisma } from '@/server/db' +import { EntitlementSource, Role } from '@orm' vi.unmock('@/server/api/trpc') vi.unmock('@/server/api/root') +vi.unmock('@/server/db') vi.unmock('@orm') vi.unmock('@orm/client') -const mockFetchPlayOrder = vi.fn() +const entitlementMocks = vi.hoisted(() => ({ + fetchPlayOrder: vi.fn(), + isPaidAppOrder: vi.fn(), + grant: vi.fn(), +})) +const TEST_ORDER_ID = 'GPA.1234-5678' +const TEST_USER = { + id: '00000000-0000-4000-a000-000000000001', + email: 'test@test.com', + name: 'Test User', + role: Role.USER, + permissions: [], + showNsfw: false, +} vi.mock('@/server/services/googlePlayOrders.service', () => ({ - fetchPlayOrder: (...args: unknown[]) => mockFetchPlayOrder(...args), - isPaidAppOrder: vi.fn(), + fetchPlayOrder: entitlementMocks.fetchPlayOrder, + isPaidAppOrder: entitlementMocks.isPaidAppOrder, +})) + +vi.mock('@/server/repositories/entitlements.repository', () => ({ + EntitlementsRepository: class MockEntitlementsRepository { + grant = entitlementMocks.grant + }, })) const { entitlementsRouter } = await import('./entitlements') @@ -18,16 +39,9 @@ const { entitlementsRouter } = await import('./entitlements') function createCaller() { return entitlementsRouter.createCaller({ session: { - user: { - id: '00000000-0000-4000-a000-000000000001', - email: 'test@test.com', - name: 'Test User', - role: Role.USER, - permissions: [], - showNsfw: false, - }, + user: TEST_USER, }, - prisma: {} as never, + prisma, headers: new Headers(), }) } @@ -47,6 +61,39 @@ describe('entitlements router', () => { code: 'BAD_REQUEST', message: 'Operation not allowed: Android entitlement verification is disabled', }) - expect(mockFetchPlayOrder).not.toHaveBeenCalled() + expect(entitlementMocks.fetchPlayOrder).not.toHaveBeenCalled() + expect(entitlementMocks.grant).not.toHaveBeenCalled() + }) + + it('grants an entitlement for a paid Google Play order when verification is enabled', async () => { + vi.stubEnv('ENABLE_ANDROID_ENTITLEMENT_VERIFICATION', 'true') + vi.stubEnv('ANDROID_PACKAGE_NAME', 'com.example.emuready') + entitlementMocks.fetchPlayOrder.mockResolvedValueOnce({ orderId: TEST_ORDER_ID }) + entitlementMocks.isPaidAppOrder.mockReturnValueOnce(true) + entitlementMocks.grant.mockResolvedValueOnce({}) + + await expect(createCaller().claimPlayOrder({ orderId: TEST_ORDER_ID })).resolves.toEqual({ + ok: true, + }) + expect(entitlementMocks.fetchPlayOrder).toHaveBeenCalledWith( + 'com.example.emuready', + TEST_ORDER_ID, + ) + expect(entitlementMocks.grant).toHaveBeenCalledWith(TEST_USER.id, EntitlementSource.PLAY, { + referenceId: TEST_ORDER_ID, + }) + }) + + it('rejects a Google Play order that is not recognized as paid', async () => { + vi.stubEnv('ENABLE_ANDROID_ENTITLEMENT_VERIFICATION', 'true') + vi.stubEnv('ANDROID_PACKAGE_NAME', 'com.example.emuready') + entitlementMocks.fetchPlayOrder.mockResolvedValueOnce({ orderId: TEST_ORDER_ID }) + entitlementMocks.isPaidAppOrder.mockReturnValueOnce(false) + + await expect(createCaller().claimPlayOrder({ orderId: TEST_ORDER_ID })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Order not recognized as paid app', + }) + expect(entitlementMocks.grant).not.toHaveBeenCalled() }) }) diff --git a/src/server/api/routers/releases.test.ts b/src/server/api/routers/releases.test.ts index cd78a5aa7..1a9f599cf 100644 --- a/src/server/api/routers/releases.test.ts +++ b/src/server/api/routers/releases.test.ts @@ -1,14 +1,25 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { Role } from '@orm' vi.unmock('@/server/api/trpc') vi.unmock('@/server/api/root') +vi.unmock('@/server/db') vi.unmock('@orm') vi.unmock('@orm/client') -const prismaMocks = { - releaseFindFirst: vi.fn(), - entitlementCount: vi.fn(), +vi.stubEnv('NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS', 'false') +vi.resetModules() + +const { prisma } = await import('@/server/db') +const releaseFindFirst = vi.spyOn(prisma.release, 'findFirst') +const entitlementCount = vi.spyOn(prisma.entitlement, 'count') +const TEST_USER = { + id: '00000000-0000-4000-a000-000000000001', + email: 'test@test.com', + name: 'Test User', + role: Role.USER, + permissions: [], + showNsfw: false, } const { releasesRouter } = await import('./releases') @@ -16,49 +27,34 @@ const { releasesRouter } = await import('./releases') function createCaller() { return releasesRouter.createCaller({ session: { - user: { - id: '00000000-0000-4000-a000-000000000001', - email: 'test@test.com', - name: 'Test User', - role: Role.USER, - permissions: [], - showNsfw: false, - }, + user: TEST_USER, }, - prisma: { - release: { - findFirst: prismaMocks.releaseFindFirst, - }, - entitlement: { - count: prismaMocks.entitlementCount, - }, - } as never, + prisma, headers: new Headers(), }) } describe('releases router', () => { + afterAll(() => { + vi.unstubAllEnvs() + }) + afterEach(() => { vi.clearAllMocks() - vi.unstubAllEnvs() }) it('does not expose release metadata when Android downloads are disabled', async () => { - vi.stubEnv('NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS', 'false') - await expect(createCaller().latest({})).resolves.toBeUndefined() - expect(prismaMocks.releaseFindFirst).not.toHaveBeenCalled() + expect(releaseFindFirst).not.toHaveBeenCalled() }) it('does not sign downloads when Android downloads are disabled', async () => { - vi.stubEnv('NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS', 'false') - await expect( createCaller().signDownload({ releaseId: '00000000-0000-4000-a000-000000000002' }), ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'Operation not allowed: Android downloads are disabled', }) - expect(prismaMocks.entitlementCount).not.toHaveBeenCalled() + expect(entitlementCount).not.toHaveBeenCalled() }) }) diff --git a/src/server/prisma-client.test.ts b/src/server/prisma-client.test.ts index 7eb545374..1f246dcbc 100644 --- a/src/server/prisma-client.test.ts +++ b/src/server/prisma-client.test.ts @@ -6,24 +6,28 @@ vi.mock('@prisma/adapter-pg', () => ({ PrismaPg: class {} })) const { getPoolMax, getPoolMin } = await import('./prisma-client') +const LOCAL_DATABASE_URL = 'postgres://u:p@localhost:5432/db' +const LOCAL_IP_DATABASE_URL = 'postgres://u:p@127.0.0.1:5432/db' +const REMOTE_DATABASE_URL = 'postgres://u:p@db.supabase.co:5432/postgres' + afterEach(() => { vi.unstubAllEnvs() }) describe('getPoolMax', () => { it('returns undefined for local hosts (Prisma default pool)', () => { - expect(getPoolMax('postgres://u:p@localhost:5432/db')).toBeUndefined() - expect(getPoolMax('postgres://u:p@127.0.0.1:5432/db')).toBeUndefined() + expect(getPoolMax(LOCAL_DATABASE_URL)).toBeUndefined() + expect(getPoolMax(LOCAL_IP_DATABASE_URL)).toBeUndefined() }) it('returns 1 on Vercel (one connection per ephemeral instance)', () => { vi.stubEnv('VERCEL', '1') - expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres')).toBe(1) + expect(getPoolMax(REMOTE_DATABASE_URL)).toBe(1) }) it('returns 5 for a remote persistent server when not on Vercel', () => { vi.stubEnv('VERCEL', '') - expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres')).toBe(5) + expect(getPoolMax(REMOTE_DATABASE_URL)).toBe(5) }) it('honors an explicit connection_limit over the default', () => { From c5bf75a6476335bbcfc1eecc65a26263dfe0b53c Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 31 Aug 2026 21:26:28 +0200 Subject: [PATCH 13/14] fix(health): bound database readiness checks --- .../health/server/health.repository.test.ts | 49 +++++++++++++++++++ .../health/server/health.repository.ts | 21 +++++--- .../health/server/health.service.test.ts | 35 +++++++++++++ src/features/health/server/health.service.ts | 4 +- 4 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 src/features/health/server/health.repository.test.ts create mode 100644 src/features/health/server/health.service.test.ts diff --git a/src/features/health/server/health.repository.test.ts b/src/features/health/server/health.repository.test.ts new file mode 100644 index 000000000..e29239ec7 --- /dev/null +++ b/src/features/health/server/health.repository.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { prisma } from '@/server/db' +import { HealthRepository } from './health.repository' + +const transaction = vi.hoisted(() => ({ + $queryRaw: vi.fn(), +})) + +const mockPrisma = vi.hoisted(() => ({ + $transaction: vi.fn(), +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +describe('HealthRepository', () => { + beforeEach(() => { + transaction.$queryRaw.mockReset() + mockPrisma.$transaction.mockReset() + mockPrisma.$transaction.mockImplementation( + (operation: (client: typeof transaction) => Promise) => operation(transaction), + ) + }) + + it('bounds the readiness query with transaction and statement timeouts', async () => { + transaction.$queryRaw.mockResolvedValue(undefined) + const repository = new HealthRepository(prisma) + + await expect(repository.checkDatabase()).resolves.toBeUndefined() + + expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { + maxWait: 5_000, + timeout: 5_000, + }) + expect(transaction.$queryRaw).toHaveBeenNthCalledWith( + 1, + ["SELECT set_config('statement_timeout', ", ', true)'], + '5000', + ) + expect(transaction.$queryRaw).toHaveBeenNthCalledWith(2, ['SELECT 1']) + }) + + it('propagates database failures to the readiness handler', async () => { + const error = new Error('database unavailable') + transaction.$queryRaw.mockResolvedValueOnce(undefined).mockRejectedValueOnce(error) + const repository = new HealthRepository(prisma) + + await expect(repository.checkDatabase()).rejects.toBe(error) + }) +}) diff --git a/src/features/health/server/health.repository.ts b/src/features/health/server/health.repository.ts index e39e532d1..b0212d17f 100644 --- a/src/features/health/server/health.repository.ts +++ b/src/features/health/server/health.repository.ts @@ -1,14 +1,23 @@ -import { - PrismaRepository, - type PrismaRepositoryClient, -} from '@/server/persistence/prisma.repository' +import { PrismaRepository } from '@/server/persistence/prisma.repository' +import type { PrismaClient } from '@orm/client' + +const DATABASE_CHECK_TIMEOUT_MS = 5_000 export class HealthRepository extends PrismaRepository { - constructor(prisma: PrismaRepositoryClient) { + constructor(prisma: PrismaClient) { super(prisma) } async checkDatabase(): Promise { - await this.prisma.$queryRaw`SELECT 1` + await this.prisma.$transaction( + async (transaction) => { + await transaction.$queryRaw`SELECT set_config('statement_timeout', ${String(DATABASE_CHECK_TIMEOUT_MS)}, true)` + await transaction.$queryRaw`SELECT 1` + }, + { + maxWait: DATABASE_CHECK_TIMEOUT_MS, + timeout: DATABASE_CHECK_TIMEOUT_MS, + }, + ) } } diff --git a/src/features/health/server/health.service.test.ts b/src/features/health/server/health.service.test.ts new file mode 100644 index 000000000..3ebbd0e67 --- /dev/null +++ b/src/features/health/server/health.service.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { prisma } from '@/server/db' +import { HealthRepository } from './health.repository' +import { HealthService } from './health.service' + +const mockPrisma = vi.hoisted(() => ({ + $transaction: vi.fn(), +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +describe('HealthService', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('delegates the database check to the repository', async () => { + const repository = new HealthRepository(prisma) + const checkDatabase = vi.spyOn(repository, 'checkDatabase').mockResolvedValueOnce() + const service = new HealthService(repository) + + await expect(service.checkDatabase()).resolves.toBeUndefined() + + expect(checkDatabase).toHaveBeenCalledOnce() + }) + + it('propagates repository failures', async () => { + const error = new Error('database unavailable') + const repository = new HealthRepository(prisma) + vi.spyOn(repository, 'checkDatabase').mockRejectedValueOnce(error) + const service = new HealthService(repository) + + await expect(service.checkDatabase()).rejects.toBe(error) + }) +}) diff --git a/src/features/health/server/health.service.ts b/src/features/health/server/health.service.ts index b99cffd21..2556e7455 100644 --- a/src/features/health/server/health.service.ts +++ b/src/features/health/server/health.service.ts @@ -1,5 +1,5 @@ import { HealthRepository } from './health.repository' -import type { PrismaRepositoryClient } from '@/server/persistence/prisma.repository' +import type { PrismaClient } from '@orm/client' export class HealthService { constructor(private readonly repository: HealthRepository) {} @@ -9,6 +9,6 @@ export class HealthService { } } -export function createHealthService(prisma: PrismaRepositoryClient): HealthService { +export function createHealthService(prisma: PrismaClient): HealthService { return new HealthService(new HealthRepository(prisma)) } From 344b8c5d2e68ba2c3b342900687a37ec55f86243 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 31 Aug 2026 22:28:21 +0200 Subject: [PATCH 14/14] docs: update verification steps and add Clerk deletion task --- docs/SELF_HOSTING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 84ae9a291..7ddc4e0d1 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -32,6 +32,7 @@ The VPS currently builds from source in Coolify. A verified GitHub App webhook a - Move builds to GitHub-hosted Actions, publish immutable images to GHCR, and have Coolify deploy them by digest. Do not run the build runner on the application VPS. - Replace the current `staging` default branch and `master` production convention with a documented release and promotion flow. - Consolidate the duplicate mobile tRPC paths and remove the unused transport. +- Make Clerk user deletion idempotent and define how authored reports are retained; current production deliveries can fail on `Listing_authorId_fkey`. Reconcile duplicate-email `user.created` events as part of the same webhook cleanup. - Audit the stale TransIP, FTP, and mail DNS records, then add DMARC after confirming the mail policy. ## Verification and cutover @@ -39,7 +40,7 @@ The VPS currently builds from source in Coolify. A verified GitHub App webhook a 1. Deploy with staging Clerk and Supabase credentials under a temporary hostname. 2. Verify `/api/health/live`, `/api/health/ready`, public pages, authentication, API routes, and image optimization. 3. Measure baseline and burst performance against staging, including p95 latency, errors, CPU, memory, image processing, disk use, and Supabase pool usage. -4. Deploy the production configuration while the production domain still points to Vercel. -5. Point Cloudflare at the VPS and keep the previous Vercel deployment available for rollback. +4. Deploy the production configuration while the production domain still points to Vercel. Verify web and mobile Clerk flows through the temporary hostname. +5. Point both the apex and `www` Cloudflare records at the VPS, preserve the current apex-to-`www` canonical redirect, and keep the previous Vercel deployment available for rollback. Do not run an upload backfill unless a read-only production database inventory confirms that `/uploads/...` references still exist and the matching source files have been recovered.