Free early-access launch: LAUNCH_MODE policy, CRO, pipeline hardening, AI-SEO, ops

- launch.ts policy: LAUNCH_MODE=free_early_access default; effective access =
  published && (free || early); intended tier (access) never rewritten
- Billing off server-side: /api/subscribe 503 honest copy; subscribe CTAs
  render disabled 'قريباً' from first paint; /subscription early-access panel
  + /api/notify (LaunchNotifications) with honest 503 when mail transport off
- Signup trust: verification email at signup (Users afterChange), no
  confirm-password, 429 message fidelity, typo suggestion, live password hint
- Agent pipeline: pickFields strips access/readingMinutes, sourceId dedup,
  secret-scan 422 (pattern names only), string-content rejected (was silently
  replaced), publish stays explicit opt-in
- lexical.ts: root unwrap fix — lexicalToText/countLexicalWords returned 0
  for real serialized trees (secret scan + readingMinutes were inert)
- llms-full.txt: fetchIndexDocs(true) — bodies were never selected
- Content honesty: contentType/authorName/sourceId fields + migration,
  readingMinutes computed at write (160 wpm) + backfill script, byline,
  'حُدّث' freshness
- SEO/AI-SEO: metadata rewrite (published-only, og:image 1200x630), Article +
  BreadcrumbList + FAQPage + Organization JSON-LD, isAccessibleForFree honest,
  table renderer, robots AI-crawler policy (10 named bots), llms.txt +
  llms-full.txt, /topics/* hubs + sitemap, noindex auth pages, www 301
- Ops: backup-db.sh (restore rehearsed on VPS), docs copies (launch
  readiness + marketing audit + owner-inputs for L-03), GLOSSARY.md entries

Verified: tsc --noEmit 0, npm run build 0, local smoke (ghost VIP read,
subscribe/notify/agent APIs, signup token, 429 on 6th, llms/topics/sitemap,
og:image/JSON-LD)
This commit is contained in:
AHMED
2026-10-10 18:03:45 +03:00
parent 82414e8a93
commit 0161924088
57 changed files with 4042 additions and 402 deletions
+53 -3
View File
@@ -5,6 +5,8 @@ import type { Media } from '@payload-types'
import configPromise from '@payload-config'
import { resolveCoverImage } from '@/lib/agent-media'
import { resolveAttachments, deleteFileDocs } from '@/lib/agent-files'
import { scanAgentPayload } from '@/lib/secret-scan'
import { lexicalToText } from '@/lib/lexical'
export const dynamic = 'force-dynamic'
@@ -20,9 +22,11 @@ function validKey(req: NextRequest) {
return req.headers.get('x-agent-key') === key
}
/** Fields the agent is allowed to write; status is NEVER included. */
/** Fields the agent is allowed to write; status is NEVER included.
* `access`/`readingMinutes` are admin-only (tier changes and reading time
* are admin/agent-policy decisions — the hook computes honest minutes). */
function pickFields(body: Body): Body {
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'cover'] as const
const out: Body = {}
for (const field of allowed) {
if (field in body) out[field] = body[field]
@@ -58,6 +62,21 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'slug is required' }, { status: 400 })
}
// L-10 dedup first: a retried ingest with the same sourceId surfaces the
// existing post (200) instead of a misleading slug 409.
if (typeof body.sourceId === 'string' && body.sourceId.trim() !== '') {
const dupe = await payload.find({
collection: 'posts',
where: { sourceId: { equals: body.sourceId } },
limit: 1,
overrideAccess: true,
})
const dup = dupe.docs[0] as { id: number; slug: string; status: string } | undefined
if (dup) {
return NextResponse.json({ id: dup.id, slug: dup.slug, status: dup.status, deduplicated: true }, { status: 200 })
}
}
// Slug conflict → 409
const existing = await payload.find({
collection: 'posts',
@@ -69,6 +88,20 @@ export async function POST(req: NextRequest) {
}
const data = pickFields(body)
if (typeof body.sourceId === 'string' && body.sourceId.trim() !== '') data.sourceId = body.sourceId
// L-05: pattern names only — matched values are never stored or echoed.
// A string content (wrong shape) is scanned raw too — lexicalToText of a
// string returns '' and would silently skip the scan.
const secretPatterns = scanAgentPayload([
data.title as string,
data.dek as string,
data.teaser as string,
typeof data.content === 'string' ? data.content : lexicalToText(data.content),
])
if (secretPatterns.length > 0) {
return NextResponse.json({ error: 'content rejected: potential secrets', patterns: secretPatterns }, { status: 422 })
}
// Per-post work files (inline base64) → stored as gated `files` documents.
// Like coverImage, `attachments` is an input transform, not a raw field.
@@ -99,7 +132,14 @@ export async function POST(req: NextRequest) {
// directly (unpublished content requires a deliberate admin action otherwise).
const publish = body.publish === true
if (publish && !data.publishedAt) data.publishedAt = new Date().toISOString()
if (!data.content || typeof data.content !== 'object') {
if (typeof data.content === 'string') {
// Wrong shape — never store it and never silently drop the agent's text.
return NextResponse.json(
{ error: 'content must be Lexical JSON ({ root: { children: [...] } }), not a markdown/plain string' },
{ status: 400 },
)
}
if (!data.content) {
// minimal valid Lexical doc (empty root is rejected by validation); agent PUTs full content later
data.content = {
root: {
@@ -159,6 +199,16 @@ export async function PUT(req: NextRequest) {
const data = pickFields(body)
delete (data as Body).status // agent can never publish/unpublish
const secretPatterns = scanAgentPayload([
data.title as string,
data.dek as string,
data.teaser as string,
typeof data.content === 'string' ? data.content : lexicalToText(data.content),
])
if (secretPatterns.length > 0) {
return NextResponse.json({ error: 'content rejected: potential secrets', patterns: secretPatterns }, { status: 422 })
}
const doc = await payload.update({
collection: 'posts',
id: post.id,
+5 -1
View File
@@ -5,6 +5,7 @@ import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { FILES_DIR } from '@/collections/Files'
import { getViewer } from '@/lib/viewer'
import { freeEarlyAccess } from '@/lib/launch'
export const dynamic = 'force-dynamic'
@@ -26,7 +27,10 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
}
if (!doc?.filename) return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 })
if (doc.access === 'vip') {
// L-02: all files open during free early access (owner curates file
// access in the admin; ALL vip files download while billing is off).
// Viewer checks apply only when billing is enabled.
if (doc.access === 'vip' && !freeEarlyAccess()) {
const viewer = await getViewer()
if (!viewer) return NextResponse.json({ error: 'سجّل الدخول لتنزيل الملفات.' }, { status: 401 })
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { sendMail } from '@/lib/mail'
export const dynamic = 'force-dynamic'
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/* POST /api/notify — L-01 launch-notification list.
Idempotent: an existing email returns 200 { ok: true, alreadySubscribed: true }
(no duplicate row, no re-send). A fresh email is stored and a confirmation
is sent; if the mail transport is not wired the response is an honest 503
(the row is kept — no fake success). */
export async function POST(req: Request) {
let body: { email?: string } | null = null
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'صيغة الطلب غير صحيحة.' }, { status: 400 })
}
const email = (body?.email ?? '').trim().toLowerCase()
if (!EMAIL_RE.test(email)) {
return NextResponse.json({ error: 'أدخل بريداً إلكترونياً صحيحاً.' }, { status: 400 })
}
const payload = await getPayload({ config: configPromise })
const { docs } = await payload.find({
collection: 'launch-notifications',
where: { email: { equals: email } },
limit: 1,
overrideAccess: true,
})
if (docs.length > 0) {
return NextResponse.json({ ok: true, alreadySubscribed: true })
}
try {
await payload.create({
collection: 'launch-notifications',
data: { email, source: 'subscription' },
})
} catch (err) {
// Lost a unique-constraint race against a concurrent signup → idempotent OK.
const message = err instanceof Error ? err.message : ''
if (/duplicate key|unique constraint/i.test(message)) {
return NextResponse.json({ ok: true, alreadySubscribed: true })
}
console.error('launch notification create failed:', err)
return NextResponse.json({ error: 'تعذر حفظ البريد، حاول مرة أخرى.' }, { status: 500 })
}
const { sent } = await sendMail(
email,
'سنعلمك عند تفعيل العضوية — أنور بي آي',
'مرحباً بك في أنور بي آي.\n\nسجّلنا بريدك في قائمة الإشعار، وسنراسلك قبل تفعيل العضوية بأيام.\n\nخلال الوصول المبكر كل الأدلة المنشورة متاحة مجاناً، ولن يُنشأ لك اشتراك أو يُحصَّل أي مبلغ.',
)
if (!sent) {
return NextResponse.json({ error: 'خدمة البريد غير مربوطة بعد.' }, { status: 503 })
}
return NextResponse.json({ ok: true })
}
+5 -1
View File
@@ -3,6 +3,7 @@ import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { REST_DELETE, REST_GET, REST_PATCH, REST_PUT } from '@payloadcms/next/routes'
import { getViewer } from '@/lib/viewer'
import { freeEarlyAccess } from '@/lib/launch'
export const dynamic = 'force-dynamic'
@@ -35,7 +36,10 @@ export async function GET(req: NextRequest, ctx: Ctx) {
})
const post = docs[0]
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (post.access === 'vip') {
// L-02: in free early access, published VIP posts serve the full body
// (mirrors the page + field-ACL policy); the tier gate applies only when
// billing is enabled.
if (post.access === 'vip' && !freeEarlyAccess()) {
const viewer = await getViewer()
if (!viewer) return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
+11 -8
View File
@@ -4,6 +4,7 @@ import type { Payload } from 'payload'
import { headers } from 'next/headers'
import Stripe from 'stripe'
import configPromise from '@payload-config'
import { billingEnabled } from '@/lib/launch'
export const dynamic = 'force-dynamic'
@@ -12,15 +13,17 @@ export async function POST() {
const priceId = process.env.STRIPE_PRICE_ID
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
// Stripe pending mode: no keys yet → subscription is «قريباً» (coming soon).
// No VIP is granted without a Stripe-verified session; adding
// STRIPE_SECRET_KEY + STRIPE_PRICE_ID switches to real Checkout with the
// same success/cancel pages.
// L-01: checkout is disabled server-side during free early access —
// 503, and never a Checkout session in this mode. Paid transition =
// LAUNCH_MODE=paid + Stripe keys; this route re-enables with no code change.
if (!billingEnabled()) {
return NextResponse.json({ error: 'الدفع غير مفعّل خلال فترة الوصول المبكر.' }, { status: 503 })
}
// Config-error guard for paid mode (TS narrowing; billingEnabled() already
// guarantees the secret) — never create a session with a missing price.
if (!secret || !priceId) {
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
return NextResponse.json({ comingSoon: true })
return NextResponse.json({ error: 'الدفع غير مكتمل الإعداد حالياً.' }, { status: 503 })
}
const payload = await getPayload({ config: configPromise })