Files
anwar-pi-website/ARCHITECTURE.md
T
AHMED 26d662ed7c fix: revert to next 15.4.11 (payload peer ceiling), record CVE risk acceptance
@payloadcms/next@3.90.2 peer-requires next <15.5.0; 15.5.27 was an
unsupported combo. Owner chose the pin + documented risk (SECURITY.md
§8). Transitive overrides (undici/sharp/postcss/sass) stay.
Gate skipped: parallel session WIP still in tree.
2026-10-13 06:02:15 +03:00

13 KiB

ARCHITECTURE — anwarpy.ai

Source of truth for structure and boundaries. Change this file with the code, in the same PR.

1. Stack (pinned 2026-10-11)

Layer Choice Version
Framework Next.js App Router (server components first) next@15.4.11 — ceiling of @payloadcms/next peer support; known CVEs risk-accepted (SECURITY.md §8)
CMS / API / admin Payload 3 (Next-native, Lexical rich text) ^3.60.0
Database PostgreSQL postgres:16 (container)
DB adapter @payloadcms/db-postgres (Drizzle migrations) ^3.60.0
UI runtime React ^19.2.0
Styling Hand-rolled CSS (src/app/design.css + per-route CSS). No Tailwind, no component library. —
Payments stripe ^18.5.0 (scaffolded, gated by billingEnabled())
Email Cloudflare Email REST API (ADR-0003) — never SMTP —
Language TypeScript strict ^5.9.0
Deploy Docker Compose on VPS root@147.79.102.201:/root/anwarpi, Traefik proxy network, apex anwarpy.ai (www 308) —

2. Repository layout

/                              docs + specs (PRD.md, ARCHITECTURE.md, docs/adr/, docs/launch/)
web/                           the app — everything below lives here
  src/app/
    (frontend)/                public site routes (about, articles, catalog, posts, topics,
                               subscription, signup, login, verify-email, onboarding,
                               account-settings, payment-*, terms, privacy)
    api/                       route handlers (see §5)
    design.css                 design tokens — single source of visual truth
    layout.tsx                 fonts (Readex Pro), metadata templates
  src/collections/             Payload collections = the schema (see DATABASE.md)
  src/lib/                     shared modules (see §4)
  src/migrations/              generated Drizzle migrations
  src/middleware.ts            rate limiting on /api/users (login/lockout fidelity)
  scripts/                     backup-db.sh, backfill-reading-minutes.ts, generate-covers.mjs, seed.ts
docker-compose.vps.yml         prod topology (db, web, volumes, proxy network)

Rules: root holds docs only; the app never imports from outside web/; docs are committed with the change they describe.

3. The one decision that rules everything: effective access

launch.ts (policy)  +  doc.access + doc.status  →  effective access
  • src/lib/launch.ts is the only place that reads LAUNCH_MODE. billingEnabled(), freeEarlyAccess(), READING_WORDS_PER_MINUTE = 160.
  • Posts.ts field-level read ACL on content (public iff status=published && (access=free || freeEarlyAccess())) — and the same decision is consumed independently by:
    • the article page (posts/[slug]/page.tsx) → full body vs H2-preview paywall,
    • the public post JSON route,
    • the download gate (Files.ts per-file access),
    • llms.ts (fetchIndexDocs(withContent)).
  • If you touch access logic, update all surfaces or extract a shared function first. Ghost-read (logged-out VIP post) is the acceptance check.

4. Module boundaries (src/lib/)

Module Owns Must not
launch.ts LAUNCH_MODE policy, WPM constant read env elsewhere for launch state
viewer.ts getViewer() — session → {email, role, id, interest?, level?, emailVerified?, onboarded?} touch collections directly beyond auth
lexical.ts Lexical serialization: lexicalToText, countLexicalWords (walks content.root!), extractH2s, lexicalToPlain know about Posts/DB
secret-scan.ts scanForSecrets/scanAgentPayload — pattern names only in responses log or return matched secret text
llms.ts llms.txt/full rendering, fetchIndexDocs(withContent) bypass the access decision
topics.ts TOPICS registry (agents/models/automation/foundations), topicsFor(), isTopic() hardcode slugs elsewhere
format.ts Arabic number/label agreement (guidesLabel, minutesLabel, MEMBERSHIP_PRICE) duplicate Arabic plurals inline
mail.ts Cloudflare Email REST send use SMTP; log recipient addresses
agent-media.ts / agent-files.ts pipeline ingestion of media/attachments accept access from the payload
next.ts shared Next helpers —

Import rule: pages/components may import lib + payload; lib modules never import each other's internals — if two libs need shared logic, promote it to the lower-level module.

5. API surface & communication patterns

Route handlers (src/app/api/**/route.ts) return NextResponse.json. Every error body is {"error": "<real Arabic sentence>"} — honest, specific, never a bare code.

Route Auth Notes
POST /api/agent/posts, PUT /api/agent/posts/[slug] x-agent-key header pipeline; 401 bad key, 400 shape, 409 slug conflict (after sourceId dedup), 422 secret scan
GET /api/posts/[slug] public JSON honoring the access decision
POST /api/posts/[slug]/comments session required server-forced author; moderated status (ADR-0001/0002)
POST /api/posts/[slug]/like session required one per user per post
POST /api/posts/[slug]/view public anonymous view counter
POST /api/notify public always records; honest 503 when mail transport down; dup → alreadySubscribed
POST /api/subscribe public 503 honest Arabic while billing disabled
POST /api/users, /api/auth/verify-email, /api/auth/resend-verification public / token verification-token writes at signup; middleware rate-limits this path
GET /api/files/[id] per-file access gate streams attachments
POST /api/stripe/webhook signature no-op while billingEnabled() is false

Data access patterns:

  • Server components use the Payload Local API (payload.find, payload.findByID) — never internal fetch to own routes.
  • Client components are thin: forms POST to route handlers and render the returned Arabic message. No client data-fetching library, no global store; if a page needs reactive data, it re-fetches via a route handler or uses Payload's REST with credentials.
  • Generated types: npm run generate:types (runs as part of build) — collections' Collection types come from payload-types.ts, never hand-written interfaces for docs.

6. Rendering & state

  • Server components by default. 'use client' only for: forms (signup, notify, comments, account settings), small interactive bits (menu, like button). Check before adding more.
  • State management = URL + server. No Redux/Zustand/Jotai. Session state = getViewer(); launch state = launch.ts; content state = Payload. The only client state is form-local.
  • No render-time nondeterminism in components that render on the server: no Date.now() in bodies that should be static, no unseeded randomness.

7. Deployment topology

Internet → Traefik (proxy net) → web (Next standalone, port 3000) → db (postgres:16, internal only)
  • docker-compose.vps.yml services: db (postgres:16, volume db-data, no published ports), web (volume media-uploads, files-uploads), Traefik labels for apex + www → apex 308 (priority above API router).
  • LAUNCH_MODE=free_early_access lives in /root/anwarpi/.env.production — compose passes it; the file is never committed.
  • Migrations apply on container boot. Deploy = rsync web/ (excludes node_modules .next .git media files-private '.env*' '*.tsbuildinfo', no --delete) → docker compose -f docker-compose.vps.yml up -d --build.
  • Prod DB access for scripts: the standalone image has no scripts/; open an SSH tunnel to the db container IP and run npx tsx locally (see AGENTS.md rulebook).
  • Rollback: previous image + docker compose up -d; DB restores come from backup-db.sh dumps (rehearsed 2026-10-10).
  • Anticipated: Railway move (owner, 2026-10-11). Keep the app deployable there without redesign: all config via env vars (already true), no host-local state in the container (media/files volumes must move to object storage / managed volume; DB → managed Postgres), Traefik labels replaced by Railway's router + custom domain. Any change that deepens VPS coupling (cron on host, local disk caches) must be flagged in the PR that adds it.

8. Decision records

Architecture decisions live in docs/adr/NNNN-*.md (current: 0001 comments route, 0002 moderated comments/server-forced author, 0003 Cloudflare Email REST not SMTP, 0004 coming-soon subscribe at public launch, 0005 member trust model). New decisions that change structure, contracts, or policy get a numbered ADR — one file, context + decision + consequences.