From 894283143e4c7d524a6545fae997382258fac6f1 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:24:21 +0100 Subject: [PATCH] fix(seo): AI-crawler allow-list, cf-visitor https redirect, HSTS preload, llms-full.txt --- apps/caramel-app/e2e/seo-regression.spec.ts | 84 ++++++++++++++ apps/caramel-app/next.config.mjs | 7 +- .../caramel-app/src/app/(auth)/login/page.tsx | 4 + .../src/app/(auth)/signup/page.tsx | 2 + .../src/app/(auth)/verify/page.tsx | 2 + apps/caramel-app/src/app/layout.tsx | 15 ++- .../src/app/llms-full.txt/route.ts | 101 +++++++++++++++++ apps/caramel-app/src/app/llms.txt/route.ts | 5 + apps/caramel-app/src/app/page.tsx | 5 +- apps/caramel-app/src/app/robots.ts | 14 ++- .../caramel-app/src/components/FaqSection.tsx | 46 +------- .../caramel-app/src/layouts/Footer/Footer.tsx | 7 ++ apps/caramel-app/src/lib/faqItems.ts | 48 ++++++++ apps/caramel-app/src/lib/seo/aiCrawlers.ts | 29 +++++ .../src/lib/seo/llmsTxtAlternate.ts | 17 +++ apps/caramel-app/src/middleware.ts | 49 +++++--- .../tests/unit/llms-txt-routes.test.ts | 35 ++++++ .../caramel-app/tests/unit/middleware.test.ts | 107 ++++++++++++++++++ .../tests/unit/robots-env-contract.test.ts | 98 +++++++++++----- 19 files changed, 585 insertions(+), 90 deletions(-) create mode 100644 apps/caramel-app/src/app/llms-full.txt/route.ts create mode 100644 apps/caramel-app/src/lib/faqItems.ts create mode 100644 apps/caramel-app/src/lib/seo/aiCrawlers.ts create mode 100644 apps/caramel-app/src/lib/seo/llmsTxtAlternate.ts create mode 100644 apps/caramel-app/tests/unit/llms-txt-routes.test.ts create mode 100644 apps/caramel-app/tests/unit/middleware.test.ts diff --git a/apps/caramel-app/e2e/seo-regression.spec.ts b/apps/caramel-app/e2e/seo-regression.spec.ts index ac55dffa..45b29009 100644 --- a/apps/caramel-app/e2e/seo-regression.spec.ts +++ b/apps/caramel-app/e2e/seo-regression.spec.ts @@ -297,4 +297,88 @@ test.describe('SEO regression gate (raw server HTML)', () => { const body = await res.text() expect(body).toContain('Caramel') }) + + test('llms-full.txt is served for answer engines and carries the FAQ', async ({ + page, + }) => { + // Measured 404 on prod 2026-09-11 (audit-findings.md "Host hygiene"). + const res = await page.request.get('/llms-full.txt') + expect(res.ok()).toBe(true) + expect(res.headers()['content-type']).toContain('text/plain') + const body = await res.text() + expect(body).toContain('Caramel') + // The FAQ block is rendered from the SAME array as the landing FAQ + // (src/lib/faqItems.ts) — one question is enough to prove the wiring. + expect(body).toContain('Is Caramel really free?') + expect(body).toContain('/privacy') + }) + + test('home raw HTML links llms.txt (rel=alternate + a crawlable footer )', async ({ + page, + }) => { + const html = await (await page.request.get('/')).text() + // Next renders alternates.types as + // + // (href absolute via metadataBase). Attribute order is Next's, not ours. + expect(html).toMatch( + / in the server HTML. + expect(html).toMatch(/]*>llms\.txt<\/a>/) + }) + + test('home Organization JSON-LD keeps its entity anchors (@id, alternateName, sameAs, parentOrganization)', async ({ + page, + }) => { + const html = await (await page.request.get('/')).text() + type Node = { + '@type'?: string + '@id'?: string + alternateName?: string[] + sameAs?: string[] + parentOrganization?: { '@type'?: string; url?: string } + } + const nodes = extractJsonLdBlocks(html).flatMap(block => { + const parsed = JSON.parse(block) as { '@graph'?: Node[] } & Node + return parsed['@graph'] ?? [parsed] + }) + const org = nodes.find(node => node['@type'] === 'Organization') + expect(org, 'home must ship an Organization node').toBeTruthy() + expect(org!['@id']).toMatch(/#organization$/) + expect(org!.alternateName).toContain('Caramel coupon extension') + expect(Array.isArray(org!.sameAs)).toBe(true) + expect(org!.sameAs!.length).toBeGreaterThan(0) + for (const url of org!.sameAs!) { + expect(url).toMatch(/^https:\/\//) + } + expect(org!.parentOrganization?.['@type']).toBe('Organization') + expect(org!.parentOrganization?.url).toBe('https://devino.ca') + }) + + test('HSTS carries preload (next.config.mjs SECURITY_HEADERS, every context)', async ({ + page, + }) => { + // Set by next.config.mjs headers(), so it holds on the hermetic server + // and on the deployed site alike. Measured missing on prod 2026-09-11. + const res = await page.request.get('/') + const hsts = res.headers()['strict-transport-security'] ?? '' + expect(hsts).toMatch(/max-age=31536000/) + expect(hsts).toMatch(/includeSubDomains/) + expect(hsts).toMatch(/preload/) + }) + + for (const routePath of ['/login', '/signup', '/verify']) { + test(`${routePath.slice(1)} raw HTML carries a robots noindex meta (belt-and-braces with robots.txt)`, async ({ + page, + }) => { + // robots.txt only asks crawlers not to FETCH these; a link-discovered + // URL can still be indexed title-only unless the page says noindex. + const res = await page.request.get(routePath) + expect(res.ok(), `${routePath} must return 2xx`).toBe(true) + const html = await res.text() + expect(html).toMatch(/name="robots"[^>]*content="[^"]*noindex/) + expect(html).toMatch(/name="robots"[^>]*content="[^"]*nofollow/) + }) + } }) diff --git a/apps/caramel-app/next.config.mjs b/apps/caramel-app/next.config.mjs index 8721af68..b1bd0b8e 100644 --- a/apps/caramel-app/next.config.mjs +++ b/apps/caramel-app/next.config.mjs @@ -25,9 +25,14 @@ const SECURITY_HEADERS = [ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()', }, + // `preload` is the hstspreload.org eligibility flag (max-age >= 1y + + // includeSubDomains + preload). Safe here because every subdomain that + // serves anything (dev.grabcaramel.com) is already https-only. Actually + // submitting the apex to the preload list is an owner action; the header + // alone changes nothing until then. Pinned by e2e/seo-regression.spec.ts. { key: 'Strict-Transport-Security', - value: 'max-age=31536000; includeSubDomains', + value: 'max-age=31536000; includeSubDomains; preload', }, ] diff --git a/apps/caramel-app/src/app/(auth)/login/page.tsx b/apps/caramel-app/src/app/(auth)/login/page.tsx index 739113ed..f46f8776 100644 --- a/apps/caramel-app/src/app/(auth)/login/page.tsx +++ b/apps/caramel-app/src/app/(auth)/login/page.tsx @@ -19,6 +19,10 @@ export const metadata: Metadata = { alternates: { canonical: canonicalUrl, }, + /* Belt-and-braces with robots.ts's Disallow: robots.txt only asks a + * crawler not to FETCH the page; a URL discovered through a link can still + * be indexed title-only unless the page itself says noindex. */ + robots: { index: false, follow: false }, openGraph: { type: 'website', url: canonicalUrl, diff --git a/apps/caramel-app/src/app/(auth)/signup/page.tsx b/apps/caramel-app/src/app/(auth)/signup/page.tsx index f0a20d71..ad848166 100644 --- a/apps/caramel-app/src/app/(auth)/signup/page.tsx +++ b/apps/caramel-app/src/app/(auth)/signup/page.tsx @@ -19,6 +19,8 @@ export const metadata: Metadata = { alternates: { canonical: canonicalUrl, }, + /* Belt-and-braces with robots.ts's Disallow — see login/page.tsx. */ + robots: { index: false, follow: false }, openGraph: { type: 'website', url: canonicalUrl, diff --git a/apps/caramel-app/src/app/(auth)/verify/page.tsx b/apps/caramel-app/src/app/(auth)/verify/page.tsx index 09b396de..00891d6f 100644 --- a/apps/caramel-app/src/app/(auth)/verify/page.tsx +++ b/apps/caramel-app/src/app/(auth)/verify/page.tsx @@ -16,6 +16,8 @@ export const metadata: Metadata = { alternates: { canonical: canonicalUrl, }, + /* Belt-and-braces with robots.ts's Disallow — see login/page.tsx. */ + robots: { index: false, follow: false }, openGraph: { type: 'website', url: canonicalUrl, diff --git a/apps/caramel-app/src/app/layout.tsx b/apps/caramel-app/src/app/layout.tsx index 70c92894..8d361629 100644 --- a/apps/caramel-app/src/app/layout.tsx +++ b/apps/caramel-app/src/app/layout.tsx @@ -8,6 +8,7 @@ import { SAFARI_APP_STORE_URL, } from '@/lib/brandLinks' import { BASE_URL } from '@/lib/env.client' +import { LLMS_TXT_ALTERNATE_TYPES } from '@/lib/seo/llmsTxtAlternate' import '@/styles/globals.css' import type { Metadata, Viewport } from 'next' import { ReactNode } from 'react' @@ -22,6 +23,7 @@ export const metadata: Metadata = { title: 'Caramel | The Trusted Alternative To Honey For Finding Coupons', description, metadataBase: new URL(BASE_URL), + alternates: { types: LLMS_TXT_ALTERNATE_TYPES }, openGraph: { type: 'website', title: 'Caramel | The Trusted Alternative To Honey For Finding Coupons', @@ -62,8 +64,12 @@ const THEME_INIT_SCRIPT = `(function(){var d=false;try{d=localStorage.getItem('t // entity: "Caramel coupon extension" + the "grabcaramel" handle. The sameAs // URLs come from src/lib/brandLinks.ts — the same constants the footer and // llms.txt render, so the graph can't drift from the UI. Price 0 is real -// (free forever, no paid tier — see /pricing). Deliberately NO -// aggregateRating/review markup of any kind. +// (free forever, no paid tier — see /pricing). parentOrganization ties the +// entity to the company that ships it (the repo lives under the +// DevinoSolutions GitHub org; devino.ca is the company site, 200 on +// 2026-09-12). Deliberately NO aggregateRating/review markup of any kind. +// The @id / alternateName / sameAs / parentOrganization shape is pinned in +// raw HTML by e2e/seo-regression.spec.ts. const ENTITY_STRUCTURED_DATA = { '@context': 'https://schema.org', '@graph': [ @@ -83,6 +89,11 @@ const ENTITY_STRUCTURED_DATA = { DISCORD_INVITE_URL, INSTAGRAM_URL, ], + parentOrganization: { + '@type': 'Organization', + name: 'Devino Solutions', + url: 'https://devino.ca', + }, }, { '@type': 'SoftwareApplication', diff --git a/apps/caramel-app/src/app/llms-full.txt/route.ts b/apps/caramel-app/src/app/llms-full.txt/route.ts new file mode 100644 index 00000000..1c21c4e3 --- /dev/null +++ b/apps/caramel-app/src/app/llms-full.txt/route.ts @@ -0,0 +1,101 @@ +import { + CHROME_WEB_STORE_URL, + DISCORD_INVITE_URL, + EDGE_ADDONS_URL, + FIREFOX_ADDONS_URL, + GITHUB_REPO_URL, + SAFARI_APP_STORE_URL, +} from '@/lib/brandLinks' +import { BASE_URL } from '@/lib/env.client' +import { faqItems } from '@/lib/faqItems' + +// The long-form companion of /llms.txt (same family as robots.ts and +// sitemap.ts: a static public text asset, deliberately NOT a `withRoute` +// handler — no request input, no auth, no DB). llms.txt is the index card; +// this is the one document an answer engine can ingest instead of crawling +// the site. Every fact here is sourced from a module that ALREADY feeds a +// visible surface — brandLinks.ts (footer, hero buttons, JSON-LD sameAs) and +// faqItems.ts (the landing FAQ + its FAQPage JSON-LD) — so this file can +// never state something the site itself does not. +const origin = BASE_URL.replace(/\/+$/, '') + +const faqSection = faqItems + .map(item => `### ${item.question}\n\n${item.answer}`) + .join('\n\n') + +const LLMS_FULL_TXT = `# Caramel + +> Caramel is a free, open-source, privacy-first browser extension that finds and +> applies coupon codes automatically at checkout. It does not sell browsing data +> and does not overwrite creators' affiliate commissions. + +This is the full-length version of ${origin}/llms.txt. + +## What Caramel is + +- A browser extension for Chrome, Firefox, Microsoft Edge, and Safari. +- Free to use, with no paid tier and no account required to install. +- Open source (AGPL-3.0) under the DevinoSolutions organization: + ${GITHUB_REPO_URL} +- Built and maintained by Devino Solutions (https://devino.ca) together with + community contributors. +- Positioned as an alternative to Honey for shoppers who care about privacy and + about not hijacking creator commissions. + +## How it works + +1. You shop normally; Caramel detects a supported store's checkout page. +2. It looks up known coupon codes for that store from its own catalog. +3. It tries the codes at checkout and keeps the one with the best discount. +4. It reports whether a code worked so the catalog's rankings stay accurate. + +## Browsers and install links + +- Chrome — Chrome Web Store: ${CHROME_WEB_STORE_URL} +- Firefox — Firefox Add-ons: ${FIREFOX_ADDONS_URL} +- Microsoft Edge — Edge Add-ons: ${EDGE_ADDONS_URL} +- Safari — App Store: ${SAFARI_APP_STORE_URL} + +## Frequently asked questions + +${faqSection} + +## Key pages + +- [Home](${origin}/): what Caramel is and how it works. +- [Pricing](${origin}/pricing): the plan structure — Caramel is free. +- [Coupons](${origin}/coupons): browse the full coupon catalog. +- [Store coupon pages](${origin}/coupons/amazon.com): per-store codes, one page + per store domain, e.g. /coupons/amazon.com or /coupons/nike.com. +- [Supported stores](${origin}/supported-stores): which stores Caramel can + auto-apply codes on. +- [Sources](${origin}/sources): the transparency page listing where Caramel's + coupon codes come from. +- [Privacy policy](${origin}/privacy): what data Caramel does and does not + collect. +- [Support](${origin}/support): contact the team. + +## Privacy summary + +Caramel never sells or shares personal information and ships no ads and no +third-party trackers. The extension talks only to Caramel's own servers: at +checkout on a supported store it fetches coupon codes for that store's domain, +sends 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. Settings and the optional sign-in are kept in the browser's extension +storage. The full policy is at ${origin}/privacy. + +## Community + +- GitHub: ${GITHUB_REPO_URL} +- Discord: ${DISCORD_INVITE_URL} +` + +export function GET(): Response { + return new Response(LLMS_FULL_TXT, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'public, max-age=3600', + }, + }) +} diff --git a/apps/caramel-app/src/app/llms.txt/route.ts b/apps/caramel-app/src/app/llms.txt/route.ts index f0cb48b0..221f68f3 100644 --- a/apps/caramel-app/src/app/llms.txt/route.ts +++ b/apps/caramel-app/src/app/llms.txt/route.ts @@ -54,6 +54,11 @@ const LLMS_TXT = `# Caramel - Firefox Add-ons: ${FIREFOX_ADDONS_URL} - Microsoft Edge Add-ons: ${EDGE_ADDONS_URL} - Safari (App Store): ${SAFARI_APP_STORE_URL} + +## Optional + +- [Full version](${origin}/llms-full.txt): the same facts plus the FAQ + questions and answers and a privacy summary, in one document. ` export function GET(): Response { diff --git a/apps/caramel-app/src/app/page.tsx b/apps/caramel-app/src/app/page.tsx index c3489242..ecc7874d 100644 --- a/apps/caramel-app/src/app/page.tsx +++ b/apps/caramel-app/src/app/page.tsx @@ -7,11 +7,14 @@ import OpenSourceSection from '@/components/OpenSourceSection' import SectionDivider from '@/components/SectionDivider' import SupportedSection from '@/components/SupportedSection' import WhyNotHoneySection from '@/components/WhyNot' +import { LLMS_TXT_ALTERNATE_TYPES } from '@/lib/seo/llmsTxtAlternate' import type { Metadata } from 'next' import { Suspense } from 'react' export const metadata: Metadata = { - alternates: { canonical: '/' }, + // `alternates` replaces the root layout's wholesale, so the llms.txt + // pointer has to be re-stated next to the canonical (see llmsTxtAlternate). + alternates: { canonical: '/', types: LLMS_TXT_ALTERNATE_TYPES }, } // Server component on purpose. The sections below are still client components diff --git a/apps/caramel-app/src/app/robots.ts b/apps/caramel-app/src/app/robots.ts index 2d73dad6..ae7af2c1 100644 --- a/apps/caramel-app/src/app/robots.ts +++ b/apps/caramel-app/src/app/robots.ts @@ -1,4 +1,5 @@ import { BASE_URL } from '@/lib/env.client' +import { AI_CRAWLERS } from '@/lib/seo/aiCrawlers' import type { MetadataRoute } from 'next' // The only origins that may be indexed. Every other BASE_URL a build can carry @@ -31,7 +32,18 @@ export default function robots(): MetadataRoute.Robots { } return { - rules: [{ userAgent: '*', allow: '/', disallow: DISALLOWED_PATHS }], + rules: [ + { userAgent: '*', allow: '/', disallow: DISALLOWED_PATHS }, + // Explicit AI-crawler allow group (src/lib/seo/aiCrawlers.ts). + // Same disallow set as `*`: the private paths stay private for + // answer engines too; the point is an unambiguous, named + // invitation for everything else. + { + userAgent: [...AI_CRAWLERS], + allow: '/', + disallow: DISALLOWED_PATHS, + }, + ], sitemap: `${origin}/sitemap.xml`, } } diff --git a/apps/caramel-app/src/components/FaqSection.tsx b/apps/caramel-app/src/components/FaqSection.tsx index 8f9f2a6b..d8f63812 100644 --- a/apps/caramel-app/src/components/FaqSection.tsx +++ b/apps/caramel-app/src/components/FaqSection.tsx @@ -1,3 +1,4 @@ +import { faqItems } from '@/lib/faqItems' import { FaChevronDown } from 'react-icons/fa' // Deliberately a SERVER component (no 'use client'): AI answer engines and @@ -5,48 +6,9 @@ import { FaChevronDown } from 'react-icons/fa' // be present in the server-rendered DOM with zero client JS. The accordion is // native
/ — accessible, keyboard-operable, and the collapsed // answers are still in the DOM. The FAQPage JSON-LD script is generated from -// the SAME array as the visible markup, so the two can never drift. -// -// 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. -const faqItems = [ - { - 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.', - }, -] +// the SAME array as the visible markup, so the two can never drift. The +// array itself (and its claim-integrity ledger) lives in src/lib/faqItems.ts +// so /llms-full.txt can render the same strings. const faqStructuredData = { '@context': 'https://schema.org', diff --git a/apps/caramel-app/src/layouts/Footer/Footer.tsx b/apps/caramel-app/src/layouts/Footer/Footer.tsx index f476198a..710a9b75 100644 --- a/apps/caramel-app/src/layouts/Footer/Footer.tsx +++ b/apps/caramel-app/src/layouts/Footer/Footer.tsx @@ -140,6 +140,13 @@ export default function Footer() { ))} + {/* Crawlable pointer at the answer-engine summary + (a route handler, so a plain , not ). */} +
  • + + llms.txt + +
  • 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')