Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/caramel-app/e2e/seo-regression.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 /<key>.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)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { indexNowKeyResponse } from '@/lib/seo/indexnow'

// /<key>.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()
}
53 changes: 53 additions & 0 deletions apps/caramel-app/src/lib/seo/indexnow.ts
Original file line number Diff line number Diff line change
@@ -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/<store>) 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://<host>/<key>.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/<key>.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/<key>.txt to match,
* then re-run the unit pin.
*/
export const INDEXNOW_KEY = 'd5c0ad54cc4dc724a423fa3e9d273f7f'

/** Path IndexNow fetches to verify host ownership: `/<key>.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',
},
})
}
79 changes: 79 additions & 0 deletions apps/caramel-app/tests/unit/indexnow-key-file.test.ts
Original file line number Diff line number Diff line change
@@ -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://<host>/<key>.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 /<key>.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)
})
})
Loading