# Production Readiness Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Harden boha-app-ts for production: security fixes, Zod validation, service layer extraction, test suite, and build preparation. **Architecture:** Fastify backend with Prisma ORM (MySQL), React SPA frontend, JWT + TOTP auth. Routes are refactored from fat controllers to thin handlers calling service functions. All request bodies validated with Zod schemas. Tests target the HTTP API layer via Supertest. **Tech Stack:** TypeScript, Fastify 5, Prisma 6, Zod, Vitest, Supertest, bcryptjs, jsonwebtoken, otpauth **Spec:** `docs/superpowers/specs/2026-03-23-production-readiness-design.md` --- ## Phase 1: Security Hardening ### Task 1: Secrets & Environment **Files:** - Verify: `.gitignore` - Create: `.env.example` - [ ] **Step 1: Verify .env is gitignored and never committed** ```bash grep "^\.env$" .gitignore git log --all --diff-filter=A -- .env ``` Expected: `.env` appears in `.gitignore`. Git log shows no results (never committed). If `.env` was committed, run `git rm --cached .env` and add to `.gitignore`. - [ ] **Step 2: Create .env.example** Create `.env.example` with placeholder values: ```env # Database DATABASE_URL=mysql://user:password@localhost:3306/app # Server PORT=3001 HOST=127.0.0.1 APP_ENV=local # Auth — MUST regenerate for production: openssl rand -hex 32 JWT_SECRET=generate-with-openssl-rand-hex-32 ACCESS_TOKEN_EXPIRY=900 REFRESH_TOKEN_SESSION_EXPIRY=3600 REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # TOTP — MUST regenerate for production: openssl rand -hex 32 TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32 # File storage NAS_PATH=Z:/02_PROJEKTY MAX_UPLOAD_SIZE=52428800 # Email CONTACT_EMAIL_TO= CONTACT_EMAIL_FROM= SMTP_FROM= # CORS (production only, comma-separated) CORS_ORIGINS= ``` - [ ] **Step 3: Commit** ```bash git add .env.example .gitignore git commit -m "chore: add .env.example with placeholder values" ``` --- ### Task 2: Rate Limiting — Login Endpoint **Files:** - Modify: `src/server.ts` - Modify: `src/routes/admin/auth.ts` - [ ] **Step 1: Add per-route rate limit to login endpoint** In `src/routes/admin/auth.ts`, add rate limit config to the login route: ```typescript fastify.post('/login', { config: { rateLimit: { max: 20, timeWindow: '1 minute', }, }, }, async (request, reply) => { // ... existing login handler }); ``` - [ ] **Step 2: Verify global rate limit stays at 100/min** Confirm `src/server.ts` line 53-56 remains: ```typescript await app.register(rateLimit, { max: 100, timeWindow: '1 minute', }); ``` - [ ] **Step 3: Commit** ```bash git add src/routes/admin/auth.ts git commit -m "security: add stricter rate limit on login endpoint (20/min)" ``` --- ### Task 3: Security Headers — CSP & Permissions-Policy **Files:** - Modify: `src/middleware/security.ts` - [ ] **Step 1: Add CSP (production only) and Permissions-Policy** Replace `src/middleware/security.ts` with: ```typescript import { FastifyReply, FastifyRequest } from 'fastify'; import { config } from '../config/env'; export async function securityHeaders( _request: FastifyRequest, reply: FastifyReply, ): Promise { reply.header('X-Content-Type-Options', 'nosniff'); reply.header('X-Frame-Options', 'DENY'); reply.header('Referrer-Policy', 'strict-origin-when-cross-origin'); reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); if (config.isProduction) { reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); reply.header( 'Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'", ); } } ``` - [ ] **Step 2: Commit** ```bash git add src/middleware/security.ts git commit -m "security: add CSP (prod) and Permissions-Policy headers" ``` --- ### Task 4: Request Body Limits **Files:** - Modify: `src/server.ts` - [ ] **Step 1: Add global body limit to Fastify** In `src/server.ts`, add `bodyLimit` to the Fastify constructor: ```typescript const app = Fastify({ logger: { level: config.isProduction ? 'warn' : 'info', }, trustProxy: true, bodyLimit: 1048576, // 1MB global limit }); ``` - [ ] **Step 2: Add 10KB limit to auth routes** In `src/routes/admin/auth.ts`, add bodyLimit to login, refresh, and TOTP routes: ```typescript fastify.post('/login', { bodyLimit: 10240, // 10KB config: { rateLimit: { max: 20, timeWindow: '1 minute' }, }, }, async (request, reply) => { /* ... */ }); fastify.post('/refresh', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ }); ``` Also in `src/routes/admin/totp.ts`, add to verify endpoints: ```typescript fastify.post('/verify', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ }); fastify.post('/login/totp', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ }); ``` - [ ] **Step 3: Commit** ```bash git add src/server.ts src/routes/admin/auth.ts src/routes/admin/totp.ts git commit -m "security: add request body size limits (1MB global, 10KB auth)" ``` --- ### Task 5: Timing-Safe Auth **Files:** - Modify: `src/services/auth.ts` - [ ] **Step 1: Add dummy hash constant and timing-safe comparison** At the top of `src/services/auth.ts`, add a pre-computed dummy hash: ```typescript // Pre-computed bcrypt hash for timing-safe comparison when user not found const DUMMY_HASH = '$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO'; ``` Then in the `login()` function, replace the early return on user-not-found: ```typescript if (!user) { // Timing-safe: run bcrypt even when user not found await bcrypt.compare(password, DUMMY_HASH); return { type: 'error', message: 'Neplatné přihlašovací údaje', status: 401 }; } ``` - [ ] **Step 2: Commit** ```bash git add src/services/auth.ts git commit -m "security: timing-safe auth to prevent username enumeration" ``` --- ## Phase 2: Input Validation (Zod) ### Task 6: Install Zod, Create Common Helper & Auth Schema **Files:** - Create: `src/schemas/common.ts` - Create: `src/schemas/auth.schema.ts` - Modify: `src/routes/admin/auth.ts` - [ ] **Step 1: Install zod** ```bash npm install zod ``` - [ ] **Step 2: Create common validation helper** Create `src/schemas/common.ts`: ```typescript import { ZodSchema, ZodError } from 'zod'; export function parseBody(schema: ZodSchema, body: unknown): { data: T } | { error: string } { try { return { data: schema.parse(body) }; } catch (e) { if (e instanceof ZodError) { return { error: e.errors.map(err => err.message).join(', ') }; } return { error: 'Neplatný požadavek' }; } } ``` - [ ] **Step 3: Create auth validation schemas** Create `src/schemas/auth.schema.ts`: ```typescript import { z } from 'zod'; export const LoginSchema = z.object({ username: z.string().min(1, 'Uživatelské jméno je povinné'), password: z.string().min(1, 'Heslo je povinné'), remember_me: z.boolean().optional().default(false), }); export const TotpVerifySchema = z.object({ login_token: z.string().min(1, 'Token je povinný'), totp_code: z.string().length(6, 'Kód musí mít 6 číslic'), }); export const TotpBackupSchema = z.object({ login_token: z.string().min(1, 'Token je povinný'), backup_code: z.string().min(1, 'Záložní kód je povinný'), }); export type LoginInput = z.infer; export type TotpVerifyInput = z.infer; export type TotpBackupInput = z.infer; ``` - [ ] **Step 4: Apply schemas in auth routes** In `src/routes/admin/auth.ts`, import and use: ```typescript import { parseBody } from '../../schemas/common'; import { LoginSchema, TotpVerifySchema } from '../../schemas/auth.schema'; ``` Replace existing manual validation in the login handler with: ```typescript const parsed = parseBody(LoginSchema, request.body); if ('error' in parsed) return error(reply, parsed.error, 400); const { username, password, remember_me } = parsed.data; ``` - [ ] **Step 5: Commit** ```bash git add src/schemas/common.ts src/schemas/auth.schema.ts src/routes/admin/auth.ts package.json package-lock.json git commit -m "feat: add Zod validation for auth endpoints" ``` --- ### Task 7: Create Validation Schemas for All Domains **Files:** - Create: `src/schemas/users.schema.ts` - Create: `src/schemas/offers.schema.ts` - Create: `src/schemas/orders.schema.ts` - Create: `src/schemas/invoices.schema.ts` - Create: `src/schemas/projects.schema.ts` - Create: `src/schemas/customers.schema.ts` - Create: `src/schemas/attendance.schema.ts` - Create: `src/schemas/trips.schema.ts` - Create: `src/schemas/vehicles.schema.ts` - Create: `src/schemas/leave-requests.schema.ts` - Create: `src/schemas/roles.schema.ts` - Create: `src/schemas/bank-accounts.schema.ts` - Create: `src/schemas/company-settings.schema.ts` - Create: `src/schemas/received-invoices.schema.ts` - Create: `src/schemas/scope-templates.schema.ts` - Create: `src/schemas/profile.schema.ts` - [ ] **Step 1: Create domain schemas** Create each schema file based on the existing `request.body as Record` patterns in each route file. Example for `src/schemas/users.schema.ts`: ```typescript import { z } from 'zod'; export const CreateUserSchema = z.object({ username: z.string().min(1, 'Uživatelské jméno je povinné'), email: z.string().email('Neplatný formát e-mailu'), password: z.string().min(8, 'Heslo musí mít alespoň 8 znaků'), first_name: z.string().optional().default(''), last_name: z.string().optional().default(''), role_id: z.number().int().positive().optional(), is_active: z.boolean().optional().default(true), }); export const UpdateUserSchema = CreateUserSchema.partial().omit({ password: true }).extend({ password: z.string().min(8, 'Heslo musí mít alespoň 8 znaků').optional(), }); export type CreateUserInput = z.infer; export type UpdateUserInput = z.infer; ``` Follow the same pattern for all domains — each schema mirrors the fields currently extracted from `request.body`. Cover ALL route files that use `as Record`: users, offers, orders, invoices, projects, customers, attendance, trips, vehicles, leave-requests, roles, bank-accounts, company-settings, received-invoices, scope-templates, profile, totp. - [ ] **Step 2: Apply schemas in all route files** For each route file, replace `const body = request.body as Record` with: ```typescript const parsed = parseBody(CreateOfferSchema, request.body); if ('error' in parsed) return error(reply, parsed.error, 400); const body = parsed.data; ``` - [ ] **Step 3: Remove `as Record` casts** Search all route files for remaining `as Record` and replace with typed Zod parsing. ```bash grep -rn "as Record" src/routes/ ``` Expected: zero results after cleanup. - [ ] **Step 4: Commit** ```bash git add src/schemas/ src/routes/ git commit -m "feat: add Zod validation schemas for all domain routes" ``` --- ## Phase 3: Service Layer Refactor ### Task 8: Create Numbering Service **Files:** - Create: `src/services/numbering.service.ts` - Modify: `src/routes/admin/orders.ts` - Modify: `src/routes/admin/projects.ts` - Modify: `src/routes/admin/quotations.ts` - [ ] **Step 1: Create numbering service** Create `src/services/numbering.service.ts`: ```typescript import prisma from '../config/database'; /** * Shared number generator for orders and projects. * Format: YYtypeCode + 4-digit sequence (e.g., 26710003) * Queries MAX from both orders and projects tables. */ export async function generateSharedNumber(): Promise { const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } }); const typeCode = settings?.order_type_code || '71'; const yy = String(new Date().getFullYear()).slice(-2); const prefix = `${yy}${typeCode}`; const prefixLen = prefix.length; const likePattern = `${prefix}%`; const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>` SELECT COALESCE(MAX(seq), 0) as max_seq FROM ( SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq FROM orders WHERE order_number LIKE ${likePattern} UNION ALL SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq FROM projects WHERE project_number LIKE ${likePattern} ) combined `; const nextNum = Number(result[0]?.max_seq ?? 0) + 1; return `${prefix}${String(nextNum).padStart(4, '0')}`; } /** * Next offer number. Queries MAX from quotations table. * Format: YEAR/PREFIX/NNN (e.g., 2026/NA/008) */ export async function generateOfferNumber(): Promise { const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } }); const prefix = settings?.quotation_prefix || 'NA'; const year = new Date().getFullYear(); const likePattern = `${year}/${prefix}/%`; const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>` SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num FROM quotations WHERE quotation_number LIKE ${likePattern} `; const nextNum = Number(result[0]?.max_num ?? 0) + 1; return `${year}/${prefix}/${String(nextNum).padStart(3, '0')}`; } /** * Next invoice number via atomic sequence table. */ export async function generateInvoiceNumber(year: number): Promise { return prisma.$transaction(async (tx) => { const existing = await tx.number_sequences.findFirst({ where: { type: 'invoice', year }, }); if (existing) { const nextNum = (existing.last_number ?? 0) + 1; await tx.number_sequences.update({ where: { id: existing.id }, data: { last_number: nextNum }, }); return nextNum; } await tx.number_sequences.create({ data: { type: 'invoice', year, last_number: 1 }, }); return 1; }); } ``` - [ ] **Step 2: Update orders.ts to use numbering service** Replace `generateOrderNumber()` and `generateProjectNumber()` in `src/routes/admin/orders.ts` with imports from the service: ```typescript import { generateSharedNumber } from '../../services/numbering.service'; ``` Remove the local `generateOrderNumber()`, `generateProjectNumber()`, and `generateSharedNumber()` functions. Replace all calls: ```typescript const orderNumber = await generateSharedNumber(); const projectNumber = await generateSharedNumber(); ``` - [ ] **Step 3: Update projects.ts and quotations.ts similarly** In `src/routes/admin/projects.ts`, replace the inline next-number logic with: ```typescript import { generateSharedNumber } from '../../services/numbering.service'; ``` In `src/routes/admin/quotations.ts`, replace the inline offer number logic with: ```typescript import { generateOfferNumber } from '../../services/numbering.service'; ``` - [ ] **Step 4: Update sequence.ts to re-export from numbering service** In `src/utils/sequence.ts`, replace the implementation with a re-export to avoid breaking any remaining callers: ```typescript // Re-export from canonical location export { generateInvoiceNumber as getNextNumber } from '../services/numbering.service'; ``` Check for any remaining imports of `sequence.ts` and update them to import from `numbering.service.ts` directly: ```bash grep -rn "from.*sequence" src/ ``` If no callers remain, delete `src/utils/sequence.ts`. - [ ] **Step 5: Commit** ```bash git add src/services/numbering.service.ts src/routes/admin/orders.ts src/routes/admin/projects.ts src/routes/admin/quotations.ts src/utils/sequence.ts git commit -m "refactor: extract numbering logic into numbering.service.ts" ``` --- ### Task 9: Extract Offers Service **Files:** - Create: `src/services/offers.service.ts` - Modify: `src/routes/admin/quotations.ts` - [ ] **Step 1: Create offers service** Extract the business logic from each route handler in `quotations.ts` into `src/services/offers.service.ts`. The service functions should handle Prisma queries, calculations, and data enrichment. Example: ```typescript import prisma from '../config/database'; import { generateOfferNumber } from './numbering.service'; export async function listOffers(params: { page: number; limit: number; skip: number; sort: string; order: string; search?: string; status?: string; customerId?: number }) { const where: Record = {}; if (params.status) where.status = params.status; if (params.customerId) where.customer_id = params.customerId; // ... build where clause, run query, enrich results // Return { offers, total } } export async function getOffer(id: number) { /* ... */ } export async function createOffer(data: CreateOfferInput) { /* ... */ } export async function updateOffer(id: number, data: UpdateOfferInput) { /* ... */ } export async function duplicateOffer(id: number) { /* ... */ } export async function getNextOfferNumber() { return generateOfferNumber(); } ``` - [ ] **Step 2: Slim down quotations.ts routes** Each route handler becomes: ```typescript fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => { const query = request.query as Record; const pagination = parsePagination(query); const result = await listOffers({ ...pagination, status: query.status as string, customerId: query.customer_id ? Number(query.customer_id) : undefined }); return reply.send({ success: true, data: result.offers, pagination: buildPaginationMeta(result.total, pagination.page, pagination.limit) }); }); ``` - [ ] **Step 3: Commit** ```bash git add src/services/offers.service.ts src/routes/admin/quotations.ts git commit -m "refactor: extract offers business logic into offers.service.ts" ``` --- ### Task 10a: Extract Orders Service **Files:** - Create: `src/services/orders.service.ts` - Modify: `src/routes/admin/orders.ts` - [ ] **Step 1:** Follow the same pattern as Task 9. Move order CRUD, status transitions, project creation, and item/section management from `src/routes/admin/orders.ts` into `src/services/orders.service.ts`. - [ ] **Step 2: Commit** ```bash git add src/services/orders.service.ts src/routes/admin/orders.ts git commit -m "refactor: extract orders business logic into orders.service.ts" ``` --- ### Task 10b: Extract Invoices Service **Files:** - Create: `src/services/invoices.service.ts` - Modify: `src/routes/admin/invoices.ts` - [ ] **Step 1:** Move invoice CRUD, stats calculation, overdue auto-update, and PDF data preparation from `src/routes/admin/invoices.ts` into `src/services/invoices.service.ts`. - [ ] **Step 2: Commit** ```bash git add src/services/invoices.service.ts src/routes/admin/invoices.ts git commit -m "refactor: extract invoices business logic into invoices.service.ts" ``` --- ### Task 10c: Extract Projects Service **Files:** - Create: `src/services/projects.service.ts` - Modify: `src/routes/admin/projects.ts` - [ ] **Step 1:** Move project CRUD, notes management from `src/routes/admin/projects.ts` into `src/services/projects.service.ts`. - [ ] **Step 2: Commit** ```bash git add src/services/projects.service.ts src/routes/admin/projects.ts git commit -m "refactor: extract projects business logic into projects.service.ts" ``` --- ### Task 10d: Extract Users Service **Files:** - Create: `src/services/users.service.ts` - Modify: `src/routes/admin/users.ts` - [ ] **Step 1:** Move user CRUD, role assignment, password reset from `src/routes/admin/users.ts` into `src/services/users.service.ts`. - [ ] **Step 2: Commit** ```bash git add src/services/users.service.ts src/routes/admin/users.ts git commit -m "refactor: extract users business logic into users.service.ts" ``` --- ### Task 10e: Extract Attendance Service **Files:** - Create: `src/services/attendance.service.ts` - Modify: `src/routes/admin/attendance.ts` - [ ] **Step 1:** Move clock in/out, shift management, monthly calculations from `src/routes/admin/attendance.ts` into `src/services/attendance.service.ts`. - [ ] **Step 2: Commit** ```bash git add src/services/attendance.service.ts src/routes/admin/attendance.ts git commit -m "refactor: extract attendance business logic into attendance.service.ts" ``` --- ### Task 10f: Verify Service Extraction - [ ] **Step 1: Check remaining Prisma calls in routes** ```bash grep -rn "prisma\." src/routes/admin/ | grep -v "import" | wc -l ``` Goal: minimal direct Prisma calls in the 7 refactored route files (offers, orders, invoices, projects, users, attendance). Other route files (trips, vehicles, leave-requests, bank-accounts, etc.) are intentionally not refactored — they are simpler CRUD with minimal business logic. - [ ] **Step 2: Commit any remaining fixes** ```bash git add -A git commit -m "refactor: finalize service layer extraction" ``` --- ## Phase 4: Testing ### Task 11: Testing Infrastructure **Files:** - Create: `vitest.config.ts` - Create: `.env.test` - Create: `src/__tests__/setup.ts` - Modify: `package.json` - [ ] **Step 1: Install test dependencies** ```bash npm install -D vitest supertest @types/supertest ``` - [ ] **Step 2: Create vitest config** Create `vitest.config.ts`: ```typescript import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, environment: 'node', setupFiles: ['./src/__tests__/setup.ts'], testTimeout: 15000, hookTimeout: 15000, }, }); ``` - [ ] **Step 3: Add .env.test to .gitignore and create .env.test.example** Add `.env.test` to `.gitignore`: ``` .env.test ``` Create `.env.test.example` (committed, no real credentials): ```env DATABASE_URL=mysql://user:password@127.0.0.1:3306/app_test JWT_SECRET=test-jwt-secret-do-not-use-in-production TOTP_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef APP_ENV=local PORT=3099 HOST=127.0.0.1 ``` Then create the actual `.env.test` locally (not committed) with real credentials. - [ ] **Step 4: Create test setup** Create `src/__tests__/setup.ts`: ```typescript import dotenv from 'dotenv'; dotenv.config({ path: '.env.test' }); ``` - [ ] **Step 5: Add test scripts to package.json** ```json "test": "vitest run", "test:watch": "vitest" ``` - [ ] **Step 6: Commit** ```bash git add vitest.config.ts .env.test.example .gitignore src/__tests__/setup.ts package.json package-lock.json git commit -m "chore: add vitest testing infrastructure" ``` --- ### Task 12: Auth Flow Tests **Files:** - Create: `src/__tests__/helpers.ts` - Create: `src/__tests__/auth.test.ts` - [ ] **Step 1: Create test helpers** Create `src/__tests__/helpers.ts`: ```typescript import Fastify from 'fastify'; import cookie from '@fastify/cookie'; import rateLimit from '@fastify/rate-limit'; import authRoutes from '../routes/admin/auth'; import totpRoutes from '../routes/admin/totp'; export async function buildApp() { const app = Fastify({ logger: false }); await app.register(cookie); await app.register(rateLimit, { max: 1000, timeWindow: '1 minute' }); await app.register(authRoutes, { prefix: '/api/admin' }); await app.register(totpRoutes, { prefix: '/api/admin/totp' }); return app; } export function extractCookie(response: any, name: string): string | undefined { const cookies = response.headers['set-cookie']; if (!cookies) return undefined; const arr = Array.isArray(cookies) ? cookies : [cookies]; for (const c of arr) { if (c.startsWith(`${name}=`)) { return c.split(';')[0].split('=')[1]; } } return undefined; } ``` - [ ] **Step 2: Write auth tests** Create `src/__tests__/auth.test.ts`: ```typescript import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp, extractCookie } from './helpers'; let app: Awaited>; beforeAll(async () => { app = await buildApp(); }); afterAll(async () => { await app.close(); }); describe('POST /api/admin/login', () => { it('returns 401 for invalid credentials', async () => { const res = await app.inject({ method: 'POST', url: '/api/admin/login', payload: { username: 'nonexistent', password: 'wrong' }, }); expect(res.statusCode).toBe(401); expect(res.json().success).toBe(false); }); it('returns 400 for missing fields', async () => { const res = await app.inject({ method: 'POST', url: '/api/admin/login', payload: {}, }); expect(res.statusCode).toBe(400); }); it('returns access token and sets refresh cookie on valid login', async () => { const res = await app.inject({ method: 'POST', url: '/api/admin/login', payload: { username: 'admin', password: 'test-password' }, }); // Depends on test DB having an admin user if (res.statusCode === 200) { const body = res.json(); expect(body.success).toBe(true); expect(body.data.access_token).toBeDefined(); expect(extractCookie(res, 'refresh_token')).toBeDefined(); } }); }); describe('POST /api/admin/refresh', () => { it('returns 401 without refresh token', async () => { const res = await app.inject({ method: 'POST', url: '/api/admin/refresh', }); expect(res.statusCode).toBe(401); }); }); describe('POST /api/admin/logout', () => { it('clears refresh token cookie', async () => { const res = await app.inject({ method: 'POST', url: '/api/admin/logout', }); // Should succeed even without a valid token expect(res.statusCode).toBeLessThan(500); }); }); ``` - [ ] **Step 3: Run tests** ```bash npm test ``` Expected: tests pass (or skip gracefully if test DB not seeded). - [ ] **Step 4: Commit** ```bash git add src/__tests__/ git commit -m "test: add auth flow integration tests" ``` --- ### Task 13: Permissions & CRUD Tests **Files:** - Create: `src/__tests__/permissions.test.ts` - Create: `src/__tests__/numbering.test.ts` - [ ] **Step 1: Write permissions tests** Create `src/__tests__/permissions.test.ts` testing: - Unauthenticated requests return 401 - Non-admin without permission returns 403 - Admin can access all endpoints - [ ] **Step 2: Write numbering tests** Create `src/__tests__/numbering.test.ts` testing: - `generateSharedNumber()` returns correct format (YYtypeCode + 4 digits) - `generateOfferNumber()` returns correct format (YEAR/PREFIX/NNN) - Sequential calls return incrementing numbers - [ ] **Step 3: Run full test suite** ```bash npm test ``` - [ ] **Step 4: Commit** ```bash git add src/__tests__/ git commit -m "test: add permissions and numbering tests" ``` --- ## Phase 5: Production Build Preparation ### Task 14: Graceful Shutdown **Files:** - Modify: `src/server.ts` - Modify: `src/config/database.ts` - [ ] **Step 1: Add shutdown handlers** At the end of the `start()` function in `src/server.ts`, after `app.listen()`, add: ```typescript const shutdown = async (signal: string) => { app.log.info(`${signal} received, shutting down gracefully...`); try { await app.close(); const { default: prisma } = await import('./config/database'); await prisma.$disconnect(); app.log.info('Server shut down successfully'); process.exit(0); } catch (err) { app.log.error(err, 'Error during shutdown'); process.exit(1); } }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); ``` - [ ] **Step 2: Commit** ```bash git add src/server.ts git commit -m "feat: add graceful shutdown handling (SIGTERM/SIGINT)" ``` --- ### Task 15: Static File Serving (Production) **Files:** - Modify: `src/server.ts` - [ ] **Step 1: Add @fastify/static for production** In `src/server.ts`, after the dev-only Vite block, add production static serving: ```typescript if (config.isProduction) { const fastifyStatic = (await import('@fastify/static')).default; await app.register(fastifyStatic, { root: path.join(__dirname, '..', 'dist-client'), prefix: '/', wildcard: false, }); app.setNotFoundHandler((request, reply) => { if (request.url.startsWith('/api/')) { return reply.status(404).send({ success: false, error: 'Not found' }); } return reply.sendFile('index.html'); }); } ``` Add `import path from 'path'` at the top. - [ ] **Step 2: Commit** ```bash git add src/server.ts git commit -m "feat: serve static frontend assets in production via @fastify/static" ``` --- ### Task 16: Prisma Migration Baseline - [ ] **Step 1: Create baseline migration** ```bash mkdir -p prisma/migrations/0_init npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql npx prisma migrate resolve --applied 0_init ``` This marks the existing schema as the baseline without running any SQL. - [ ] **Step 2: Commit** ```bash git add prisma/migrations/ git commit -m "chore: create Prisma migration baseline from existing schema" ``` --- ### Task 17: TOTP Re-encryption Script **Files:** - Create: `scripts/rotate-totp-key.ts` - [ ] **Step 1: Create rotation script** Create `scripts/rotate-totp-key.ts`: ```typescript import crypto from 'crypto'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); function decrypt(ciphertext: string, keyHex: string): string { const key = Buffer.from(keyHex, 'hex'); const [ivHex, encHex, tagHex] = ciphertext.split(':'); const iv = Buffer.from(ivHex, 'hex'); const encrypted = Buffer.from(encHex, 'hex'); const tag = Buffer.from(tagHex, 'hex'); const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8'); } function encrypt(plaintext: string, keyHex: string): string { const key = Buffer.from(keyHex, 'hex'); const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`; } async function main() { const oldKey = process.argv[2]; const newKey = process.argv[3]; const dryRun = process.argv.includes('--dry-run'); if (!oldKey || !newKey) { console.error('Usage: tsx scripts/rotate-totp-key.ts [--dry-run]'); process.exit(1); } const users = await prisma.users.findMany({ where: { totp_enabled: true, totp_secret: { not: null } }, select: { id: true, username: true, totp_secret: true }, }); console.log(`Found ${users.length} users with TOTP enabled`); if (dryRun) { for (const user of users) { try { const decrypted = decrypt(user.totp_secret!, oldKey); const reEncrypted = encrypt(decrypted, newKey); const verify = decrypt(reEncrypted, newKey); console.log(` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`); } catch (e) { console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`); } } console.log('\nDry run complete. No changes made.'); return; } await prisma.$transaction(async (tx) => { for (const user of users) { const decrypted = decrypt(user.totp_secret!, oldKey); const reEncrypted = encrypt(decrypted, newKey); await tx.users.update({ where: { id: user.id }, data: { totp_secret: reEncrypted } }); console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`); } }); console.log('\nAll TOTP secrets re-encrypted successfully.'); await prisma.$disconnect(); } main().catch((e) => { console.error(e); process.exit(1); }); ``` - [ ] **Step 2: Test with dry run** ```bash npx tsx scripts/rotate-totp-key.ts --dry-run ``` - [ ] **Step 3: Commit** ```bash git add scripts/rotate-totp-key.ts git commit -m "chore: add TOTP encryption key rotation script" ``` --- ### Task 18: Final Cleanup - [ ] **Step 1: Check for unused dependencies** ```bash npx depcheck ``` Review output and remove any unused packages. - [ ] **Step 2: Verify .gitignore completeness** Ensure `dist/`, `dist-client/`, `.env`, `.env.test`, `node_modules/` are all ignored. - [ ] **Step 3: Verify production build works** ```bash npm run build APP_ENV=production node dist/server.js ``` Expected: server starts without errors, serves static files, API responds. - [ ] **Step 4: Final commit** ```bash git add -A git commit -m "chore: production readiness cleanup" ```