Docs: new README (Arabic flows, screenshots, architecture) + AGENT.md for AI agents

- README rewritten for the real Next.js + Payload + Postgres stack
  (old one described the dead static prototype)
- Logo + 4 live screenshots (home, articles, post, subscription)
- Arabic flow explanations with mermaid diagrams: visitor journey,
  content pipeline, request path
- Architecture table, quick start, API reference, security summary,
  deploy commands
- AGENT.md: orientation for future AI agents (data model, trust rules,
  rate limits, routes, env, deploy, house rules)
- GLOSSARY.md and docs/adr committed (linked from README)
This commit is contained in:
AHMED
2026-10-09 17:23:14 +03:00
parent e69e419721
commit 165efc3269
12 changed files with 507 additions and 21 deletions
@@ -0,0 +1,21 @@
# ADR 0001 — Comments endpoint lives at /api/posts/[slug]/comments, not /api/comments
Date: 2026-10-03
## Status
Accepted
## Context
The comments API needs a route. The conventional-looking path `/api/comments` collides with Payload: it auto-mounts REST CRUD for every collection at `/api/<slug>`. A `comments` collection therefore already serves `/api/comments` for the admin panel, and moderation (approve/reject) runs through that panel. An app-router route at the same path would shadow and break the panel's CRUD.
## Decision
Comments are created at `POST /api/posts/[slug]/comments` (`src/app/api/posts/[slug]/comments/route.ts`), matching the existing `/api/posts/[slug]/like` pattern. Admin moderation keeps using Payload's own REST/admin panel.
## Tests for a real ADR
- **Hard to reverse**: the endpoint is a public contract consumed by the client form; moving it breaks every deployed client.
- **Surprising**: the collision with Payload's auto-mounted REST is non-obvious — a developer would naturally reach for `/api/comments`.
- **Real trade-off**: a shorter conventional path vs. a working admin moderation panel.
@@ -0,0 +1,25 @@
# ADR 0002 — Moderated comments with server-forced author
Date: 2026-10-03
## Status
Accepted
## Context
Comments are user-generated content on a public site. Risks: identity spoofing (a request forging `author`), spam (no gate), and orphaned identity data (nested user lookups through restrictive read access). QA-D04 required a real backend for comments.
## Decision
- Comments default to `status: 'pending'`; only `approved` are publicly visible. The read access rule returns `{ status: { equals: 'approved' } }` for non-admins.
- The author is forced server-side on create, twice: a `beforeChange` hook on the collection and the custom route — REST-created comments can never spoof identity.
- `authorName` is denormalized at submit time (snapshot of `fullName`, else «عضو») to avoid nested user-read access issues when rendering; the email is never exposed.
- Approval/rejection is admin-only, done in the Payload admin panel. Authors cannot edit or delete their comments (moderated model).
- Authors see their own pending comments rendered on the post page tagged «قيد المراجعة» (via `overrideAccess` query filtered to their own author id), so a submitted comment does not "vanish".
## Tests for a real ADR
- **Hard to reverse**: the display-name snapshot and status lifecycle are visible in every rendered comment and in the DB.
- **Surprising**: forcing author in two places (hook + route) and `overrideAccess` for the pending query are non-obvious.
- **Real trade-off**: moderation latency for users vs. spam safety; denormalization vs. live join through restricted user reads.
@@ -0,0 +1,25 @@
# ADR 0003 — Cloudflare Email Sending REST, not SMTP
Date: 2026-10-03
## Status
Accepted
## Context
The site needs exactly one transactional email: the verification mail sent by `POST /api/auth/resend-verification` via `sendVerificationEmail(to, token)` in `src/lib/mail.ts`. The first implementation used nodemailer over generic SMTP. Cloudflare Email Service (the mail provider chosen for anwarpy.ai) has **no SMTP interface** — it exposes a REST endpoint only — so the nodemailer path could never be activated on the target host.
## Decision
- `src/lib/mail.ts` sends via `POST https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/email/sending/send` using `fetch`, authenticated with `Authorization: Bearer ${CLOUDFLARE_API_TOKEN}`.
- The `from` field uses the REST shape `{ address, name }` (key is `address`, **not** `email`); default address `noreply@anwarpy.ai` (override: `MAIL_FROM`).
- Active only when `CLOUDFLARE_API_TOKEN` **and** `CLOUDFLARE_ACCOUNT_ID` are set; otherwise `{ sent: false }` (existing degrade-gracefully contract — the resend route answers 503 with a clear message).
- Non-OK response or fetch throw: `console.error` the error body, return `{ sent: false }`. No retry logic (single-shot; the resend route re-invokes).
- nodemailer is uninstalled; the exported signature `sendVerificationEmail(to: string, token: string): Promise<{ sent: boolean }>` is unchanged.
## Consequences
- The API token needs the Email Sending permission; the sender domain must be onboarded in Cloudflare (SPF/DKIM records created via `wrangler email sending dns get`).
- `smtpConfigured()` and all `SMTP_*` env vars are gone; anything expecting them must be updated when mail features grow.
- Adding more email types later means extending this one module — it is the single mail boundary.
@@ -0,0 +1,28 @@
# ADR 0004 — Coming-soon subscribe on public launch (no simulated VIP)
Date: 2026-10-03
## Status
Accepted
## Context
Until Stripe keys exist, the subscribe flow had two self-grant holes that are fatal on a public site:
1. `POST /api/subscribe` (no keys) returned `{ url: '/payment-success?simulated=1' }`.
2. `/payment-success` without a `session_id` set `activate = true` — i.e. any logged-in user visiting the URL became `role: 'vip'` for free.
On the public internet this lets anyone self-grant VIP.
## Decision
- With no Stripe keys, `POST /api/subscribe` returns `{ comingSoon: true }` and no `url`; `SubscribeButton` already renders a disabled «قريباً» button whenever the response has no `url`.
- `/payment-success` no longer activates anything without a session: the `else if (!sessionId) { activate = true }` branch is deleted and `simulated` is removed from the page props. After the activation block, free users (`role` not `vip`/`admin`) are redirected to `/subscription`. VIP/admin users pass through to the success copy.
- The real Stripe path is unchanged: `session_id` present + `STRIPE_SECRET_KEY` set → session verified with Stripe → VIP activated.
- When Stripe keys land, the same pages switch to real Checkout without further flow changes.
## Consequences
- No revenue until Stripe is configured; VIP can still be granted manually by an admin in `/admin`.
- Anyone with a saved `?simulated=1` link gets redirected — the query param is simply ignored now.
+47
View File
@@ -0,0 +1,47 @@
# ADR 0005 — Member trust model (server-only privilege/verification/billing fields)
Date: 2026-10-09
## Status
Accepted
## Context
The 2026-10-08 pentest (findings #1, #3, #4, #6) confirmed that anonymous or self-authenticated
client requests could write privileged fields on `users`:
1. `POST /api/users` accepted client-supplied `emailVerified`, `role`, `onboarded`,
`stripeCustomerId`, `verificationToken` — e.g. instant self-verified admin.
2. Self-`PATCH /api/users/{id}` accepted the same fields on update.
3. Per-account login lockout (no time-box) let an attacker lock the admin out of the site
indefinitely with wrong-password floods.
## Decision
- `role`, `emailVerified`, `verificationToken`, `stripeCustomerId`, `onboarded` are stripped from
every **untrusted write** in `Users.beforeChange` (`web/src/collections/Users.ts`). Untrusted =
no authenticated admin **and** no `context.systemActivation === true`. On create, the hook forces
`role: 'free'`, `emailVerified: false`, `onboarded: false`.
- Server-side writers opt back in with `context: { systemActivation: true }` on their local-API
`payload.update`/`create` calls. Enumerated writers: `verify-email`, `resend-verification`,
`subscribe`, `stripe/webhook` (×2), `payment-success`, `seed`. **Omitting the context silently
no-ops the write (the field is deleted) — that is the intended failure mode**, so a missing
context is caught in QA rather than leaking privilege.
- Login lockout is per-account, time-boxed: 5 failed attempts → 15-minute lock (`auth:
{ maxLoginAttempts: 5, lockTime: 15 * 60 * 1000 }`).
- `role === 'admin'` accounts are **exempt from lockout** (owner decision 2026-10-09): a
`beforeOperation` hook on `login` pre-clears `lockUntil`/`loginAttempts` via
`db.updateOne` (which, like Payload's own `incrementLoginAttempts`, bypasses hooks). Free/VIP
accounts keep the normal 5/15-min lockout.
- Commenting requires a verified email; admins are exempt (see the 403 gate in
`api/posts/[slug]/comments`).
## Consequences
- Every future server-side writer of these fields MUST pass `context.systemActivation`; a writer
that forgets it silently loses its write.
- The admin password has **no brute-force throttle** — compensate with a strong password. If that
posture changes, delete the `beforeOperation` hook and the account falls back to 5/15-min lockout.
- Client-sent profile fields (`fullName`, `termsVersion`, `interest`, `level`, `goal`, `prefs`) stay
writable; only privilege/verification/billing fields are protected.
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB