diff --git a/apps/caramel-app/e2e/seo-regression.spec.ts b/apps/caramel-app/e2e/seo-regression.spec.ts index ac55dffa..b7a8a6bb 100644 --- a/apps/caramel-app/e2e/seo-regression.spec.ts +++ b/apps/caramel-app/e2e/seo-regression.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test' +import { INDEXNOW_KEY, INDEXNOW_KEY_PATH } from '../src/lib/seo/indexnow' // SEO regression gate — everything here reads the RAW server HTML via // page.request.get (no JS execution), so it asserts exactly what a crawler @@ -297,4 +298,19 @@ test.describe('SEO regression gate (raw server HTML)', () => { const body = await res.text() expect(body).toContain('Caramel') }) + + test('IndexNow key file is served verbatim at /.txt', async ({ + page, + }) => { + // IndexNow (Bing/Yandex/Seznam/Naver) validates a submission by + // fetching this exact path and comparing the body to the key. The + // constant is imported RELATIVELY: src/lib/seo/indexnow.ts is + // alias-free and env-free precisely so a spec can reach it without + // the `@/` tsconfig alias Playwright does not resolve. + const res = await page.request.get(INDEXNOW_KEY_PATH) + expect(res.status()).toBe(200) + expect(res.headers()['content-type']).toContain('text/plain') + const body = await res.text() + expect(body.trim()).toBe(INDEXNOW_KEY) + }) }) diff --git a/apps/caramel-app/src/app/d5c0ad54cc4dc724a423fa3e9d273f7f.txt/route.ts b/apps/caramel-app/src/app/d5c0ad54cc4dc724a423fa3e9d273f7f.txt/route.ts new file mode 100644 index 00000000..d73796ae --- /dev/null +++ b/apps/caramel-app/src/app/d5c0ad54cc4dc724a423fa3e9d273f7f.txt/route.ts @@ -0,0 +1,16 @@ +import { indexNowKeyResponse } from '@/lib/seo/indexnow' + +// /.txt — the IndexNow host-ownership proof for grabcaramel.com. +// +// The directory name IS the key: IndexNow fetches this exact path and expects +// the body to be the same key, so the two are kept in step by importing the +// one constant (see lib/seo/indexnow for why the key is public) and pinned +// by tests/unit/indexnow-key-file.test.ts. +// +// Deliberately NOT a `withRoute` handler, for the same reason as +// src/app/llms.txt/route.ts: withRoute owns the /api surface. This is a +// static public text asset in the robots.ts / sitemap.ts / llms.txt family — +// no request input, no auth, no DB. +export function GET(): Response { + return indexNowKeyResponse() +} diff --git a/apps/caramel-app/src/lib/seo/indexnow.ts b/apps/caramel-app/src/lib/seo/indexnow.ts new file mode 100644 index 00000000..d840b2a9 --- /dev/null +++ b/apps/caramel-app/src/lib/seo/indexnow.ts @@ -0,0 +1,53 @@ +/** + * IndexNow key for grabcaramel.com. + * + * IndexNow (https://www.indexnow.org) is the open ping protocol shared by + * Bing, Yandex, Seznam and Naver: one POST to api.indexnow.org tells all of + * them that a URL changed, instead of waiting for the next organic crawl. + * That is the legitimate push channel for the 4,000+ per-store coupon pages + * (/coupons/) that the engines are slow to discover on their own. + * + * WHY the key is committed in plain sight: the protocol authenticates a + * submission by requiring the SAME key to be readable at + * `https:///.txt`. The key is therefore public BY DESIGN — it is + * served verbatim to anyone who asks — and putting it in the env layer would + * buy no secrecy. It is a host-ownership proof, not a credential: the only + * thing holding it lets anyone do is ask a search engine to re-crawl a URL + * that is already on our own public site. + * + * WHY a route handler instead of `public/.txt`: this keeps the key a + * single typed constant with compile-time consumers, so rotating it is one + * edit that the CI pin (tests/unit/indexnow-key-file.test.ts) re-checks, + * rather than a filename and a file body that can silently disagree. + * + * Submission is NOT done from the app. The SEO owner submits URL changes + * through the fleet seo-mcp (`indexing_submit method=index_now`); this + * module only serves the ownership proof. + * + * Env-free and alias-free on purpose: the e2e spec imports it relatively + * (Playwright does not resolve the `@/` tsconfig alias for spec files). + * + * To rotate: change the value here AND rename src/app/.txt to match, + * then re-run the unit pin. + */ +export const INDEXNOW_KEY = 'd5c0ad54cc4dc724a423fa3e9d273f7f' + +/** Path IndexNow fetches to verify host ownership: `/.txt`. */ +export const INDEXNOW_KEY_PATH = `/${INDEXNOW_KEY}.txt` + +/** + * The verification file's body is the key and nothing else — no trailing + * newline, because validators compare the fetched body to the key after at + * most a trim, and an exact match is the only form that is safe everywhere. + */ +export function indexNowKeyResponse(): Response { + return new Response(INDEXNOW_KEY, { + status: 200, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + // The value only changes on deploy, and IndexNow re-fetches it + // on every submission, so a day at the edge is safe. + 'Cache-Control': 'public, max-age=86400', + }, + }) +} diff --git a/apps/caramel-app/tests/unit/indexnow-key-file.test.ts b/apps/caramel-app/tests/unit/indexnow-key-file.test.ts new file mode 100644 index 00000000..6449227f --- /dev/null +++ b/apps/caramel-app/tests/unit/indexnow-key-file.test.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +import { + INDEXNOW_KEY, + INDEXNOW_KEY_PATH, + indexNowKeyResponse, +} from '@/lib/seo/indexnow' + +// CI pin for the IndexNow host-ownership proof. +// +// IndexNow accepts a submission only if the key it is handed is also readable +// at `https:///.txt`. The path is a LITERAL directory name under +// src/app, so nothing type-checks it against the constant: a rotation that +// edits one and not the other builds green and fails every submission at the +// search engine, far from the change that caused it. Hence this pin, which +// checks the directory on disk against the exported constant. + +const APP_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../src/app', +) + +/** IndexNow's stated constraint: 8-128 chars of [A-Za-z0-9-]. */ +const INDEXNOW_KEY_SHAPE = /^[A-Za-z0-9-]{8,128}$/ + +/** + * A key-file route, for spotting one left behind by a half-finished rotation. + * Narrower than the protocol allows on purpose: our keys are generated as + * lowercase hex (`randomBytes(16).toString('hex')`), so this cannot mistake a + * named text route (llms.txt) for an abandoned key. A rotation to some other + * alphabet must widen this. + */ +const KEY_FILE_ROUTE = /^[0-9a-f]{8,128}\.txt$/ + +describe('IndexNow key file (src/lib/seo/indexnow.ts)', () => { + it('holds a key IndexNow will accept', () => { + expect(INDEXNOW_KEY).toMatch(INDEXNOW_KEY_SHAPE) + }) + + it('derives the fetched path from the key', () => { + expect(INDEXNOW_KEY_PATH).toBe(`/${INDEXNOW_KEY}.txt`) + }) + + it('routes the key file at exactly /.txt, with no stale twin', () => { + const textRoutes = fs + .readdirSync(APP_DIR, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && entry.name.endsWith('.txt')) + .map(entry => entry.name) + + expect(textRoutes).toContain(`${INDEXNOW_KEY}.txt`) + expect(textRoutes.filter(name => KEY_FILE_ROUTE.test(name))).toEqual([ + `${INDEXNOW_KEY}.txt`, + ]) + + const handler = fs.readFileSync( + path.join(APP_DIR, `${INDEXNOW_KEY}.txt`, 'route.ts'), + 'utf8', + ) + expect(handler).toContain("from '@/lib/seo/indexnow'") + expect(handler).toContain('indexNowKeyResponse()') + }) + + it('answers with the key as the whole body', async () => { + const served = indexNowKeyResponse() + + expect(served.status).toBe(200) + expect(served.headers.get('content-type')).toBe( + 'text/plain; charset=utf-8', + ) + expect(served.headers.get('cache-control')).toBe( + 'public, max-age=86400', + ) + // Verbatim: no trailing newline, no BOM, nothing to trim. + await expect(served.text()).resolves.toBe(INDEXNOW_KEY) + }) +})