# 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 | | 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": ""}` — 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.