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

1173 lines
32 KiB
Markdown

# 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<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**
```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<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`:
```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<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:
```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<string, unknown>` 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<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:
```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<string, unknown>` casts**
Search all route files for remaining `as Record<string, unknown>` and replace with typed Zod parsing.
```bash
grep -rn "as Record<string, unknown>" 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<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:
```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<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:
```typescript
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**
```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<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**
```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 <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**
```bash
npx tsx scripts/rotate-totp-key.ts <current-key> <new-key> --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"
```