8.5 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 |
| 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()) |
| 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.tsis the only place that readsLAUNCH_MODE.billingEnabled(),freeEarlyAccess(),READING_WORDS_PER_MINUTE = 160.Posts.tsfield-levelreadACL oncontent(public iffstatus=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.tsper-file access), llms.ts(fetchIndexDocs(withContent)).
- the article page (
- 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 ofbuild) — collections'Collectiontypes come frompayload-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.ymlservices:db(postgres:16, volumedb-data, no published ports),web(volumemedia-uploads,files-uploads), Traefik labels for apex +www → apex 308(priority above API router).LAUNCH_MODE=free_early_accesslives in/root/anwarpi/.env.production— compose passes it; the file is never committed.- Migrations apply on container boot. Deploy = rsync
web/(excludesnode_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 runnpx tsxlocally (see AGENTS.md rulebook). - Rollback: previous image +
docker compose up -d; DB restores come frombackup-db.shdumps (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.