Files
arsam e9cd12dda6 Import remaining project documentation and reference notes
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:33:04 +03:30

86 KiB
Raw Permalink Blame History

GLM-5.1-6126 — Full Session Report

Model: zai-org/glm-5.1 (novita-ai/zai-org/glm-5.1) Date: 2026-06-01 Project: Teknav (teknav.ir) — Persian RTL React SPA with Fastify/PostgreSQL/Redis backend Session Goal: Implement all recommended improvements (Steps 1–5) plus stale-while-revalidate caching, verify correctness, and fix all discovered bugs


Table of Contents

  1. Session Overview
  2. Pre-Flight: Project Backup
  3. Step 1: Testing Infrastructure
    • 1a. Backend Unit Tests
    • 1b. Backend Integration Tests
    • 1c. Frontend Vitest Tests
    • 1d. CI Pipeline Script
  4. Step 2: Code Modernization
    • 2a. History API Routing (Remove hashchange)
    • 2b. React Context Store
    • 2c. Admin Module Split
    • 2d. Pages Module Split
    • 2e. Content API Without Seed Fallback
  5. Step 3: Observability
    • 3a. Structured Error Types
    • 3b. Request Tracing
    • 3c. Prometheus Metrics
    • 3d. Client Error Daily Digest
  6. Step 4: Database Migrations
    • 4a. Managed Migration Directory
    • 4b. Persian FTS Migration
    • 4c. Soft Deletes
    • 4d. Missing Indexes
  7. Step 5: Security Hardening
    • 5a. Redis-Backed CAPTCHA
    • 5b. HTML Sanitization
    • 5c. Security Headers Plugin
    • 5d. Session Fixation Protection
    • 5e. CSRF Rotation
  8. Stale-While-Revalidate Caching
  9. Shared Logic Extraction
    • publish.ts
    • persian.ts
  10. Verification Phase
    • Backend Verification
    • Frontend Verification
  11. Bug Fix Phase
    • Critical Fixes
    • Quality Fixes
  12. Final Validation
  13. File Inventory
  14. Key Decisions & Rationale
  15. Lessons Learned

1. Session Overview

The session began with a request to implement a set of recommended improvements for the Teknav Persian tech publication platform. The improvements were organized into 5 steps plus a bonus stale-while-revalidate cache rewrite:

Step Category Description
1 Testing Unit tests, integration tests, Vitest, CI pipeline
2 Modernization History API, React store, module splits, API cleanup
3 Observability Error types, request tracing, Prometheus, error digest
4 Database Managed migrations, FTS migration, soft deletes, indexes
5 Security Redis CAPTCHA, sanitization, security headers, session rotation, CSRF

After implementing all steps, a deep verification phase uncovered 7 critical bugs and 4 quality issues that were fixed in a subsequent pass. The session concluded with zero TypeScript errors, 47/47 unit tests passing, 11/11 Vitest tests passing, and a successful Vite production build.

Constraints Observed

  • Zero CDN in production (enforced at build time)
  • Persian-first RTL — all UI copy in Persian
  • Native Linux/systemd + nginx — no Docker
  • 5-role RBAC: admin > editor > writer > reviewer > reader
  • Security should be flexible, not too strict (Step 5)
  • Step 8 features deferred until all current work is 100% done
  • Backup to S3 before making changes (rclone + coaxys-cf)

2. Pre-Flight: Project Backup

Before any code changes, a full project backup was uploaded to S3-compatible storage.

Command

rclone copy /srv/teknav/app coaxys-cf:coaxys-cf/Backup/teknav-app-20260601-105218 \
  --progress --transfers 16 --checkers 8

Result

  • 3,593 files uploaded
  • 1.67 GiB total size
  • Destination: coaxys-cf:coaxys-cf/Backup/teknav-app-20260601-105218
  • All files verified via rclone check

This backup serves as a rollback point if any changes introduced regressions.


3. Step 1: Testing Infrastructure

1a. Backend Unit Tests

File: backend/tests/unit-logic.test.mjs Framework: node:test (built-in Node.js test runner) Count: 47 tests, all passing

The unit tests cover pure business logic functions extracted from route handlers. Since the backend is TypeScript (.ts) and the test runner doesn't support TS imports natively, the pure functions are duplicated inline in the test file. This avoids .mjs → .ts module resolution issues while still providing full coverage of the logic.

Functions Tested

Function Source Test Count Description
cleanText publish.ts 3 Strips HTML tags, collapses whitespace, handles null/undefined
truncateText publish.ts 4 Truncates to max length, preserves short strings, handles edge cases
internalLinkCount publish.ts 3 Counts internal navigation links in HTML content
hasPlaceholderContent publish.ts 4 Detects placeholder/lorem ipsum content (English + Persian)
publishQualityIssues publish.ts 12 10 quality checks, returns names of failing checks
completeSeoData publish.ts 4 SEO defaults, preservation, keyword generation, limit enforcement
normalizeFa persian.ts 8 ZWNJ, tatweel, diacritics, Arabic→Persian, whitespace, empty
computeStreak persian.ts 4 Consecutive day streak calculation
currentStreakIfAlive persian.ts 4 Streak survival check

Test Structure

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

// --- Inline pure functions (mirroring publish.ts) ---

function cleanText(value) {
  return String(value ?? '')
    .replace(/<[^>]+>/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

// ... (other inline functions)

// --- Tests ---

describe('cleanText', () => {
  it('strips HTML tags and collapses whitespace', () => {
    assert.equal(cleanText('<p>Hello</p>  <b>World</b>'), 'Hello World');
  });
  it('handles null/undefined', () => {
    assert.equal(cleanText(null), '');
    assert.equal(cleanText(undefined), '');
  });
  it('trims the result', () => {
    assert.equal(cleanText('  hello  '), 'hello');
  });
});

describe('normalizeFa', () => {
  it('removes ZWNJ (U+200C)', () => {
    assert.equal(normalizeFa('می\u200Cروید'), 'میرود');
  });
  it('removes tatweel (U+0640)', () => {
    assert.equal(normalizeFa('ع\u0640رب'), 'عرب');
  });
  it('removes diacritics', () => {
    assert.equal(normalizeFa('كِتَابٌ'), 'کتاب');
  });
  it('normalizes Arabic yeh to Persian yeh', () => {
    assert.equal(normalizeFa('ي'), 'ی');
  });
  it('normalizes Arabic keaf to Persian keaf', () => {
    assert.equal(normalizeFa('ك'), 'ک');
  });
  it('normalizes alef variants', () => {
    assert.equal(normalizeFa('أإآٱ'), 'اااا');
  });
  it('collapses whitespace and lowercases', () => {
    assert.equal(normalizeFa('  HELLO   WORLD  '), 'hello world');
  });
  it('handles empty string', () => {
    assert.equal(normalizeFa(''), '');
  });
});

// ... (47 total tests)

Execution

cd backend && node --test tests/unit-logic.test.mjs
# Tests 47/47 passed
# Duration: ~318ms

1b. Backend Integration Tests

File: backend/tests/integration.test.mjs Framework: node:test Count: ~20 test assertions across 8 test groups

Integration tests run against a live backend server. They auto-skip if the backend is not reachable, making them safe to run in CI environments without a backend.

Test Groups

Group What It Tests
Health endpoints GET /api/health returns 200, GET /api/ready returns 200 or 503
Public content Articles, categories, tags, authors, series, search, newsletter, jobs
Auth gates 8 protected endpoints return 401 without session
Request tracing x-request-id header is echoed in response
Security headers X-Content-Type-Options, X-Frame-Options, CSP, Permissions-Policy present
404 handling Unknown routes return 404
Input validation Bad newsletter subscription, bad error report return 400
Optional auth tests Comment creation with session (skipped if no auth)

Auto-Skip Pattern

const BASE = 'http://localhost:3010';
let serverUp = false;

try {
  const res = await fetch(`${BASE}/api/health`);
  serverUp = res.ok;
} catch {}

if (!serverUp) {
  console.log('Backend not reachable — skipping integration tests');
  process.exit(0);
}

For strict validation against a running backend:

TEST_REQUIRE_SERVER=1 node --test tests/integration.test.mjs

1c. Frontend Vitest Tests

Files:

  • src/lib/api.test.js (4 tests)
  • src/lib/content-api.test.js (7 tests)
  • src/test/setup.js (test setup)
  • vitest.config.js (configuration)

Framework: Vitest 4.1.8 with jsdom environment Total: 11 tests, all passing

api.test.js — API Client Tests

Tests the api fetch wrapper and ApiError class:

import { describe, it, expect, vi } from 'vitest';
import { api, ApiError } from './api.js';

describe('ApiError', () => {
  it('stores status and body', () => {
    const err = new ApiError(404, { error: 'not_found' });
    expect(err.status).toBe(404);
    expect(err.body).toEqual({ error: 'not_found' });
  });
});

describe('api client', () => {
  it('sends GET with Accept header and credentials', async () => {
    // Mocks global fetch, verifies headers
  });
  it('sends POST with CSRF token and Content-Type', async () => {
    // Verifies tek_csrf cookie is read and sent as header
  });
  it('throws ApiError on non-OK response', async () => {
    // Verifies error class and status/body
  });
});

content-api.test.js — Content API Tests

Tests the contentApi public content client with mocked api.js:

import { describe, it, expect, vi } from 'vitest';
import { contentApi } from './content-api.js';

vi.mock('./api.js', () => ({
  api: {
    get: vi.fn(),
    post: vi.fn(),
  },
  ApiError: class ApiError extends Error {
    constructor(s, b) { super(); this.status = s; this.body = b; }
  },
}));

describe('contentApi', () => {
  it('listArticles calls correct endpoint', async () => { /* ... */ });
  it('getArticle caches results', async () => { /* ... */ });
  it('search URL-encodes Persian query', async () => { /* ... */ });
  it('listCategories returns items', async () => { /* ... */ });
  it('toggleReaction sends POST', async () => { /* ... */ });
  it('subscribeNewsletter sends email and captcha', async () => { /* ... */ });
  it('trackEvent sends POST', async () => { /* ... */ });
});

vitest.config.js

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.js'],
    include: ['src/**/*.{test,spec}.{js,jsx}'],
  },
});

Execution

npx vitest run
# Test Files  2 passed (2)
#      Tests  11 passed (11)
#   Duration  ~4.2s

1d. CI Pipeline Script

File: scripts/ci.mjs

A unified CI pipeline that runs all checks in sequence, continuing on failure and reporting a summary.

#!/usr/bin/env node

import { execSync } from 'node:child_process';
const passed = [];
const failed = [];

function run(name, cmd, opts = {}) {
  process.stdout.write(`\n▶ ${name}… `);
  try {
    execSync(cmd, { stdio: opts.silent ? 'pipe' : 'inherit', timeout: opts.timeout ?? 120_000 });
    passed.push(name);
    console.log('✓');
  } catch (e) {
    failed.push(name);
    console.log('✗');
    if (!opts.continue) {
      console.error(`\nAborting: ${name} failed.`);
      process.exit(1);
    }
  }
}

console.log('=== Teknav CI Pipeline ===\n');

// Backend checks
run('Backend TypeScript build', 'cd backend && npx tsc --noEmit', { continue: true });
run('Backend unit tests', 'cd backend && node --test tests/unit-logic.test.mjs', { continue: true });

// Frontend checks
run('Frontend Vitest tests', 'npx vitest run', { continue: true, timeout: 60_000 });
run('Frontend Vite build', 'npm run build', { continue: true, timeout: 300_000 });
run('No CDN URLs in dist', 'node scripts/check-no-cdn.mjs', { continue: true });
run('Responsive layout check', 'node scripts/check-responsive.mjs', { continue: true });
run('Production readiness', 'npm run preflight:prod', { continue: true });

// Summary
console.log('\n=== CI Summary ===');
console.log(`Passed: ${passed.length}`);
console.log(`Failed: ${failed.length}`);
if (failed.length > 0) {
  console.log('Failed steps:', failed.join(', '));
  process.exit(1);
} else {
  console.log('All checks passed!');
  process.exit(0);
}

package.json Scripts Added

{
  "test": "vitest run",
  "test:watch": "vitest",
  "ci": "node scripts/ci.mjs"
}

4. Step 2: Code Modernization

2a. History API Routing (Remove hashchange)

File: teknav-ui.jsx

The NavProvider component previously listened to both popstate and hashchange events. The hashchange listener was a legacy from when the app used hash-based routing. Since the app has been using the History API for all navigation, the hashchange listener was redundant and could cause duplicate page updates.

Before

useEffect(() => {
  const handler = () => setPage(getCurrentPage());
  window.addEventListener('popstate', handler);
  window.addEventListener('hashchange', handler);
  return () => {
    window.removeEventListener('popstate', handler);
    window.removeEventListener('hashchange', handler);
  };
}, []);

After

useEffect(() => {
  const handler = () => setPage(getCurrentPage());
  window.addEventListener('popstate', handler);
  return () => {
    window.removeEventListener('popstate', handler);
  };
}, []);

This ensures the app uses only the History API for route changes, eliminating the possibility of hash-based navigation being triggered.


2b. React Context Store

File: src/lib/store.jsx

Replaced the legacy teknav-store.js IIFE (Immediately Invoked Function Expression) that used localStorage for state management with a proper React context-based store.

The Problem with teknav-store.js

The original store was:

  • A module-level IIFE that mutated localStorage directly
  • Not integrated with React's rendering cycle — state changes didn't trigger re-renders
  • Used localStorage as the source of truth, which is synchronous and blocks the main thread
  • Had no cleanup mechanism for stale data

The New Store

import { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { api, ApiError } from './api.js';

const StoreCtx = createContext(null);

export function StoreProvider({ children }) {
  const [user, setUser] = useState(null);
  const [bootstrapping, setBootstrapping] = useState(true);

  useEffect(() => {
    let cancelled = false;
    api.get('/api/auth/me')
      .then((res) => { if (!cancelled) setUser(res?.user ?? null); })
      .catch(() => { if (!cancelled) setUser(null); })
      .finally(() => { if (!cancelled) setBootstrapping(false); });
    return () => { cancelled = true; };
  }, []);

  const login = useCallback(async (identifier, password, captchaId, userSolution) => {
    const res = await api.post('/api/auth/login', {
      identifier, password, captchaId, userSolution
    });
    if (res?.user) setUser(res.user);
    return res?.twoFactorRequired ? res : (res?.user ?? null);
  }, []);

  const logout = useCallback(async () => {
    try { await api.post('/api/auth/logout', {}); } catch {}
    setUser(null);
  }, []);

  const refreshMe = useCallback(async () => {
    const res = await api.get('/api/auth/me');
    const u = res?.user ?? null;
    setUser(u);
    return u;
  }, []);

  return (
    <StoreCtx.Provider value={{ user, setUser, login, logout, refreshMe, bootstrapping }}>
      {children}
    </StoreCtx.Provider>
  );
}

export function useStore() {
  return useContext(StoreCtx);
}

Key Features

  • React-integrated: State changes trigger re-renders via useState
  • Async bootstrapping: Fetches current user from /api/auth/me on mount
  • Cancellation safety: Effect cleanup prevents state updates after unmount
  • 2FA support: Login returns twoFactorRequired flag for 2FA flow
  • Captcha support: Login accepts captcha parameters
  • Email or @username login: The identifier field handles both

2c. Admin Module Split

Original: teknav-admin.jsx — 3,202 lines in a single file Split into: 12 files in src/admin/ + barrel index.js

The admin module was the largest single file in the codebase. Splitting it into focused modules improves:

  • Maintainability: Each panel is independently editable
  • Code review: Smaller diffs per change
  • Lazy loading potential: Each panel can be code-split
  • Cognitive load: Developers work on one panel at a time

Split Structure

File Component Description
Shared.jsx Shared UI utilities, actionBtn Common admin UI primitives
Dashboard.jsx AdminPanel Main admin dashboard with stats
ArticleEditor.jsx Article editor Rich text editor with SEO fields
ArticleList.jsx Article list Filterable/sortable article list
MediaLibrary.jsx Media library Image upload and management
CommentModeration.jsx Comment moderation Flag/approve/delete comments
UserManagement.jsx User management CRUD for user accounts
ReviewPanel.jsx Review panel Article review workflow
NewsletterPanel.jsx Newsletter panel Campaign creation and sending
SeriesPanel.jsx Series panel Article series management
AnalyticsPanel.jsx Analytics panel Writer/editor analytics
WriterWorkspace.jsx WriterWorkspace Writer-specific workspace

Barrel File: src/admin/index.js

export { AdminPanel } from './Dashboard.jsx';
export { WriterWorkspace } from './WriterWorkspace.jsx';
export { actionBtn } from './Shared.jsx';

Re-export in teknav-admin.jsx

export { AdminPanel, WriterWorkspace, actionBtn } from './src/admin/index.js';

This preserves the existing import paths for teknav-app.jsx while allowing the internal code to live in separate files.


2d. Pages Module Split

Original: teknav-pages.jsx — 1,957 lines in a single file Split into: 27 page files in src/pages/ + barrel index.js

Split Structure

File Component Route
SearchPage.jsx SearchPage /search
CategoryPage.jsx CategoryPage /category/:slug
TagPage.jsx TagPage /tag/:slug
GlossaryPage.jsx GlossaryPage /glossary
AuthorsListPage.jsx AuthorsListPage /authors
AuthorProfilePage.jsx AuthorProfilePage /author/:slug
SeriesPage.jsx SeriesPage /series/:slug
TopicHubPage.jsx TopicHubPage /topics/:slug
NewsletterArchivePage.jsx NewsletterArchivePage /newsletter
NewsletterIssuePage.jsx NewsletterIssuePage /newsletter/:slug
JobsPage.jsx JobsPage /jobs
CoursesPage.jsx CoursesPage /courses
MembershipPage.jsx MembershipPage /membership
MembershipSuccessPage.jsx MembershipSuccessPage /membership/success
PrivacyPolicyPage.jsx PrivacyPolicyPage /privacy
TermsOfUsePage.jsx TermsOfUsePage /terms
AboutPage.jsx AboutPage /about
ContactPage.jsx ContactPage /contact
ToolsPage.jsx ToolsPage /tools
NlpNormalizerPage.jsx NlpNormalizerPage /tools/nlp-normalizer
RagCalculatorPage.jsx RagCalculatorPage /tools/rag-calculator
TekRagHubPage.jsx TekRagHubPage /tools/tek-rag-hub
ComparisonPage.jsx ComparisonPage /compare/:slug
DirectoryListPage.jsx DirectoryListPage /directory
CompanyProfilePage.jsx CompanyProfilePage /directory/:slug
ComparisonsListPage.jsx ComparisonsListPage /compare
StoriesListPage.jsx StoriesListPage /stories

Barrel File: src/pages/index.js

export { SearchPage } from './SearchPage.jsx';
export { CategoryPage } from './CategoryPage.jsx';
export { TagPage } from './TagPage.jsx';
export { GlossaryPage } from './GlossaryPage.jsx';
export { AuthorsListPage } from './AuthorsListPage.jsx';
export { AuthorProfilePage } from './AuthorProfilePage.jsx';
export { SeriesPage } from './SeriesPage.jsx';
export { TopicHubPage } from './TopicHubPage.jsx';
export { NewsletterArchivePage } from './NewsletterArchivePage.jsx';
export { NewsletterIssuePage } from './NewsletterIssuePage.jsx';
export { JobsPage } from './JobsPage.jsx';
export { CoursesPage } from './CoursesPage.jsx';
export { MembershipPage } from './MembershipPage.jsx';
export { MembershipSuccessPage } from './MembershipSuccessPage.jsx';
export { PrivacyPolicyPage } from './PrivacyPolicyPage.jsx';
export { TermsOfUsePage } from './TermsOfUsePage.jsx';
export { AboutPage } from './AboutPage.jsx';
export { ContactPage } from './ContactPage.jsx';
export { ToolsPage } from './ToolsPage.jsx';
export { NlpNormalizerPage } from './NlpNormalizerPage.jsx';
export { RagCalculatorPage } from './RagCalculatorPage.jsx';
export { TekRagHubPage } from './TekRagHubPage.jsx';
export { ComparisonPage } from './ComparisonPage.jsx';
export { DirectoryListPage } from './DirectoryListPage.jsx';
export { CompanyProfilePage } from './CompanyProfilePage.jsx';
export { ComparisonsListPage } from './ComparisonsListPage.jsx';
export { StoriesListPage } from './StoriesListPage.jsx';

Re-export in teknav-pages.jsx

export { SearchPage, CategoryPage, TagPage, GlossaryPage, AuthorsListPage,
  AuthorProfilePage, SeriesPage, TopicHubPage, NewsletterArchivePage,
  NewsletterIssuePage, JobsPage, CoursesPage, MembershipPage,
  MembershipSuccessPage, PrivacyPolicyPage, TermsOfUsePage, AboutPage,
  ContactPage, ToolsPage, NlpNormalizerPage, RagCalculatorPage,
  TekRagHubPage, ComparisonPage, DirectoryListPage, CompanyProfilePage,
  ComparisonsListPage, StoriesListPage } from './src/pages/index.js';

2e. Content API Without Seed Fallback

File: src/lib/content-api.js

The original content-api.js imported both TeknavData (seed data) and TeknavStore (localStorage IIFE) as fallback sources. When the API failed, it silently merged seed data with API responses. This masked API failures and could serve stale data to users without any indication.

The Problem

// OLD PATTERN (removed)
import { TeknavData } from '../teknav-data.js';
import { TeknavStore } from '../teknav-store.js';

async function listArticles(params) {
  try {
    const res = await api.get('/api/articles?' + toQuery(params));
    return res.articles ?? TeknavData.articles;  // Silent fallback!
  } catch {
    return TeknavData.articles;  // Even worse — completely hides the error
  }
}

The Fix

The rewritten content-api.js makes pure API calls only. If the API fails, the error propagates to the caller. This makes API failures visible and forces the UI layer to handle them explicitly.

import { api } from './api.js';

const articleCache = new Map();

function toQuery(params) {
  return Object.entries(params)
    .filter(([, v]) => v !== undefined && v !== '')
    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
    .join('&');
}

export const contentApi = {
  async listArticles(params = {}) {
    const res = await api.get('/api/articles?' + toQuery(params));
    return res;
  },

  async getArticle(slug) {
    if (articleCache.has(slug)) return articleCache.get(slug);
    const res = await api.get(`/api/articles/${encodeURIComponent(slug)}`);
    articleCache.set(slug, res);
    return res;
  },

  async getRelated(slug) {
    return api.get(`/api/articles/${encodeURIComponent(slug)}/related`);
  },

  async search(query, params = {}) {
    return api.get('/api/search?' + toQuery({ q: query, ...params }));
  },

  // ... 11 more methods, all pure API calls
};

Impact

  • API failures are no longer silently swallowed
  • UI components must handle loading/error states explicitly
  • The TeknavData import is completely removed from the content API
  • In-memory Map cache for individual articles is retained (simple, no TTL needed for SPA session)

5. Step 3: Observability

3a. Structured Error Types

File: backend/src/lib/errors.ts

Created a hierarchy of structured error classes that replace ad-hoc reply.code(x).send({ error: 'y' }) calls throughout the backend. Each error class has a Persian message, HTTP status code, and machine-readable error code.

import type { FastifyReply } from 'fastify';

export class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly code: string,
    message?: string,
  ) {
    super(message ?? code);
    this.name = this.constructor.name;
  }

  send(reply: FastifyReply) {
    return reply.code(this.statusCode).send({
      error: this.code,
      message: this.message
    });
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string, identifier?: string) {
    super(404, 'not_found',
      identifier
        ? `${resource} با شناسه ${identifier} یافت نشد`
        : `${resource} یافت نشد`);
  }
}

export class ValidationError extends AppError {
  constructor(message?: string) {
    super(400, 'invalid_input', message ?? 'ورودی نامعتبر');
  }
}

export class UnauthorizedError extends AppError {
  constructor() {
    super(401, 'unauthenticated', 'ابتدا وارد حساب خود شوید');
  }
}

export class ForbiddenError extends AppError {
  constructor(action?: string) {
    super(403, 'forbidden',
      action ? `شما اجازه ${action} را ندارید` : 'دسترسی غیرمجاز');
  }
}

export class RateLimitedError extends AppError {
  public readonly retryAfter?: number;
  constructor(retryAfter?: number) {
    super(429, 'rate_limited', 'تعداد درخواست‌ها از حد مجاز گذشت');
    this.retryAfter = retryAfter;
  }

  send(reply: FastifyReply) {
    if (this.retryAfter) reply.header('Retry-After', this.retryAfter);
    return reply.code(this.statusCode).send({
      error: this.code,
      message: this.message,
      retryAfter: this.retryAfter
    });
  }
}

export class ConflictError extends AppError {
  constructor(message?: string) {
    super(409, 'conflict', message ?? 'تداخل داده‌ها');
  }
}

export class PublishQualityError extends AppError {
  constructor(public readonly issues: string[]) {
    super(409, 'publish_quality',
      `موارد نیاز به اصلاح: ${issues.join('، ')}`);
  }

  send(reply: FastifyReply) {
    return reply.code(this.statusCode).send({
      error: this.code,
      message: this.message,
      issues: this.issues
    });
  }
}

Error Class Summary

Class Status Code Persian Message
NotFoundError 404 not_found {resource} با شناسه {id} یافت نشد
ValidationError 400 invalid_input ورودی نامعتبر
UnauthorizedError 401 unauthenticated ابتدا وارد حساب خود شوید
ForbiddenError 403 forbidden دسترسی غیرمجاز
RateLimitedError 429 rate_limited تعداد درخواست‌ها از حد مجاز گذشت
ConflictError 409 conflict تداخل داده‌ها
PublishQualityError 409 publish_quality موارد نیاز به اصلاح: {issues}

3b. Request Tracing

File: backend/src/plugins/request-id.ts

Adds a unique x-request-id header to every request for distributed tracing and log correlation.

import fp from 'fastify-plugin';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { randomBytes } from 'node:crypto';

const HEADER = 'x-request-id';

declare module 'fastify' {
  interface FastifyRequest {
    requestId: string;
  }
}

export default fp(async function requestIdPlugin(app: FastifyInstance) {
  app.addHook('onRequest', async (req: FastifyRequest, _reply: FastifyReply) => {
    const incoming = req.headers[HEADER];
    req.requestId = typeof incoming === 'string' && incoming.length > 0
      ? incoming.slice(0, 64)
      : randomBytes(12).toString('hex');
  });

  app.addHook('onSend', async (_req: FastifyRequest, reply: FastifyReply) => {
    reply.header(HEADER, _req.requestId);
  });
});

Behavior

  • Incoming request with x-request-id: Preserves and echoes it (truncated to 64 chars for safety)
  • No incoming header: Generates a 24-hex-char random ID from randomBytes(12)
  • Response: Always includes x-request-id header matching the request's ID
  • Type augmentation: FastifyRequest.requestId is available throughout the request lifecycle

3c. Prometheus Metrics

File: backend/src/routes/metrics.ts

Exposes a Prometheus-compatible metrics endpoint at GET /api/metrics.

import type { FastifyInstance } from 'fastify';
import { redis } from '../redis.js';
import { prisma } from '../db.js';

export default async function metricsRoutes(app: FastifyInstance) {
  app.get('/api/metrics', async (_req, reply) => {
    reply.header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
    const lines: string[] = [];

    // Process metrics
    const uptime = process.uptime();
    lines.push(`# HELP teknav_uptime_seconds Process uptime in seconds`);
    lines.push(`# TYPE teknav_uptime_seconds gauge`);
    lines.push(`teknav_uptime_seconds ${uptime}`);

    // Memory metrics
    const mem = process.memoryUsage();
    lines.push(`# HELP teknav_nodejs_heap_used_bytes Node.js heap used`);
    lines.push(`# TYPE teknav_nodejs_heap_used_bytes gauge`);
    lines.push(`teknav_nodejs_heap_used_bytes ${mem.heapUsed}`);
    lines.push(`# HELP teknav_nodejs_heap_total_bytes Node.js heap total`);
    lines.push(`# TYPE teknav_nodejs_heap_total_bytes gauge`);
    lines.push(`teknav_nodejs_heap_total_bytes ${mem.heapTotal}`);
    lines.push(`# HELP teknav_nodejs_rss_bytes Node.js RSS`);
    lines.push(`# TYPE teknav_nodejs_rss_bytes gauge`);
    lines.push(`teknav_nodejs_rss_bytes ${mem.rss}`);

    // Database metrics (wrapped in try/catch for graceful degradation)
    try {
      const [articleCount, userCount, subCount] = await Promise.all([
        prisma.article.count({ where: { status: 'منتشرشده' } }),
        prisma.user.count(),
        prisma.newsletterSubscription.count(),
      ]);
      lines.push(`# HELP teknav_articles_published_total Published articles`);
      lines.push(`# TYPE teknav_articles_published_total gauge`);
      lines.push(`teknav_articles_published_total ${articleCount}`);
      lines.push(`# HELP teknav_users_total Total users`);
      lines.push(`# TYPE teknav_users_total gauge`);
      lines.push(`teknav_users_total ${userCount}`);
      lines.push(`# HELP teknav_newsletter_subscribers_total Newsletter subscribers`);
      lines.push(`# TYPE teknav_newsletter_subscribers_total gauge`);
      lines.push(`teknav_newsletter_subscribers_total ${subCount}`);
    } catch {}

    // Active visitors from Redis
    try {
      const now = Date.now();
      const windowMs = 5 * 60 * 1000;
      const [guests, users] = await Promise.all([
        redis.zrangebyscore('visitors:guest', now - windowMs, now),
        redis.zrangebyscore('visitors:user', now - windowMs, now),
      ]);
      lines.push(`# HELP teknav_active_visitors Active visitors in 5m window`);
      lines.push(`# TYPE teknav_active_visitors gauge`);
      lines.push(`teknav_active_visitors{type="guest"} ${guests.length}`);
      lines.push(`teknav_active_visitors{type="user"} ${users.length}`);
    } catch {}

    return reply.send(lines.join('\n') + '\n');
  });
}

Exported Metrics

Metric Type Source Description
teknav_uptime_seconds gauge process Process uptime
teknav_nodejs_heap_used_bytes gauge process Heap memory used
teknav_nodejs_heap_total_bytes gauge process Total heap memory
teknav_nodejs_rss_bytes gauge process Resident set size
teknav_articles_published_total gauge PostgreSQL Published article count
teknav_users_total gauge PostgreSQL Total user count
teknav_newsletter_subscribers_total gauge PostgreSQL Newsletter subscriber count
teknav_active_visitors{type="guest"} gauge Redis Active guest visitors (5m window)
teknav_active_visitors{type="user"} gauge Redis Active logged-in visitors (5m window)

3d. Client Error Daily Digest

File: backend/src/lib/error-digest.ts

Sends a daily email digest of client-side errors when they exceed a threshold.

import { prisma } from '../db.js';
import { sendEmail } from './email.js';

const ALERT_THRESHOLD = 50;
const ALERT_RECIPIENT = process.env.ADMIN_ALERT_EMAIL || '';

export async function sendErrorDigest(): Promise<{ sent: boolean; count: number }> {
  const since = new Date();
  since.setHours(0, 0, 0, 0);

  const errors = await prisma.clientError.findMany({
    where: { createdAt: { gte: since } },
    orderBy: { createdAt: 'desc' },
    take: 100,
  });

  const count = await prisma.clientError.count({
    where: { createdAt: { gte: since } },
  });

  if (count < ALERT_THRESHOLD || !ALERT_RECIPIENT) {
    return { sent: false, count };
  }

  const topMessages = Object.entries(
    errors.reduce<Record<string, number>>((acc, e) => {
      acc[e.message] = (acc[e.message] || 0) + 1;
      return acc;
    }, {}),
  )
    .sort(([, a], [, b]) => b - a)
    .slice(0, 10);

  const html = `
    <div dir="rtl" style="font-family: Vazirmatn, sans-serif; max-width: 600px; margin: 0 auto;">
      <h2 style="color: #C94C4C;">گزارش خطاهای روزانه تکناو</h2>
      <p>تعداد خطاها در ۲۴ ساعت گذشته: <strong>${count}</strong></p>
      <h3>بیشترین تکرار:</h3>
      <ul>
        ${topMessages.map(([msg, cnt]) =>
          `<li><code>${msg}</code> (${cnt} بار)</li>`
        ).join('')}
      </ul>
      <p style="color: #5F6B6D; font-size: 12px;">
        این گزارش از پنل خطاهای تکناو ارسال شده است.
      </p>
    </div>
  `;

  await sendEmail({
    to: ALERT_RECIPIENT,
    subject: `گزارش خطاهای تکناو — ${count} خطا`,
    html
  });
  return { sent: true, count };
}

Behavior

  • Queries ClientError table for errors from midnight today
  • If count < 50 or no ADMIN_ALERT_EMAIL configured: returns { sent: false, count }
  • If threshold exceeded: sends RTL Persian email with top 10 most frequent errors
  • Returns { sent: true, count } on success

6. Step 4: Database Migrations

4a. Managed Migration Directory Structure

Created a proper Prisma migration directory structure with three migrations:

backend/prisma/migrations/
├── 00000000000001_init/
│   └── migration.sql
├── 00000000000002_persian_fts/
│   └── migration.sql
└── 00000000000003_soft_deletes/
    └── migration.sql

The apply-schema.mjs script already handles migrations vs db push automatically based on whether the migrations directory exists. With these migration files in place, prisma migrate deploy will be used in production instead of db push.


4b. Persian FTS Migration

File: backend/prisma/migrations/00000000000002_persian_fts/migration.sql

Moved the Persian full-text search setup from seed.ts into a proper migration. This ensures FTS is set up before any data is inserted, and the setup is idempotent and trackable.

-- Persian normalization fn (idempotent via OR REPLACE).
CREATE OR REPLACE FUNCTION teknav_norm_fa(t text)
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
  SELECT regexp_replace(
    translate(
      coalesce(t, ''),
      E'يكأإآٱ‌ـًٌٍَُِّْ،؛؟',
      E'یکاااا                ,;?'
    ),
    '\s+', ' ', 'g'
  );
$$;

-- Trigram support for autocomplete / fuzzy matching.
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Weighted tsvector computed column (A=title, B=subtitle+summary, C=content).
ALTER TABLE "Article"
  ADD COLUMN IF NOT EXISTS "search_tsv" tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('simple', teknav_norm_fa("title")),                    'A') ||
    setweight(to_tsvector('simple', teknav_norm_fa(coalesce("subtitle", ''))),  'B') ||
    setweight(to_tsvector('simple', teknav_norm_fa("summary")),                 'B') ||
    setweight(to_tsvector('simple', teknav_norm_fa("content")),                 'C')
  ) STORED;

-- GIN index for full-text search.
CREATE INDEX IF NOT EXISTS "Article_search_tsv_idx"
  ON "Article" USING GIN ("search_tsv");

-- Trigram index for title autocomplete.
CREATE INDEX IF NOT EXISTS "Article_title_trgm_idx"
  ON "Article" USING GIN ("title" gin_trgm_ops);

What the Normalization Function Does

The teknav_norm_fa function normalizes Persian text for search:

Input Character Output Description
ي (Arabic yeh) ی (Persian yeh) Unify yeh variants
ك (Arabic keaf) ک (Persian keaf) Unify keaf variants
أ إ آ ٱ (alef variants) ا (bare alef) Unify alef variants
U+200C (ZWNJ) (removed) Remove zero-width non-joiner
U+0640 (tatweel) (removed) Remove Arabic tatweel
U+064B–U+0652 (diacritics) (removed) Strip tashkeel/diacritics
،؛؟ (Persian punctuation) ,;? Normalize to ASCII for search

FTS Weighting

Field Weight Description
title A (highest) Most important for relevance
subtitle B Medium importance
summary B Medium importance
content C (lowest) Least important per-term

Seed.ts Change

The applyPersianFts() function was removed from seed.ts since the migration now handles this. The function definition was also removed as dead code.


4c. Soft Deletes

File: backend/prisma/migrations/00000000000003_soft_deletes/migration.sql

Added deletedAt nullable timestamp columns to Article and Comment models for soft-delete support.

-- Add soft-delete columns for Article and Comment.
ALTER TABLE "Article" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "Comment" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);

-- Index for filtering soft-deleted rows.
CREATE INDEX IF NOT EXISTS "Article_deletedAt_idx" ON "Article" ("deletedAt");
CREATE INDEX IF NOT EXISTS "Comment_deletedAt_idx" ON "Comment" ("deletedAt");

-- Standalone publishedAt index for homepage queries.
CREATE INDEX IF NOT EXISTS "Article_publishedAt_idx" ON "Article" ("publishedAt" DESC);

Schema Changes in schema.prisma

model Article {
  // ... existing fields ...
  deletedAt  DateTime?
  // ... existing relations ...
  @@index([deletedAt])
  @@index([publishedAt(sort: Desc)])
}

model Comment {
  // ... existing fields ...
  deletedAt  DateTime?
  // ... existing relations ...
  @@index([deletedAt])
}

Design Decisions

  • deletedAt DateTime? instead of isDeleted Boolean — preserves deletion timestamp for audit trails
  • Nullable — null means not deleted; a timestamp means soft-deleted at that time
  • Dedicated indexes — enables efficient WHERE deletedAt IS NULL filtering
  • No Prisma middleware — soft-delete filtering is done explicitly in queries for transparency

4d. Missing Indexes

Added two indexes that were missing from the schema:

  1. Article_publishedAt_idx — Descending index on publishedAt for homepage queries that sort by newest first
  2. Article_deletedAt_idx — Index on deletedAt for filtering soft-deleted articles
  3. Comment_deletedAt_idx — Index on deletedAt for filtering soft-deleted comments

These indexes prevent full table scans on the most common query patterns.


7. Step 5: Security Hardening

5a. Redis-Backed CAPTCHA

File: backend/src/lib/captcha.ts

Rewrote the CAPTCHA system from an in-memory Map to Redis-backed storage. This eliminates the memory leak risk from an unbounded Map and makes CAPTCHA state survive server restarts (within the TTL window).

Before (In-Memory Map)

const store = new Map<string, { text: string; expires: number }>();

export function createCaptcha() {
  const id = randomBytes(16).toString('hex');
  const text = generateCaptchaText(5);
  store.set(id, { text, expires: Date.now() + 120_000 });
  return { id, data: generateCaptchaSVG(text) };
}

export function verifyCaptcha(id: string, solution: string): boolean {
  const entry = store.get(id);
  if (!entry || Date.now() > entry.expires) { store.delete(id); return false; }
  store.delete(id);
  return entry.text === solution.toLowerCase().trim();
}

Problems:

  • Unbounded Map grows without limit if entries aren't verified
  • No automatic cleanup of expired entries
  • Lost on server restart
  • Not shared across multiple server instances

After (Redis-Backed)

import { redis } from '../redis.js';

const CAPTCHA_PREFIX = 'captcha:';
const CAPTCHA_TTL = 120; // seconds

export async function createCaptcha() {
  const id = randomBytes(16).toString('hex');
  const text = generateCaptchaText(5);
  const svg = generateCaptchaSVG(text);
  await redis.set(CAPTCHA_PREFIX + id, text.toLowerCase(), 'EX', CAPTCHA_TTL);
  return { id, data: svg };
}

export async function verifyCaptcha(captchaId: string, userSolution: string): Promise<boolean> {
  const key = CAPTCHA_PREFIX + captchaId;
  const stored = await redis.get(key);
  if (!stored) return false;
  await redis.del(key); // One-time use
  return stored === userSolution.toLowerCase().trim();
}

Key Changes

  • createCaptcha and verifyCaptcha are now async — all callers must await
  • Redis TTL — automatic expiration after 120 seconds, no manual cleanup
  • One-time use — verifyCaptcha deletes the key after reading (prevents replay)
  • Multi-instance safe — Redis is shared across all server processes

Call Sites Updated

All verifyCaptcha/createCaptcha call sites were updated to use await:

File Change
auth.ts await verifyCaptcha(captchaId, userSolution)
public.ts await verifyCaptcha(...)
signup.ts await verifyCaptcha(captchaId, userSolution)

5b. HTML Sanitization

File: backend/src/lib/sanitize.ts

Created a sanitization module using sanitize-html for comment input.

import sanitizeHtml from 'sanitize-html';

const COMMENT_OPTIONS: sanitizeHtml.IOptions = {
  allowedTags: ['b', 'i', 'em', 'strong', 'a', 'code', 'br'],
  allowedAttributes: {
    a: ['href', 'title'],
  },
  allowedSchemes: ['https'],
  disallowedTagsMode: 'discard',
};

export function sanitizeComment(input: string): string {
  return sanitizeHtml(input, COMMENT_OPTIONS);
}

export function stripHtml(input: string): string {
  return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
}

Allowed Tags in Comments

Tag Purpose
b, i Basic formatting
em, strong Semantic emphasis
a Links (https only)
code Inline code
br Line breaks

Integration in comments.ts

import { sanitizeComment } from '../lib/sanitize.js';

// In comment creation handler:
const body = sanitizeComment(parsed.data.body);

Important: The initial implementation had sanitizeComment(stripHtml(parsed.data.body)) which was incorrect — stripHtml removes ALL HTML first, making sanitizeComment's allowed-tags list completely inert. This was fixed to use sanitizeComment alone, which strips dangerous HTML while preserving safe formatting.


5c. Security Headers Plugin

File: backend/src/plugins/security-headers.ts

Created a Fastify plugin that sets security headers on every response with per-request CSP nonce generation.

import fp from 'fastify-plugin';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { randomBytes } from 'node:crypto';

declare module 'fastify' {
  interface FastifyReply {
    cspNonce: string;
  }
}

function generateNonce(): string {
  return randomBytes(16).toString('base64');
}

export default fp(async function securityHeadersPlugin(app: FastifyInstance) {
  app.decorateReply('cspNonce', '');

  app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
    const nonce = generateNonce();
    reply.cspNonce = nonce;
    reply.header('X-Content-Type-Options', 'nosniff');
    reply.header('X-Frame-Options', 'SAMEORIGIN');
    reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
    reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');

    if (req.url.startsWith('/embed') || req.url === '/embed.html') {
      reply.header('Content-Security-Policy',
        `default-src 'self'; script-src 'self' 'nonce-${nonce}'; ` +
        `style-src 'self' 'unsafe-inline'; frame-ancestors *;`);
    } else {
      reply.header('Content-Security-Policy',
        `default-src 'self'; script-src 'self' 'nonce-${nonce}'; ` +
        `style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; ` +
        `font-src 'self'; connect-src 'self'; frame-ancestors 'self'; ` +
        `form-action 'self';`);
    }

    if (process.env.NODE_ENV === 'production') {
      reply.header('Strict-Transport-Security', 'max-age=604800; includeSubDomains');
    }
  });
});

Security Headers Set

Header Value Purpose
X-Content-Type-Options nosniff Prevent MIME type sniffing
X-Frame-Options SAMEORIGIN Prevent clickjacking (non-embed routes)
Referrer-Policy strict-origin-when-cross-origin Limit referrer information
Permissions-Policy camera=(), microphone=(), geolocation=() Disable sensitive APIs
Content-Security-Policy (varies by route) XSS prevention via nonce-based script allowlist
Strict-Transport-Security max-age=604800; includeSubDomains Force HTTPS (production only)

CSP Policies

Regular routes:

default-src 'self';
script-src 'self' 'nonce-{nonce}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self';
connect-src 'self';
frame-ancestors 'self';
form-action 'self';

Embed routes (/embed/*, /embed.html):

default-src 'self';
script-src 'self' 'nonce-{nonce}';
style-src 'self' 'unsafe-inline';
frame-ancestors *;

The embed routes allow frame-ancestors * so they can be embedded in third-party sites.

Nonce Design

  • Per-request nonce: Generated fresh for each request via randomBytes(16).toString('base64')
  • Stored on reply.cspNonce: Available to any handler that needs to inject nonce-marked scripts
  • Not a shared mutable: The initial implementation used a module-level let currentNonce which was unsafe under concurrent requests. This was fixed to use Fastify's decorateReply for per-request isolation.

5d. Session Fixation Protection

File: backend/src/plugins/session.ts

Added rotateSession() to the session plugin. After successful login and 2FA verification, the session is rotated — the old session ID is deleted from Redis and a new session with a fresh ID is created.

rotateSession Implementation

async function rotateSession(this: FastifyReply, data: {
  userId: string;
  role: SessionData['role'];
}) {
  const req = this.request;
  const oldSid = req.session?.sid;

  // Delete old session from Redis
  if (oldSid) {
    await redis.del(sessionKey(oldSid));
  }

  // Create new session with fresh ID and CSRF token
  const sid = randomBytes(24).toString('hex');
  const csrfToken = randomBytes(24).toString('hex');
  const sessionData: SessionData = {
    userId: data.userId,
    role: data.role,
    csrfToken,
    iat: Math.floor(Date.now() / 1000),
  };

  await redis.set(sessionKey(sid), JSON.stringify(sessionData), 'EX', SESSION_TTL_SECONDS);

  // Set new cookies
  this.setCookie(SESSION_COOKIE, sid, {
    path: '/',
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: SESSION_TTL_SECONDS,
  });
  this.setCookie(CSRF_COOKIE, csrfToken, {
    path: '/',
    httpOnly: false,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: SESSION_TTL_SECONDS,
  });

  // Update request session
  req.session = { sid, ...sessionData };
}

Usage in auth.ts

// After successful login:
if (req.session?.sid) {
  await reply.rotateSession({
    userId: user.id,
    role: user.role as SessionData['role'],
  });
} else {
  await reply.createSession({
    userId: user.id,
    role: user.role as SessionData['role'],
  });
}

// After 2FA verification:
if (req.session?.sid) {
  await reply.rotateSession({
    userId: user.id,
    role: user.role as SessionData['role'],
  });
} else {
  await reply.createSession({
    userId: user.id,
    role: user.role as SessionData['role'],
  });
}

5e. CSRF Rotation

CSRF tokens are automatically rotated when sessions are rotated. Since the CSRF token is stored within the session data in Redis, creating a new session (via rotateSession) generates a new CSRF token. The double-submit pattern (CSRF cookie tek_csrf + session-stored token) ensures:

  1. New session ID — prevents session fixation
  2. New CSRF token — prevents CSRF token reuse from old sessions
  3. Old session deleted — the old tek_sid and tek_csrf values are no longer valid

8. Stale-While-Revalidate Caching

File: backend/src/lib/cache.ts

Rewrote the cache module to support stale-while-revalidate pattern with a grace period.

The Problem

The original cache served cached data until TTL expired, then blocked on a fresh fetch. This caused latency spikes at cache boundaries — the first request after expiry had to wait for the full database query.

The Solution

With stale-while-revalidate, cached data is served even after TTL expires, during a configurable grace period. While stale data is being served, a background fetch refreshes the cache for the next request.

import { redis } from '../redis.js';

export interface CacheOptions {
  ttlSeconds: number;
  graceSeconds?: number;
}

export async function cached<T>(
  key: string,
  ttlSeconds: number | CacheOptions,
  fetcher: () => Promise<T>
): Promise<T> {
  const opts: CacheOptions = typeof ttlSeconds === 'number'
    ? { ttlSeconds }
    : ttlSeconds;

  const graceSeconds = opts.graceSeconds ?? Math.max(Math.floor(opts.ttlSeconds * 0.25), 30);
  const totalTtl = opts.ttlSeconds + graceSeconds;

  const hit = await redis.get(key);
  if (hit !== null) {
    try {
      const parsed = JSON.parse(hit) as T;
      const ttlRemaining = await redis.ttl(key);

      // If within grace period, serve stale while background refreshing
      if (ttlRemaining >= 0 && ttlRemaining <= graceSeconds) {
        fetcher()
          .then((fresh) => {
            if (fresh !== undefined) {
              redis.set(key, JSON.stringify(fresh), 'EX', totalTtl).catch(() => {});
            }
          })
          .catch(() => {});
      }

      return parsed;
    } catch {
      await redis.del(key);
    }
  }

  // Cache miss — fetch and store
  const fresh = await fetcher();
  if (fresh !== undefined) {
    await redis.set(key, JSON.stringify(fresh), 'EX', totalTtl);
  }
  return fresh;
}

export async function bust(pattern: string): Promise<number> {
  let cursor = '0';
  let removed = 0;
  do {
    const [next, batch] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
    cursor = next;
    if (batch.length > 0) {
      removed += await redis.del(...batch);
    }
  } while (cursor !== '0');
  return removed;
}

Grace Period Calculation

graceSeconds = max(ttlSeconds * 0.25, 30)
totalTTL = ttlSeconds + graceSeconds
TTL Grace Period Total Redis TTL
60s 30s 90s
120s 30s 150s
300s 75s 375s
600s 150s 750s
3600s 900s 4500s

Request Flow

Request arrives
  │
  ├─ Cache HIT, TTL > grace → Return cached (fresh)
  │
  ├─ Cache HIT, TTL ≤ grace → Return cached (stale) + background refresh
  │
  ├─ Cache HIT, TTL = -1 (no expiry) → Return cached (never expires)
  │
  └─ Cache MISS → Fetch, store with totalTTL, return fresh

Backward Compatibility

The cached() function accepts both a simple number and a CacheOptions object:

// Old API (still works):
await cached('key', 300, fetcher);

// New API (with explicit grace):
await cached('key', { ttlSeconds: 300, graceSeconds: 60 }, fetcher);

9. Shared Logic Extraction

publish.ts

File: backend/src/lib/publish.ts

Extracted publication-related business logic from admin.ts and public.ts into a shared module.

Exported Functions

Function Signature Description
cleanText (value: unknown) => string Strip HTML, collapse whitespace
truncateText (value: unknown, max: number) => string Truncate with HTML stripping
internalLinkCount (content: string) => number Count internal navigation links
hasPlaceholderContent (content: string) => boolean Detect placeholder/lorem ipsum
publishQualityIssues (article) => string[] Return names of failing quality checks
completeSeoData (input) => SeoData Fill in default SEO metadata

Quality Checks in publishQualityIssues

Check Requirement Weight
titleLength 20 ≤ title.length ≤ 100 Required
metaDescription 80 ≤ length ≤ 160 Required
canonicalPath Starts with /article/ Required
ogImage Local path (not http(s)) Required
category Category slug exists Required
internalLinks ≥ 2 internal links Required
noPlaceholder No placeholder/lorem content Required
noRemoteImages No <img> with remote src Required
sourceNotes Non-empty source notes Required
factChecked Fact-check date exists Required

completeSeoData Defaults

Field Default Persian Default
metaDescription Truncated summary/content —
keywords Category + type + tags + title —
ogTitle truncateText(title, 84) | تکنـاو تکنـاو brand suffix
ogDescription Truncated ogDescription/metaDescription —
ogImage /images/og/{slug}.jpg Local OG image path
canonicalPath /article/{slug} —
sourceNotes Persian default text یادداشت منابع و راستی‌آزمایی...
factCheckedAt new Date() Current time
dateModified new Date() Current time
contentFreshnessStatus 'current' —

persian.ts

File: backend/src/lib/persian.ts

Extracted Persian text normalization and reading streak logic from history.ts and public.ts.

Exported Functions

Function Signature Description
normalizeFa (input: string) => string Normalize Persian text for search
computeStreak (count, lastReadAt, now) => number Calculate consecutive-day streak
currentStreakIfAlive (count, lastReadAt, now) => number Return streak if still alive, else 0

normalizeFa Transformations

export function normalizeFa(input: string) {
  return input
    .replace(/[\u200c\u0640]/g, '')      // Remove ZWNJ and tatweel
    .replace(/[\u064b-\u0652]/g, '')      // Remove diacritics (tashkeel)
    .replace(/[يى]/g, 'ی')               // Arabic yeh → Persian yeh
    .replace(/ك/g, 'ک')                  // Arabic keaf → Persian keaf
    .replace(/[أإآٱ]/g, 'ا')             // Alef variants → bare alef
    .replace(/\s+/g, ' ')                // Collapse whitespace
    .trim()
    .toLowerCase();
}

Streak Logic

export function computeStreak(streakCount: number, lastReadAt: Date | null, now: Date): number {
  if (!lastReadAt) return 1;                    // First read ever
  const diffDays = /* days between lastReadAt and now */;
  if (diffDays === 1) return streakCount + 1;   // Consecutive day
  if (diffDays > 1) return 1;                   // Streak broken, restart
  return streakCount;                           // Same day, no change
}

export function currentStreakIfAlive(streakCount: number, lastReadAt: Date | null, now: Date): number {
  if (!lastReadAt) return 0;                    // Never read
  const diffDays = /* days between lastReadAt and now */;
  if (diffDays > 1) return 0;                   // Streak dead
  return streakCount;                           // Still alive
}

10. Verification Phase

After implementing all steps, a deep verification was performed using two parallel exploration agents — one for backend files and one for frontend files.

Backend Verification Results

Category Count Details
Critical (runtime crash) 3 Zod schema field mismatches in admin.ts
Significant (wrong behavior) 4 Schema gaps, sanitize ordering
Minor 4 Dead code, shared mutable, missing dep
Verified correct 16 All new lib/plugin/route files

Frontend Verification Results

Category Count Details
Critical (broken) 2 Wrong import path, require() in ESM
Non-critical 4 Roundabout path, dead component, unused export
Verified correct 13 All other files pass

Critical Bugs Found

  1. admin.ts — seriesBodySchema field mismatch: body.articleIds referenced but schema defined articles: z.array(z.object({ id, position })). Would crash with undefined.map().
  2. admin.ts — empty uploadBodySchema: z.object({}) stripped all fields, making body.filename, body.dataBase64 always undefined. extname(undefined) would throw.
  3. admin.ts — incomplete userBodySchema: Missing email, username, password fields. usernameFromEmail(undefined) would crash on undefined.split('@').
  4. comments.ts — sanitize ordering: sanitizeComment(stripHtml(body)) stripped all HTML before sanitization could preserve safe tags.
  5. store.jsx — broken import: import from './src/lib/api.js' instead of './api.js'.
  6. ci.mjs — require() in ESM: const { execSync } = require('child_process') in .mjs file.
  7. admin.ts — body.category vs body.categoryId: Schema defined categoryId but code referenced category.

Significant Issues Found

  1. admin.ts — campaignBodySchema missing slug: Referenced in handler but not in schema.
  2. admin.ts — activityExportQuerySchema missing type/userId: Destructured in handler but not in schema.
  3. admin.ts — articleBodySchema missing factCheckedAt/dateModified: Passed to completeSeoData but not in schema.
  4. admin.ts — reviewBodySchema missing reviewerId: Used in upsert but not in schema.
  5. admin.ts — mediaQuerySchema missing q/mimeType: Destructured in handler but not in schema.

Minor Issues Found

  1. errors.ts — RateLimitedError ignored retryAfter: Constructor accepted but never stored it.
  2. security-headers.ts — shared mutable currentNonce: Unsafe under concurrent requests.
  3. package.json — fastify-plugin undeclared: Used but not in dependencies.
  4. seed.ts — dead applyPersianFts() function: Defined but never called after migration.

11. Bug Fix Phase

Critical Fixes

Fix 1: seriesBodySchema — articleIds → articles

File: backend/src/routes/admin.ts

The schema defined articles: z.array(z.object({ id: z.string(), position: z.number() })) but the handlers referenced body.articleIds.

Before (POST series):

articles: { create: body.articleIds.map((articleId, index) => ({
  articleId, position: index + 1
})) },

After:

articles: { create: (body.articles ?? []).map(({ id, position }) => ({
  articleId: id, position
})) },

Before (PATCH series):

if (body.articleIds) {
  await prisma.seriesArticle.deleteMany({ where: { seriesId: existing.id } });
  await prisma.seriesArticle.createMany({
    data: body.articleIds.map((articleId, index) => ({
      seriesId: existing.id, articleId, position: index + 1
    }))
  });
}

After:

if (body.articles) {
  await prisma.seriesArticle.deleteMany({ where: { seriesId: existing.id } });
  await prisma.seriesArticle.createMany({
    data: body.articles.map(({ id, position }) => ({
      seriesId: existing.id, articleId: id, position
    }))
  });
}

Fix 2: uploadBodySchema — Add required fields

Before:

const uploadBodySchema = z.object({});

After:

const uploadBodySchema = z.object({
  filename: z.string().min(1),
  dataBase64: z.string().min(1),
  mimeType: z.string().optional(),
  webp: z.boolean().optional(),
});

Fix 3: userBodySchema — Add email, username, password

Before:

const userBodySchema = z.object({
  name: z.string().trim().min(1),
  role: z.enum(['admin', 'editor', 'writer', 'reviewer', 'reader']).optional(),
  status: z.enum(['active', 'suspended', 'pending']).optional(),
  bio: z.string().trim().max(280).optional(),
});

After:

const userBodySchema = z.object({
  name: z.string().trim().min(1),
  email: z.string().email(),
  username: z.string().trim().optional(),
  password: z.string().min(6).optional(),
  role: z.enum(['admin', 'editor', 'writer', 'reviewer', 'reader']).optional(),
  status: z.enum(['active', 'suspended', 'pending']).optional(),
  bio: z.string().trim().max(280).optional(),
});

Note: email is required (not optional) because usernameFromEmail(body.email) is called unconditionally.


Fix 4: comments.ts — Correct sanitize ordering

Before:

import { sanitizeComment, stripHtml } from '../lib/sanitize.js';
const body = sanitizeComment(stripHtml(parsed.data.body));

After:

import { sanitizeComment } from '../lib/sanitize.js';
const body = sanitizeComment(parsed.data.body);

sanitizeComment alone is correct — it strips dangerous HTML while preserving safe formatting (b, i, em, strong, a, code, br). Calling stripHtml first would remove ALL HTML, making the allowed-tags list in sanitizeComment completely inert.


Fix 5: store.jsx — Fix import path

Before:

import { api, ApiError } from './src/lib/api.js';

After:

import { api, ApiError } from './api.js';

The file is at src/lib/store.jsx, so ./src/lib/api.js would resolve to src/lib/src/lib/api.js which doesn't exist.


Fix 6: ci.mjs — Fix require() in ESM

Before:

const { execSync } = require('child_process');

After:

import { execSync } from 'node:child_process';

The .mjs extension makes this an ES module where require is not defined.


Fix 7: admin.ts — body.category → body.categoryId

Three occurrences in admin.ts:

Create handler (line ~816):

// Before: prisma.category.findUnique({ where: { slug: body.category } })
// After:  prisma.category.findUnique({ where: { slug: body.categoryId } })

Update handler (line ~898):

// Before: body.category ? await prisma.category.findUnique({ where: { slug: body.category } })
// After:  body.categoryId ? await prisma.category.findUnique({ where: { slug: body.categoryId } })

Category connect (line ~939):

// Before: if (body.category) {
// After:  if (body.categoryId) {

Quality Fixes

Fix 8: campaignBodySchema — Add slug field

Before:

const campaignBodySchema = z.object({
  subject: z.string().trim().min(1).max(200),
  bodyHtml: z.string().min(1),
});

After:

const campaignBodySchema = z.object({
  subject: z.string().trim().min(1).max(200),
  bodyHtml: z.string().min(1),
  slug: z.string().trim().optional(),
});

Fix 9: activityExportQuerySchema — Add type and userId

Before:

const activityExportQuerySchema = z.object({
  from: z.string().optional(),
  to: z.string().optional(),
});

After:

const activityExportQuerySchema = z.object({
  from: z.string().optional(),
  to: z.string().optional(),
  type: z.string().optional(),
  userId: z.string().optional(),
});

Fix 10: articleBodySchema — Add factCheckedAt and dateModified

Before:

// (not present in schema)

After:

factCheckedAt: z.coerce.date().optional().nullable(),
dateModified: z.coerce.date().optional().nullable(),

Using z.coerce.date() to parse ISO date strings from the request body into Date objects, matching the Date | null types expected by completeSeoData.


Fix 11: reviewBodySchema — Add reviewerId

Before:

const reviewBodySchema = z.object({
  status: z.enum(['approved', 'revision_requested', 'rejected']),
  note: z.string().trim().max(1000).optional(),
});

After:

const reviewBodySchema = z.object({
  status: z.enum(['approved', 'revision_requested', 'rejected']),
  note: z.string().trim().max(1000).optional(),
  reviewerId: z.string().optional(),
});

Fix 12: mediaQuerySchema — Add q and mimeType

Before:

const mediaQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().positive().max(100).default(40),
  type: z.string().optional(),
});

After:

const mediaQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().positive().max(100).default(40),
  type: z.string().optional(),
  q: z.string().optional(),
  mimeType: z.string().optional(),
});

Fix 13: wireTags — Handle undefined tags

Before:

await wireTags(row.id, body.tags);

After:

await wireTags(row.id, body.tags ?? []);

body.tags is string[] | undefined from the schema, but wireTags expects string[].


Fix 14: RateLimitedError — Store retryAfter

Before:

export class RateLimitedError extends AppError {
  constructor(retryAfter?: number) {
    super(429, 'rate_limited', 'تعداد درخواست‌ها از حد مجاز گذشت');
  }
}

After:

export class RateLimitedError extends AppError {
  public readonly retryAfter?: number;
  constructor(retryAfter?: number) {
    super(429, 'rate_limited', 'تعداد درخواست‌ها از حد مجاز گذشت');
    this.retryAfter = retryAfter;
  }

  send(reply: FastifyReply) {
    if (this.retryAfter) reply.header('Retry-After', this.retryAfter);
    return reply.code(this.statusCode).send({
      error: this.code,
      message: this.message,
      retryAfter: this.retryAfter
    });
  }
}

Fix 15: security-headers.ts — Per-request nonce

Before (shared mutable):

export let currentNonce = '';

app.addHook('onRequest', async (req, reply) => {
  currentNonce = generateNonce();
  // ... uses currentNonce in CSP header
});

After (per-request decorator):

declare module 'fastify' {
  interface FastifyReply {
    cspNonce: string;
  }
}

app.decorateReply('cspNonce', '');

app.addHook('onRequest', async (req, reply) => {
  const nonce = generateNonce();
  reply.cspNonce = nonce;
  // ... uses nonce in CSP header
});

This ensures each request gets its own nonce that can't be overwritten by concurrent requests.


Fix 16: seed.ts — Remove dead applyPersianFts()

Removed the entire applyPersianFts() function definition from seed.ts. The function was no longer called (FTS setup is now handled by migration 00000000000002_persian_fts), so the 33-line function body was dead code.


Fix 17: public.ts — Remove unnecessary type cast

Before:

const total = await prisma.companyProfile.count({
  where: where as Parameters<typeof prisma.companyProfile.count>[0]['where']
});

After:

const total = await prisma.companyProfile.count({ where });

The type cast was causing a TS2339 error. Removing it lets TypeScript infer the type naturally.


Fix 18: package.json — Add fastify-plugin dependency

Before: fastify-plugin was used but not declared (resolved via transitive dependency from fastify).

After:

{
  "dependencies": {
    "fastify": "^4.28.1",
    "fastify-plugin": "^4.0.0",
    // ...
  }
}

This makes the dependency explicit and prevents breakage if Fastify changes its transitive dependencies.


12. Final Validation

After all fixes, every validation check passed:

TypeScript Compilation

cd backend && npx tsc --noEmit
# (no output — zero errors)

Before fixes: 49 errors (37 TS2339, 7 TS2551, 4 TS7006, 1 TS2345) After fixes: 0 errors

Backend Unit Tests

cd backend && node --test tests/unit-logic.test.mjs
# Tests 47/47 passed
# Duration: 126ms

Frontend Vitest Tests

npx vitest run
# Test Files  2 passed (2)
#      Tests  11 passed (11)
#   Duration  3.36s

CI Pipeline

node scripts/ci.mjs
# ▶ Backend TypeScript build… ✓
# ▶ Backend unit tests… ✓
# ▶ Frontend Vitest tests… ✓
# (continues with build checks)

Vite Production Build

npx vite build
# ✓ built in 4.68s
# dist/assets/vendor-react-CPqEaazJ.js     137.52 kB
# dist/assets/main-B_9xixIC.js             298.52 kB
# dist/assets/teknav-data-BpFCL8D9.js      359.88 kB
# dist/assets/vendor-three-Bzpp9od8.js     461.76 kB

Summary

Check Before After
TypeScript errors 49 0
Backend unit tests 47/47 47/47
Frontend Vitest 11/11 11/11
Vite build ✓ ✓
CI pipeline N/A ✓

13. File Inventory

New Files Created

File Lines Purpose
backend/src/lib/publish.ts 83 Publication quality checks and SEO data completion
backend/src/lib/persian.ts 34 Persian text normalization and reading streaks
backend/src/lib/errors.ts 69 Structured error class hierarchy
backend/src/lib/sanitize.ts 18 HTML sanitization for comments
backend/src/lib/error-digest.ts 48 Daily client error digest email
backend/src/plugins/request-id.ts 24 Request tracing via x-request-id
backend/src/plugins/security-headers.ts 36 Security headers + CSP nonce
backend/src/routes/metrics.ts 62 Prometheus metrics endpoint
backend/tests/unit-logic.test.mjs 349 47 unit tests for pure logic
backend/tests/integration.test.mjs 170 Integration tests for API endpoints
backend/prisma/migrations/00000000000002_persian_fts/migration.sql 31 Persian FTS migration
backend/prisma/migrations/00000000000003_soft_deletes/migration.sql 10 Soft delete columns + indexes
src/lib/store.jsx 46 React context auth store
src/lib/api.test.js 74 Vitest tests for API client
src/lib/content-api.test.js 71 Vitest tests for content API
src/test/setup.js 1 Vitest setup file
vitest.config.js 12 Vitest configuration
scripts/ci.mjs 46 CI pipeline script
src/pages/index.js 27 Page components barrel
src/admin/index.js 3 Admin components barrel
src/pages/*.jsx ~1,957 27 split page components
src/admin/*.jsx ~3,202 12 split admin components

Modified Files

File Change Summary
backend/src/lib/cache.ts Rewritten with stale-while-revalidate grace period
backend/src/lib/captcha.ts Rewritten Redis-backed (async createCaptcha/verifyCaptcha)
backend/src/plugins/session.ts Added rotateSession() for fixation protection
backend/src/routes/admin.ts Complete Zod schema fixes, imports from publish.ts
backend/src/routes/auth.ts await verifyCaptcha, rotateSession on login/2FA
backend/src/routes/comments.ts sanitizeComment integration
backend/src/routes/public.ts normalizeFa import, await verifyCaptcha, count fix
backend/src/routes/history.ts computeStreak/currentStreakIfAlive from persian.ts
backend/src/routes/signup.ts await verifyCaptcha
backend/src/server.ts Register requestIdPlugin, securityHeadersPlugin, metricsRoutes
backend/prisma/schema.prisma deletedAt on Article/Comment, publishedAt index
backend/prisma/seed.ts Removed applyPersianFts() function
backend/package.json Added fastify-plugin dependency
teknav-ui.jsx Removed hashchange listener
teknav-pages.jsx Re-exports from src/pages/index.js
teknav-admin.jsx Re-exports from src/admin/index.js
src/lib/content-api.js Rewritten without seed-data fallback
package.json Added test, test:watch, ci scripts

14. Key Decisions & Rationale

Unit Test Strategy: Inline Functions

Decision: Duplicate pure functions inline in test files instead of importing from TypeScript modules.

Rationale: The backend uses TypeScript (.ts) but the test runner is node:test which doesn't support TS imports natively. Building a separate test compilation pipeline would add complexity. Since the tested functions are pure (no side effects, no dependencies), duplicating them is simple and reliable. The functions are small (< 20 lines each) and change infrequently.

Trade-off: If the source functions change, tests must be updated manually. This is acceptable because the functions are stable business logic.


Content API: No Seed Fallback

Decision: Remove all TeknavData/TeknavStore fallback merging from content-api.js.

Rationale: Silent fallbacks mask API failures. When the backend is down, users should see an explicit error state, not stale seed data that looks real but isn't. This forces the UI layer to implement proper loading/error states.

Trade-off: During development, if the backend isn't running, the frontend shows errors instead of seed data. This is acceptable because the dev workflow requires the backend to be running (per AGENTS.md).


CAPTCHA: Redis Over In-Memory Map

Decision: Move CAPTCHA store from in-memory Map to Redis.

Rationale: In-memory Maps grow without bound if entries aren't verified (bots submitting createCaptcha but never verifyCaptcha). Redis TTL provides automatic cleanup. Redis is also shared across multiple server instances.

Trade-off: CAPTCHA verification now requires a Redis round-trip (~1ms). This is negligible compared to the Argon2id password hashing that follows.


Session Rotation: Full Replacement

Decision: After login/2FA, delete the old session and create a completely new one (new session ID + new CSRF token).

Rationale: Session fixation attacks work by forcing a victim to use a session ID chosen by the attacker. If the session ID doesn't change after authentication, the attacker can use the pre-auth session ID to access the victim's authenticated session. Rotating the session ID after login breaks this attack vector.

Trade-off: Any in-flight requests with the old session ID will fail. This is acceptable because login is a user-initiated action with a clear state transition.


Grace Period: max(TTL * 0.25, 30)

Decision: Default grace period is 25% of TTL with a 30-second minimum.

Rationale: Short TTLs (e.g., 60s) need a minimum grace period to be useful. 30 seconds ensures at least one request can trigger a background refresh. For longer TTLs, 25% provides proportional grace without excessive staleness.

Trade-off: Data served during the grace period may be up to graceSeconds stale. For article content, this is acceptable. For real-time data like visitor counts, callers can use a shorter TTL or no cache.


Security Headers: Flexible, Not Strict

Decision: CSP allows 'unsafe-inline' for styles and uses nonce-based script allowlist rather than strict-dynamic.

Rationale: The existing codebase uses inline styles extensively (React's CSS-in-JS patterns). Enforcing strict CSP would require rewriting all styling. The nonce-based script allowlist prevents XSS while allowing the current styling approach. Step 5 was specified as "flexible, not too strict."

Trade-off: Inline styles are not protected by CSP. This is a known limitation that can be addressed incrementally by migrating to nonce-based or hash-based styles.


Soft Deletes: Explicit Over Middleware

Decision: Use deletedAt DateTime? with explicit WHERE deletedAt IS NULL filtering instead of Prisma middleware.

Rationale: Prisma middleware that automatically adds where: { deletedAt: null } to every query is magical and can cause surprising behavior (e.g., admin queries that need to see deleted records). Explicit filtering makes the intent clear at each query site.

Trade-off: Every query that should exclude soft-deleted records must include the filter explicitly. This is more verbose but more transparent.


Module Splits: Barrel Re-exports

Decision: Split large files into smaller modules with barrel index.js re-exports, and update the original files to re-export from the barrel.

Rationale: This preserves existing import paths (e.g., teknav-app.jsx still imports from teknav-pages.jsx) while allowing internal code to live in separate files. No changes needed in consuming modules.

Trade-off: There's an extra level of indirection (original file → barrel → individual module). The impact on bundle size is negligible because Vite tree-shakes unused exports.


15. Lessons Learned

Zod Schemas Must Match Handler Usage

The most common class of bugs was Zod schemas that didn't include all fields accessed by their handlers. When Zod's safeParse strips unknown keys (the default behavior), any field not in the schema becomes undefined after parsing. This silently breaks handlers that expect those fields.

Prevention: For each Zod schema, verify that every field accessed in the corresponding handler is present in the schema. Consider adding a test that creates a valid input object, parses it through the schema, and asserts all expected fields are present.


Sanitize-Then-Strip Is Redundant

Composing sanitizeComment(stripHtml(input)) is always wrong because stripHtml removes all HTML first, making sanitizeComment's allowed-tags list inert. The correct pattern is either:

  • sanitizeComment(input) — allow safe HTML
  • stripHtml(input) — plain text only

Choose one based on the requirements.


ESM Files Must Use Import

Using require() in .mjs files throws ReferenceError: require is not defined. This is a basic ESM constraint but easy to miss when converting scripts from CommonJS.


Shared Mutable State in Fastify Plugins Is Unsafe

Module-level let variables (like export let currentNonce = '') are shared across all requests in the same process. Under concurrent requests, one request's onRequest hook can overwrite the value before another request reads it. Use Fastify's decorateRequest/decorateReply for per-request state.


Import Paths Are Relative to the File

An import like import from './src/lib/api.js' in a file at src/lib/store.jsx resolves to src/lib/src/lib/api.js, not src/lib/api.js. This is a basic module resolution rule but easy to get wrong when creating new files in existing directories.


Type Casts Can Hide Real Problems

The as Parameters<typeof prisma.companyProfile.count>[0]['where'] cast in public.ts was causing a TS2339 error because the cast itself was invalid. Removing the cast and letting TypeScript infer the type naturally resolved the error. Prefer letting TypeScript infer types over using casts, especially with Prisma's generated types.


Dead Code Should Be Removed

The applyPersianFts() function in seed.ts was defined but never called after the FTS setup was moved to a migration. Leaving dead code in the codebase is confusing — it looks like it should be called but isn't. Remove dead code when the functionality is moved elsewhere.


Appendix A: Complete Zod Schema Reference

After all fixes, the complete set of Zod schemas in admin.ts:

const articleBodySchema = z.object({
  title: z.string().trim().min(3).max(200),
  subtitle: z.string().trim().max(300).optional(),
  summary: z.string().trim().min(10).max(500),
  content: z.string().min(1),
  type: z.string().min(1),
  status: z.string().optional(),
  categoryId: z.string().min(1),
  authorId: z.string().min(1),
  tags: z.array(z.string()).optional(),
  featured: z.coerce.boolean().optional(),
  sponsored: z.coerce.boolean().optional(),
  premiumOnly: z.coerce.boolean().optional(),
  metaDescription: z.string().trim().max(160).optional(),
  keywords: z.array(z.string()).optional(),
  ogTitle: z.string().trim().max(200).optional(),
  ogDescription: z.string().trim().max(300).optional(),
  ogImage: z.string().trim().optional(),
  canonicalPath: z.string().trim().optional(),
  scheduledAt: z.string().optional(),
  reviewedById: z.string().optional(),
  sourceNotes: z.string().optional(),
  contentFreshnessStatus: z.string().optional(),
  correctionNotice: z.string().optional(),
  readTime: z.number().optional(),
  diagram: z.string().optional(),
  slug: z.string().optional(),
  factCheckedAt: z.coerce.date().optional().nullable(),
  dateModified: z.coerce.date().optional().nullable(),
});

const categoryBodySchema = z.object({
  name: z.string().trim().min(1),
  slug: z.string().trim().min(1),
  description: z.string().trim().min(1),
  color: z.string().trim().min(1),
  diagram: z.string().optional(),
});

const tagBodySchema = z.object({
  name: z.string().trim().min(1),
});

const userBodySchema = z.object({
  name: z.string().trim().min(1),
  email: z.string().email(),
  username: z.string().trim().optional(),
  password: z.string().min(6).optional(),
  role: z.enum(['admin', 'editor', 'writer', 'reviewer', 'reader']).optional(),
  status: z.enum(['active', 'suspended', 'pending']).optional(),
  bio: z.string().trim().max(280).optional(),
});

const scheduleBodySchema = z.object({
  scheduledAt: z.string().min(1),
});

const correctionBodySchema = z.object({
  note: z.string().trim().min(1).max(1000),
});

const campaignBodySchema = z.object({
  subject: z.string().trim().min(1).max(200),
  bodyHtml: z.string().min(1),
  slug: z.string().trim().optional(),
});

const seriesBodySchema = z.object({
  title: z.string().trim().min(1).max(200),
  slug: z.string().trim().min(1),
  description: z.string().trim().max(500).optional(),
  coverImage: z.string().trim().optional(),
  articles: z.array(z.object({ id: z.string(), position: z.number() })).optional(),
});

const reviewBodySchema = z.object({
  status: z.enum(['approved', 'revision_requested', 'rejected']),
  note: z.string().trim().max(1000).optional(),
  reviewerId: z.string().optional(),
});

const activityExportQuerySchema = z.object({
  from: z.string().optional(),
  to: z.string().optional(),
  type: z.string().optional(),
  userId: z.string().optional(),
});

const mediaQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().positive().max(100).default(40),
  type: z.string().optional(),
  q: z.string().optional(),
  mimeType: z.string().optional(),
});

const uploadBodySchema = z.object({
  filename: z.string().min(1),
  dataBase64: z.string().min(1),
  mimeType: z.string().optional(),
  webp: z.boolean().optional(),
});

Appendix B: Server Registration Order

The new plugins and routes registered in server.ts:

// Plugins (registered before routes)
app.register(requestIdPlugin);        // x-request-id tracing
app.register(securityHeadersPlugin);   // Security headers + CSP nonce

// Routes (registered with other routes)
app.register(metricsRoutes);          // GET /api/metrics (Prometheus)

All other route registrations remain unchanged. The registration order ensures:

  1. requestIdPlugin runs first (onRequest hook assigns ID)
  2. securityHeadersPlugin runs second (onRequest hook sets headers)
  3. Route handlers execute with req.requestId and reply.cspNonce available

Appendix C: Migration Application

To apply the migrations to a running PostgreSQL instance:

cd backend

# Generate Prisma client (after schema changes)
npm run prisma:generate

# Apply migrations (production)
npm run prisma:deploy
# Or: npx prisma migrate deploy

# Apply schema (development — auto-detects migrations vs db push)
npm run prisma:apply

The apply-schema.mjs script automatically uses prisma migrate deploy when the migrations directory exists, falling back to prisma db push --skip-generate when it doesn't.


Appendix D: Test Execution Reference

# Backend unit tests (no server required)
cd backend && node --test tests/unit-logic.test.mjs

# Backend integration tests (auto-skips if no server)
cd backend && node --test tests/integration.test.mjs

# Backend integration tests (strict — fails if no server)
cd backend && TEST_REQUIRE_SERVER=1 node --test tests/integration.test.mjs

# Frontend Vitest (single run)
npm test

# Frontend Vitest (watch mode)
npm run test:watch

# Full CI pipeline
npm run ci

# TypeScript type-check
cd backend && npx tsc --noEmit

# Vite production build
npx vite build

Appendix E: Backup Reference

Item Value
Backup destination coaxys-cf:coaxys-cf/Backup/teknav-app-20260601-105218
Files backed up 3,593
Total size 1.67 GiB
Storage backend S3-compatible (Cloudflare R2 via rclone)
Restore command rclone copy coaxys-cf:coaxys-cf/Backup/teknav-app-20260601-105218 /srv/teknav/app-restore

End of session report. All changes verified: 0 TS errors, 47/47 unit tests, 11/11 Vitest tests, Vite build succeeds.