Skip to content

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:

  1. 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.
  2. /api/* route handlers — only when no convention fits (favicon, .well-known/*).
  3. 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

  1. Dynamic Favicon Configuration

    • Primary favicon sourced from the CMS.
    • Favicon served via /api/favicon endpoint with caching headers.
    • Next.js rewrite rule redirects /favicon.ico requests to API endpoint.
  2. 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 (favicon field is null/undefined).
      • Any error occurs during favicon retrieval (network failure, invalid URL, etc).
    • Fallback ensures consistent branding even when CMS is unavailable.
  3. Implementation Requirements

    • Graceful error handling with comprehensive try-catch blocks.
    • Proper TypeScript typing using Uint8Array for Buffer conversion.
    • Consistent caching headers for both dynamic and fallback favicons.
    • No user-visible errors during favicon serving process.
  4. 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.

Icons & Manifest

  1. PWA / device icons

    • src/app/icon.tsx — generates /icon.png from CMS site.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.
  2. Web App Manifest

    • src/app/manifest.ts — generates /manifest.webmanifest.
    • Sources name, short_name, theme_color, background_color, icons from 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 when manifest.ts is present — do not hand-roll the link tag.

Robots

  • src/app/robots.ts — generates /robots.txt per 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.xml referencing 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: generateStaticParams must only include published content). Drafts, archived, and unlisted entries are excluded.
  • Sources:
    • (main) CMS pages — lastModified from CMS updated_at.
    • (main)/blog/\[slug\] — published blog posts.
    • (lms)/learn and 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.ts returns 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_image from CMS; falls back to a generated card via next/og (ImageResponse) using site.name + tenant theme color.
  • Per-route overrides allowed: a (main)/blog/\[slug\]/opengraph-image.tsx may use the post's hero image.
  • Cache: public, max-age=31536000, immutable.

.well-known Files

  1. /.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.
    • Contact sources from CMS site.security_contact; fallback mailto:security@<host>.
    • Optional: Policy, Acknowledgments, Canonical.
    • Response Content-Type: text/plain; charset=utf-8.
    • Cache: public, max-age=86400 (1 day).
  2. /.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

  1. 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 in Authorization: Bearer <secret> or x-cms-webhook-secret; the route rejects missing or mismatched secrets with 401.
    • 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 with 422.
    • Allowed tag prefixes: course:, entry:, site:, cms:page-hierarchy, and progress:. 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_SECRET to the client bundle.
  2. 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 429 with a user-safe error shape.
    • Tests assert that accepted tags are passed to revalidateTag and rejected tags are not.

Implementation Requirements (shared)

  • All CMS fetches run server-side. Never import Auth.js, Kysely, or expose AUTH_SECRET / DATABASE_URL from these handlers (see root AGENTS.md).
  • Wrap every external fetch in try/catch; on error, return the fallback asset with the same caching headers as the dynamic response.
  • Use Uint8Array for binary payloads; never any.
  • Tenant resolution: derive from request host (Host header / headers().get('host')); pass to a shared getSiteForHost(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.