diff --git a/apps/caramel-app/src/lib/faqItems.ts b/apps/caramel-app/src/lib/faqItems.ts
new file mode 100644
index 00000000..ce6ce745
--- /dev/null
+++ b/apps/caramel-app/src/lib/faqItems.ts
@@ -0,0 +1,48 @@
+// The landing FAQ, as data. ONE array feeds three surfaces so they can never
+// drift: the visible accordion + its FAQPage JSON-LD
+// (src/components/FaqSection.tsx, pinned by tests/unit/faq-section.test.tsx)
+// and the answer-engine document at /llms-full.txt
+// (src/app/llms-full.txt/route.ts). Moved out of FaqSection.tsx verbatim on
+// 2026-09-12 so the route handler can import the strings without pulling a
+// React component (and react-icons) into a text/plain endpoint.
+//
+// CLAIM INTEGRITY (verified 2026-07-28 — engines quote this copy verbatim):
+// - affiliate answer: zero affiliate/referral/utm logic in apps/caramel-extension.
+// - free answer: PricingSection ("Free Forever Plan", no paid tier).
+// - data answer: extension network surface = background.js (fetchCoupons by
+// store domain, classifyCart page/cart signals, reportOutcome worked/failed)
+// + cart-signals.js payload (title/meta/up to 6 item names, no payment data);
+// sign-in token lives in browser extension storage (popup.js/coupon-runner.js).
+// - browsers: the four live store listings in src/lib/brandLinks.ts.
+// - numbers: 139,340 active codes / 3,402 distinct stores from the PROD
+// /api/coupons/stats + catalog on 2026-07-28, rounded DOWN. Never round up.
+export const faqItems: ReadonlyArray<{ question: string; answer: string }> = [
+ {
+ question: 'Does Caramel replace or hijack creator affiliate links?',
+ answer: 'No. The Caramel coupon extension never replaces, overrides, or injects affiliate links — there is no affiliate code anywhere in the extension, and because it is open source you can verify that yourself. Creators keep 100% of their commissions when you shop with Caramel installed.',
+ },
+ {
+ question: 'Is Caramel really free?',
+ answer: 'Yes. Caramel is free forever — there is no premium tier, no hidden fees, and no credit card required. The project is open source and maintained by Devino Solutions together with community contributors.',
+ },
+ {
+ question: 'What data does the Caramel extension collect?',
+ answer: "The extension never sells or shares your personal information, and it contains no ads and no third-party trackers. To do its job it talks to Caramel's own servers: when you reach checkout on a supported store it fetches coupon codes for that store's domain, sends the page and cart context (page title and item names — never payment details) so the right category of codes is chosen, and reports whether a code worked so rankings stay accurate for everyone. Your settings and optional sign-in are kept in your browser's extension storage.",
+ },
+ {
+ question: 'How is Caramel different from Honey?',
+ answer: "Honey has been publicly documented replacing creators' affiliate links with its own, and its code is closed source, so its behavior can't be independently audited. Caramel is the opposite by design: fully open source under the AGPL-3.0 license, it never touches affiliate links, and it is free with no premium tier.",
+ },
+ {
+ question: 'Which browsers does Caramel support?',
+ answer: 'Caramel is available for Chrome on the Chrome Web Store, for Firefox on Firefox Add-ons, for Microsoft Edge on Edge Add-ons, and for Safari through the App Store.',
+ },
+ {
+ question: 'How many coupon codes does Caramel have?',
+ answer: "Caramel's catalog holds over 139,000 active coupon codes across more than 3,000 online stores, and it is refreshed continuously as new codes are found and dead ones are retired.",
+ },
+ {
+ question: 'Do I need an account to use Caramel?',
+ answer: 'No. You can install Caramel and let it apply coupons at checkout without creating an account. Signing in is optional.',
+ },
+]
diff --git a/apps/caramel-app/src/lib/seo/aiCrawlers.ts b/apps/caramel-app/src/lib/seo/aiCrawlers.ts
new file mode 100644
index 00000000..645c2e43
--- /dev/null
+++ b/apps/caramel-app/src/lib/seo/aiCrawlers.ts
@@ -0,0 +1,29 @@
+// The AI answer-engine / search crawlers that get an EXPLICIT allow group in
+// robots.txt (src/app/robots.ts), on top of the `*` group. A named group is
+// what makes the intent unambiguous to an operator reading the file — and to
+// the crawlers whose docs say they honour a product-specific token before the
+// wildcard. The set is the fleet standard from
+// ~/.claude/skills/seo-fleet/references/aeo-crawler-access.md; it is pinned
+// verbatim by tests/unit/robots-env-contract.test.ts so nobody can drop or
+// misspell an agent without a red test. Order is documentation order — keep
+// it stable; the test compares the exact array.
+export const AI_CRAWLERS = [
+ 'GPTBot',
+ 'OAI-SearchBot',
+ 'ChatGPT-User',
+ 'ClaudeBot',
+ 'Claude-User',
+ 'Claude-SearchBot',
+ 'anthropic-ai',
+ 'PerplexityBot',
+ 'Perplexity-User',
+ 'Google-Extended',
+ 'Googlebot',
+ 'Bingbot',
+ 'Applebot',
+ 'Applebot-Extended',
+ 'CCBot',
+ 'Amazonbot',
+ 'Bytespider',
+ 'meta-externalagent',
+] as const
diff --git a/apps/caramel-app/src/lib/seo/llmsTxtAlternate.ts b/apps/caramel-app/src/lib/seo/llmsTxtAlternate.ts
new file mode 100644
index 00000000..8bb1b91c
--- /dev/null
+++ b/apps/caramel-app/src/lib/seo/llmsTxtAlternate.ts
@@ -0,0 +1,17 @@
+import type { Metadata } from 'next'
+
+// ``
+// — the discoverability pointer for the answer-engine summary served by
+// src/app/llms.txt/route.ts (which itself points at /llms-full.txt).
+//
+// Next's metadata merge REPLACES `alternates` wholesale at every level
+// (resolve-metadata.js: `newResolvedMetadata.alternates = resolveAlternates(
+// source.alternates, …)`), so a page that declares its own canonical drops the
+// root layout's entry. Every page that sets `alternates` and should carry the
+// pointer spreads this in — today that is the root layout (default) and the
+// home page. Pinned in raw home HTML by e2e/seo-regression.spec.ts.
+export const LLMS_TXT_ALTERNATE_TYPES: NonNullable<
+ NonNullable['types']
+> = {
+ 'text/plain': [{ url: '/llms.txt', title: 'llms.txt' }],
+}
diff --git a/apps/caramel-app/src/middleware.ts b/apps/caramel-app/src/middleware.ts
index d440a839..be098113 100644
--- a/apps/caramel-app/src/middleware.ts
+++ b/apps/caramel-app/src/middleware.ts
@@ -1,28 +1,45 @@
-// Canonical-host redirect: www.grabcaramel.com -> grabcaramel.com (308).
+// Canonical-origin redirects, owned by the app since the 2026-08-08 compose
+// cutover (compose-type Dokploy services carry no proxy redirects, and
+// host/scheme matching is impossible in next.config redirects()).
//
-// Until the 2026-08-08 compose cutover this redirect lived in the proxy layer
-// (a traefik redirect attached to the old Dokploy application); compose-type
-// services carry no proxy redirects, so the app now owns its own canonical
-// host. Host-based matching is impossible in next.config redirects(), hence
-// middleware. BETTER_AUTH_URL / NEXT_PUBLIC_BASE_URL are the apex, so auth
-// cookies and OAuth callbacks assume the apex host — serving pages on www
-// would fork sessions across two origins.
+// 1. www.grabcaramel.com -> grabcaramel.com (308). BETTER_AUTH_URL /
+// NEXT_PUBLIC_BASE_URL are the apex, so auth cookies and OAuth callbacks
+// assume the apex host — serving pages on www would fork sessions across
+// two origins.
+// 2. http:// -> https:// (308), decided ONLY from Cloudflare's `cf-visitor`
+// header (`{"scheme":"http"}`). Measured 2026-09-11: `http://grabcaramel.com/`
+// served 200 with the full page because the zone's "Always Use HTTPS" is
+// off, and Search Console already indexes the http twin. NEVER key this on
+// `x-forwarded-proto`: Next synthesises it and Traefik rewrites it to the
+// origin-side scheme, so an x-forwarded-proto fallback redirect-loops behind
+// the proxy. No cf-visitor header (direct-to-origin, local, CI) = serve.
+//
+// A www + http request redirects ONCE, straight to the https apex.
import { NextResponse, type NextRequest } from 'next/server'
+// Cloudflare's documented shape is exactly `{"scheme":"http"}`; matched with
+// a regex rather than JSON.parse so an unexpected value can never throw
+// inside the edge path — an unparseable header just means "not plain http".
+const CF_VISITOR_PLAIN_HTTP = /"scheme"\s*:\s*"http"/
+
export function middleware(request: NextRequest) {
const host = request.headers.get('host') ?? ''
- if (host.startsWith('www.')) {
- const url = request.nextUrl.clone()
- url.host = host.slice('www.'.length)
- url.protocol = 'https'
- url.port = ''
- return NextResponse.redirect(url, 308)
+ const isWww = host.startsWith('www.')
+ const isPlainHttp = CF_VISITOR_PLAIN_HTTP.test(
+ request.headers.get('cf-visitor') ?? '',
+ )
+ if (!isWww && !isPlainHttp) {
+ return NextResponse.next()
}
- return NextResponse.next()
+ const url = request.nextUrl.clone()
+ url.host = isWww ? host.slice('www.'.length) : host
+ url.protocol = 'https'
+ url.port = ''
+ return NextResponse.redirect(url, 308)
}
export const config = {
// Skip Next internals and static assets; everything else (pages + API)
- // must redirect so no client ever operates on the www origin.
+ // must redirect so no client ever operates on the www or http origin.
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
diff --git a/apps/caramel-app/tests/unit/llms-txt-routes.test.ts b/apps/caramel-app/tests/unit/llms-txt-routes.test.ts
new file mode 100644
index 00000000..fbf18dee
--- /dev/null
+++ b/apps/caramel-app/tests/unit/llms-txt-routes.test.ts
@@ -0,0 +1,35 @@
+import { GET as getLlmsFull } from '@/app/llms-full.txt/route'
+import { GET as getLlms } from '@/app/llms.txt/route'
+import { faqItems } from '@/lib/faqItems'
+import { describe, expect, it } from 'vitest'
+
+// The two answer-engine text assets. e2e/seo-regression.spec.ts proves they
+// are SERVED (200, text/plain); this pins their CONTENT contract at unit
+// level, where the FAQ array is importable: llms-full.txt must carry every
+// landing-FAQ question and answer verbatim (one array feeds the accordion,
+// the FAQPage JSON-LD and this document — src/lib/faqItems.ts), and llms.txt
+// must point at the full document so an engine that only reads the index
+// card can find it.
+
+describe('llms.txt + llms-full.txt', () => {
+ it('llms-full.txt renders every FAQ question and answer verbatim', async () => {
+ const res = getLlmsFull()
+ expect(res.headers.get('content-type')).toContain('text/plain')
+ const body = await res.text()
+ expect(body.startsWith('# Caramel\n')).toBe(true)
+ expect(faqItems.length).toBeGreaterThanOrEqual(5)
+ for (const { question, answer } of faqItems) {
+ expect(body).toContain(`### ${question}`)
+ expect(body).toContain(answer)
+ }
+ expect(body).toMatch(/\/privacy\b/)
+ expect(body).toContain('https://devino.ca')
+ // Claim-integrity rule carries over: no invented social proof.
+ expect(body).not.toMatch(/aggregateRating|reviewRating|"Review"/i)
+ })
+
+ it('llms.txt links the full document', async () => {
+ const body = await getLlms().text()
+ expect(body).toMatch(/\/llms-full\.txt\)/)
+ })
+})
diff --git a/apps/caramel-app/tests/unit/middleware.test.ts b/apps/caramel-app/tests/unit/middleware.test.ts
new file mode 100644
index 00000000..9ff21ff8
--- /dev/null
+++ b/apps/caramel-app/tests/unit/middleware.test.ts
@@ -0,0 +1,107 @@
+import { config, middleware } from '@/middleware'
+import { NextRequest } from 'next/server'
+import { describe, expect, it } from 'vitest'
+
+// Pins src/middleware.ts's canonical-origin contract. Two redirects, both
+// 308, both ending on the https apex:
+// - www.grabcaramel.com -> grabcaramel.com (host)
+// - http -> https, decided ONLY from Cloudflare's `cf-visitor` header
+// and — the load-bearing negative — NEVER from `x-forwarded-proto`: Next
+// synthesises that header and Traefik rewrites it to the origin-side scheme,
+// so a redirect keyed on it loops behind the proxy. Measured 2026-09-11:
+// http://grabcaramel.com/ served 200 (zone "Always Use HTTPS" off).
+
+function requestFor(
+ url: string,
+ headers: Record = {},
+): NextRequest {
+ const { host } = new URL(url)
+ return new NextRequest(url, { headers: { host, ...headers } })
+}
+
+const CF_HTTP = { 'cf-visitor': '{"scheme":"http"}' }
+const CF_HTTPS = { 'cf-visitor': '{"scheme":"https"}' }
+
+function expectServed(res: Response): void {
+ expect(res.status).not.toBe(308)
+ expect(res.headers.get('location')).toBeNull()
+}
+
+function expectRedirect(res: Response, location: string): void {
+ expect(res.status).toBe(308)
+ expect(res.headers.get('location')).toBe(location)
+}
+
+describe('middleware — canonical origin (www -> apex, http -> https)', () => {
+ it('serves an https apex request untouched', () => {
+ expectServed(
+ middleware(requestFor('https://grabcaramel.com/coupons', CF_HTTPS)),
+ )
+ })
+
+ it('serves a request with NO cf-visitor header (direct-to-origin, local, CI)', () => {
+ expectServed(middleware(requestFor('http://localhost:58000/pricing')))
+ })
+
+ it('redirects a plain-http request (cf-visitor scheme http) to https, same path + query', () => {
+ expectRedirect(
+ middleware(
+ requestFor(
+ 'http://grabcaramel.com/coupons/nike.com?page=2&sort=best',
+ CF_HTTP,
+ ),
+ ),
+ 'https://grabcaramel.com/coupons/nike.com?page=2&sort=best',
+ )
+ })
+
+ it('redirects www to the apex over https', () => {
+ expectRedirect(
+ middleware(
+ requestFor('https://www.grabcaramel.com/pricing', CF_HTTPS),
+ ),
+ 'https://grabcaramel.com/pricing',
+ )
+ })
+
+ it('a www + http request redirects ONCE, straight to the https apex', () => {
+ expectRedirect(
+ middleware(
+ requestFor('http://www.grabcaramel.com/sources?x=1', CF_HTTP),
+ ),
+ 'https://grabcaramel.com/sources?x=1',
+ )
+ })
+
+ it('NEVER redirects on x-forwarded-proto alone (Traefik rewrites it — a fallback loops)', () => {
+ expectServed(
+ middleware(
+ requestFor('http://grabcaramel.com/', {
+ 'x-forwarded-proto': 'http',
+ }),
+ ),
+ )
+ })
+
+ it('an unexpected cf-visitor value is treated as "not plain http", never as an error', () => {
+ expectServed(
+ middleware(
+ requestFor('https://grabcaramel.com/', {
+ 'cf-visitor': 'not-json',
+ }),
+ ),
+ )
+ })
+
+ it('matcher keeps Next internals and static assets out of the redirect path', () => {
+ const [pattern] = config.matcher
+ // Anchored the way Next compiles it: the group must consume the whole
+ // pathname after the leading slash.
+ const matcher = new RegExp(`^${pattern}$`)
+ expect(matcher.test('/coupons/nike.com')).toBe(true)
+ expect(matcher.test('/api/health')).toBe(true)
+ expect(matcher.test('/_next/static/chunks/main.js')).toBe(false)
+ expect(matcher.test('/_next/image?url=x')).toBe(false)
+ expect(matcher.test('/favicon.ico')).toBe(false)
+ })
+})
diff --git a/apps/caramel-app/tests/unit/robots-env-contract.test.ts b/apps/caramel-app/tests/unit/robots-env-contract.test.ts
index 6fd265e4..e97a8429 100644
--- a/apps/caramel-app/tests/unit/robots-env-contract.test.ts
+++ b/apps/caramel-app/tests/unit/robots-env-contract.test.ts
@@ -1,12 +1,51 @@
+import { AI_CRAWLERS } from '@/lib/seo/aiCrawlers'
import type { MetadataRoute } from 'next'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Pins BOTH branches of src/app/robots.ts's env-aware contract. The e2e
// suite (e2e/seo-regression.spec.ts) can only ever exercise the non-prod
// branch — CI targets localhost and dev.grabcaramel.com — so the
-// production-allow branch is proven here, at unit level, where the origin
-// is ours to choose. robots.ts resolves BASE_URL at module scope, hence
-// resetModules + doMock + dynamic import per case.
+// production-allow branch (including the named AI-crawler allow group) is
+// proven here, at unit level, where the origin is ours to choose. robots.ts
+// resolves BASE_URL at module scope, hence resetModules + doMock + dynamic
+// import per case.
+
+type Rule = Extract[number]
+
+// Authenticated surfaces / machine-only API stay out of the crawl budget —
+// keep in sync with robots.ts DISALLOWED_PATHS. Both prod groups share it.
+const DISALLOWED_PATHS = [
+ '/api/',
+ '/login',
+ '/signup',
+ '/verify',
+ '/profile',
+ '/monitoring',
+]
+
+// The fleet's AI-crawler allow-list, spelled out here ON PURPOSE (not
+// imported) so a silent edit to src/lib/seo/aiCrawlers.ts — a dropped agent,
+// a typo, a reorder — fails this test instead of quietly shipping.
+const EXPECTED_AI_CRAWLERS = [
+ 'GPTBot',
+ 'OAI-SearchBot',
+ 'ChatGPT-User',
+ 'ClaudeBot',
+ 'Claude-User',
+ 'Claude-SearchBot',
+ 'anthropic-ai',
+ 'PerplexityBot',
+ 'Perplexity-User',
+ 'Google-Extended',
+ 'Googlebot',
+ 'Bingbot',
+ 'Applebot',
+ 'Applebot-Extended',
+ 'CCBot',
+ 'Amazonbot',
+ 'Bytespider',
+ 'meta-externalagent',
+]
async function robotsFor(baseUrl: string): Promise {
vi.resetModules()
@@ -15,17 +54,10 @@ async function robotsFor(baseUrl: string): Promise {
return robots()
}
-function soleRule(
- result: MetadataRoute.Robots,
-): Extract[number] {
+function rulesOf(result: MetadataRoute.Robots): Rule[] {
const rules = result.rules
expect(Array.isArray(rules)).toBe(true)
- const list = rules as Extract<
- MetadataRoute.Robots['rules'],
- readonly unknown[]
- >
- expect(list).toHaveLength(1)
- return list[0]
+ return rules as Rule[]
}
beforeEach(() => {
@@ -34,15 +66,25 @@ beforeEach(() => {
})
describe('robots.ts env-aware indexing contract', () => {
+ it('the AI-crawler allow-list is exactly the 18 fleet agents, in order', () => {
+ expect([...AI_CRAWLERS]).toEqual(EXPECTED_AI_CRAWLERS)
+ expect(new Set(AI_CRAWLERS).size).toBe(18)
+ })
+
it.each([
'https://dev.grabcaramel.com',
'http://localhost:58000',
'https://preview-caramel.example.com',
])(
- 'non-production origin %s blanket-disallows with NO sitemap',
+ 'non-production origin %s blanket-disallows with ONE rule and NO sitemap',
async (origin: string) => {
const result = await robotsFor(origin)
- const rule = soleRule(result)
+ const rules = rulesOf(result)
+ // No AI allow group on a staging host either — a named
+ // invitation to index dev.grabcaramel.com would be worse than
+ // the wildcard one.
+ expect(rules).toHaveLength(1)
+ const [rule] = rules
expect(rule.userAgent).toBe('*')
expect(rule.disallow).toBe('/')
expect(rule.allow).toBeUndefined()
@@ -54,23 +96,25 @@ describe('robots.ts env-aware indexing contract', () => {
'production origin %s allows crawling, disallows non-content paths, and advertises the sitemap',
async origin => {
const result = await robotsFor(origin)
- const rule = soleRule(result)
- expect(rule.userAgent).toBe('*')
- expect(rule.allow).toBe('/')
- // Authenticated surfaces / machine-only API stay out of the
- // crawl budget — keep in sync with robots.ts DISALLOWED_PATHS.
- expect(rule.disallow).toEqual([
- '/api/',
- '/login',
- '/signup',
- '/verify',
- '/profile',
- '/monitoring',
- ])
+ const rules = rulesOf(result)
+ expect(rules).toHaveLength(2)
+ const [wildcard] = rules
+ expect(wildcard.userAgent).toBe('*')
+ expect(wildcard.allow).toBe('/')
+ expect(wildcard.disallow).toEqual(DISALLOWED_PATHS)
expect(result.sitemap).toBe(`${origin}/sitemap.xml`)
},
)
+ it('production adds a second, explicit allow group for the 18 AI crawlers with the SAME disallow set', async () => {
+ const result = await robotsFor('https://grabcaramel.com')
+ const [, aiGroup] = rulesOf(result)
+ expect(aiGroup.userAgent).toEqual(EXPECTED_AI_CRAWLERS)
+ expect(aiGroup.allow).toBe('/')
+ // Private paths stay private for answer engines too.
+ expect(aiGroup.disallow).toEqual(DISALLOWED_PATHS)
+ })
+
it('a trailing slash on BASE_URL still resolves the production branch', async () => {
const result = await robotsFor('https://grabcaramel.com/')
expect(result.sitemap).toBe('https://grabcaramel.com/sitemap.xml')