APIs & Well-Known Files¶
Overview¶
Every publicly addressable "well-known" file (favicon, icons, manifest, robots, sitemap, social cards, security.txt) is per-tenant, sourced from the CMS site document, with a static fallback in public/fallback/.
Implementation order of preference:
- NextJS App Router file conventions (
app/sitemap.ts,app/robots.ts,app/manifest.ts,app/icon.tsx,app/apple-icon.tsx,app/opengraph-image.tsx,app/twitter-image.tsx) — preferred whenever a convention exists. /api/*route handlers — only when no convention fits (favicon,.well-known/*).- Static files in
public/— only for true fallbacks.
All CMS fetches run server-side only (see root AGENTS.md and .cursor/rules). All binary responses use Uint8Array. No any types. Comprehensive try/catch on every external fetch with fallback to the static asset.
Favicon Management¶
-
Dynamic Favicon Configuration
- Primary favicon sourced from the CMS.
- Favicon served via
/api/faviconendpoint with caching headers. - Next.js rewrite rule redirects
/favicon.icorequests to API endpoint.
-
Fallback Strategy
- Business Rule: When site favicon is unavailable, system must serve fallback favicon.
- Fallback file located at
public/fallback/favicon.ico. - Fallback triggered in two scenarios:
- Site document has no favicon configured (
faviconfield is null/undefined). - Any error occurs during favicon retrieval (network failure, invalid URL, etc).
- Site document has no favicon configured (
- Fallback ensures consistent branding even when CMS is unavailable.
-
Implementation Requirements
- Graceful error handling with comprehensive try-catch blocks.
- Proper TypeScript typing using
Uint8Arrayfor Buffer conversion. - Consistent caching headers for both dynamic and fallback favicons.
- No user-visible errors during favicon serving process.
-
Caching Policy
- Both dynamic and fallback favicons use
Cache-Control: public, max-age=31536000, immutable. - 1-year cache duration for optimal performance.
- Immutable flag prevents unnecessary revalidation.
- Both dynamic and fallback favicons use
Icons & Manifest¶
-
PWA / device icons
src/app/icon.tsx— generates/icon.pngfrom CMSsite.icon(sizes 192, 512).src/app/apple-icon.tsx— generates/apple-icon.png(180×180) for iOS home-screen.- Fallback assets in
public/fallback/icon-192.png,public/fallback/icon-512.png,public/fallback/apple-icon.png. - Cache:
public, max-age=31536000, immutable.
-
Web App Manifest
src/app/manifest.ts— generates/manifest.webmanifest.- Sources
name,short_name,theme_color,background_color,iconsfrom the CMS site document. - Fallback values are tenant-host-derived (e.g.
name: host). - Cache:
public, max-age=3600(manifest changes are rare but not immutable). <link rel="manifest">is emitted automatically by Next.js whenmanifest.tsis present — do not hand-roll the link tag.
Robots¶
src/app/robots.ts— generates/robots.txtper request host.- Disallow the following paths on every tenant:
/admin/learn/dashboard/learn/account/learn/course/*/enroll/learn/course/*/entry/*/learn/verify,/learn/reset(token-bearing URLs)/invite/api,/ingest,/_next
- For non-default locales, also disallow locale-prefixed variants of the LMS
routes above (for example,
/fr/admin,/zh/learn/reset) while keeping static platform paths unprefixed. - Allow all other paths.
- Emit
Sitemap: https://<host>/sitemap.xmlreferencing the current request host. - Cache:
public, max-age=3600.
Sitemap¶
src/app/sitemap.ts— generates/sitemap.xml.- Only published content (mirrors root
AGENTS.md/ security rules:generateStaticParamsmust only include published content). Drafts, archived, and unlisted entries are excluded. - Sources:
(main)CMS pages —lastModifiedfrom CMSupdated_at.(main)/blog/\[slug\]— published blog posts.(lms)/learnand public course landing pages (/learn/course/\[slug\]) — exclude post-enrol-only routes.
- ISR:
export const revalidate = 60(matches site-wide ISR policy). - When the published-entry count exceeds 50,000 URLs, switch to a sitemap index:
sitemap.tsreturns the index.- Chunk files at
sitemap/\[id\]/sitemap.ts(Next.js multi-sitemap convention).
- Never include URLs that 404, redirect, or require auth.
Social Card Images¶
src/app/opengraph-image.tsx— generates/opengraph-image(1200×630).src/app/twitter-image.tsx— generates/twitter-image(1200×600).- Sources
site.og_imagefrom CMS; falls back to a generated card vianext/og(ImageResponse) usingsite.name+ tenant theme color. - Per-route overrides allowed: a
(main)/blog/\[slug\]/opengraph-image.tsxmay use the post's hero image. - Cache:
public, max-age=31536000, immutable.
.well-known Files¶
-
/.well-known/security.txt(RFC 9116)- Served by
src/app/api/well-known/security/route.ts. - Required fields:
Contact,Expires(≤ 1 year out),Preferred-Languages. Contactsources from CMSsite.security_contact; fallbackmailto:security@<host>.- Optional:
Policy,Acknowledgments,Canonical. - Response
Content-Type: text/plain; charset=utf-8. - Cache:
public, max-age=86400(1 day).
- Served by
-
/.well-known/change-password(W3C draft)
- Implemented as a redirect in
next.config.ts→/learn/forgot.- Lets password managers deep-link users to the recovery flow.
Caching Policy Table¶
| File | Cache-Control |
Rationale |
|---|---|---|
/favicon.ico |
public, max-age=31536000, immutable |
Hashed/static binary; clients re-fetch on URL change. |
/icon.png, /apple-icon.png |
public, max-age=31536000, immutable |
Same as favicon. |
/manifest.webmanifest |
public, max-age=3600 |
Rarely changes but not immutable; references icons. |
/robots.txt |
public, max-age=3600 |
Tenant rules can change with deploy. |
/sitemap.xml |
ISR revalidate: 60 (no explicit header) |
Matches site-wide ISR; new content visible within 60s. |
/opengraph-image, /twitter-image |
public, max-age=31536000, immutable |
Per-route override surfaces fresh URL when content changes. |
/.well-known/security.txt |
public, max-age=86400 |
RFC-9116 contact info; daily refresh is sufficient. |
Inbound Webhooks¶
-
CMS cache revalidation
- Route:
POST /api/cms/revalidate. - Purpose: let the CMS invalidate tagged ISR caches immediately after publish, unpublish, or content correction events instead of waiting for the 60-second ISR window.
- Auth: require a shared secret in
CMS_WEBHOOK_SECRET. The CMS sends it inAuthorization: Bearer <secret>orx-cms-webhook-secret; the route rejects missing or mismatched secrets with401. - Payload: accept a Zod-validated JSON body with either
{ "tag": "course:<slug>" }or{ "tags": ["course:<slug>", "entry:<courseSlug>:<entrySlug>"] }. Reject empty payloads, duplicate tags, and unsupported tag prefixes with422. - Allowed tag prefixes:
course:,entry:,site:,cms:page-hierarchy, andprogress:.progress:should be emitted only by LMS server actions, not by CMS webhooks, unless a future CMS feature explicitly owns learner-visible progress chrome. - Behavior: call
revalidateTag(tag)once per accepted tag and return{ revalidated: string[] }. Do not expose stack traces, raw secret values, or CMS payload internals in the response. - Rate limit: 60 requests per minute per IP, as defined in
.cursor/rules/120 - Security.mdc. - Runtime: Node.js route handler. Keep the handler server-only; never expose
CMS_WEBHOOK_SECRETto the client bundle.
- Route:
-
Testing requirements
- Happy path for one tag and multiple tags.
- Missing or invalid secret returns
401. - Malformed JSON, invalid tag prefixes, and empty arrays return
422. - Rate-limit exhaustion returns
429with a user-safe error shape. - Tests assert that accepted tags are passed to
revalidateTagand rejected tags are not.
Implementation Requirements (shared)¶
- All CMS fetches run server-side. Never import Auth.js, Kysely, or expose
AUTH_SECRET/DATABASE_URLfrom these handlers (see rootAGENTS.md). - Wrap every external fetch in try/catch; on error, return the fallback asset with the same caching headers as the dynamic response.
- Use
Uint8Arrayfor binary payloads; neverany. - Tenant resolution: derive from request host (
Hostheader /headers().get('host')); pass to a sharedgetSiteForHost(host)helper rather than duplicating logic. - Test coverage (Vitest) for each handler must include: happy path, missing CMS field, CMS network error, malformed asset URL.