Files
app/docs/superpowers/plans/2026-03-23-production-readiness.md
BOHA 4608494a3f initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:46:51 +01:00

32 KiB

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

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:

# 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
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:

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:

await app.register(rateLimit, {
  max: 100,
  timeWindow: '1 minute',
});
  • Step 3: Commit
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:

import { FastifyReply, FastifyRequest } from 'fastify';
import { config } from '../config/env';

export async function securityHeaders(
  _request: FastifyRequest,
  reply: FastifyReply,
): Promise<void> {
  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
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:

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:

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:

fastify.post('/verify', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ });
fastify.post('/login/totp', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ });
  • Step 3: Commit
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:

// 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:

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
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

npm install zod
  • Step 2: Create common validation helper

Create src/schemas/common.ts:

import { ZodSchema, ZodError } from 'zod';

export function parseBody<T>(schema: ZodSchema<T>, 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:

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<typeof LoginSchema>;
export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;
  • Step 4: Apply schemas in auth routes

In src/routes/admin/auth.ts, import and use:

import { parseBody } from '../../schemas/common';
import { LoginSchema, TotpVerifySchema } from '../../schemas/auth.schema';

Replace existing manual validation in the login handler with:

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
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<string, unknown> patterns in each route file. Example for src/schemas/users.schema.ts:

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<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;

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<string, unknown>: 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<string, unknown> with:

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<string, unknown> casts

Search all route files for remaining as Record<string, unknown> and replace with typed Zod parsing.

grep -rn "as Record<string, unknown>" src/routes/

Expected: zero results after cleanup.

  • Step 4: Commit
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:

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<string> {
  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<string> {
  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<number> {
  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:

import { generateSharedNumber } from '../../services/numbering.service';

Remove the local generateOrderNumber(), generateProjectNumber(), and generateSharedNumber() functions. Replace all calls:

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:

import { generateSharedNumber } from '../../services/numbering.service';

In src/routes/admin/quotations.ts, replace the inline offer number logic with:

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:

// 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:

grep -rn "from.*sequence" src/

If no callers remain, delete src/utils/sequence.ts.

  • Step 5: Commit
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:

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<string, unknown> = {};
  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:

fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
  const query = request.query as Record<string, unknown>;
  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
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

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

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

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

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

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
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
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

npm install -D vitest supertest @types/supertest
  • Step 2: Create vitest config

Create vitest.config.ts:

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):

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:

import dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });
  • Step 5: Add test scripts to package.json
"test": "vitest run",
"test:watch": "vitest"
  • Step 6: Commit
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:

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:

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp, extractCookie } from './helpers';

let app: Awaited<ReturnType<typeof buildApp>>;

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
npm test

Expected: tests pass (or skip gracefully if test DB not seeded).

  • Step 4: Commit
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

npm test
  • Step 4: Commit
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:

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
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:

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
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
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
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:

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 <old-key-hex> <new-key-hex> [--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
npx tsx scripts/rotate-totp-key.ts <current-key> <new-key> --dry-run
  • Step 3: Commit
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
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
npm run build
APP_ENV=production node dist/server.js

Expected: server starts without errors, serves static files, API responds.

  • Step 4: Final commit
git add -A
git commit -m "chore: production readiness cleanup"