diff --git a/apps/landing/src/__tests__/indexnow.test.ts b/apps/landing/src/__tests__/indexnow.test.ts new file mode 100644 index 00000000..8e046dda --- /dev/null +++ b/apps/landing/src/__tests__/indexnow.test.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { INDEXNOW_KEY, INDEXNOW_KEY_PATH } from '@/lib/indexnow' +import { GET } from '../app/5cb30cbda540958e8d033652400e59e3.txt/route' + +// IndexNow verification hinges on one brittle coupling: the App Router +// directory that serves the key file is NAMED after the key, so a change to +// the constant without the matching directory rename (or vice versa) leaves +// the site serving one key while claiming another — and every submission an +// engine receives fails ownership. These pin the two to each other, plus the +// response shape indexnow.org requires (exact body, text/plain). +describe('IndexNow key file served by the landing app', () => { + it('key constant is 32 lowercase hexadecimal characters as IndexNow requires', () => { + expect(INDEXNOW_KEY).toMatch(/^[0-9a-f]{32}$/) + }) + + it('key path constant is the key served as a root-level .txt file', () => { + expect(INDEXNOW_KEY_PATH).toBe(`/${INDEXNOW_KEY}.txt`) + }) + + it('route directory on disk is named exactly after the key constant', () => { + const appDir = fileURLToPath(new URL('../app/', import.meta.url)) + expect(existsSync(`${appDir}${INDEXNOW_KEY}.txt/route.ts`)).toBe(true) + }) + + it('GET responds with the bare key as plain text and nothing else', async () => { + const response = GET() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'text/plain; charset=utf-8', + ) + await expect(response.text()).resolves.toBe(INDEXNOW_KEY) + }) +}) diff --git a/apps/landing/src/app/5cb30cbda540958e8d033652400e59e3.txt/route.ts b/apps/landing/src/app/5cb30cbda540958e8d033652400e59e3.txt/route.ts new file mode 100644 index 00000000..ff8c6eb1 --- /dev/null +++ b/apps/landing/src/app/5cb30cbda540958e8d033652400e59e3.txt/route.ts @@ -0,0 +1,15 @@ +import { INDEXNOW_KEY } from '@/lib/indexnow' + +// The IndexNow ownership key file. The directory name above is the key and +// must match INDEXNOW_KEY exactly — src/__tests__/indexnow.test.ts pins it. +// Static: the body is a constant, so there is nothing to compute per request. +export const dynamic = 'force-static' + +export function GET() { + return new Response(INDEXNOW_KEY, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'public, max-age=86400', + }, + }) +} diff --git a/apps/landing/src/lib/indexnow.ts b/apps/landing/src/lib/indexnow.ts new file mode 100644 index 00000000..7c8789f1 --- /dev/null +++ b/apps/landing/src/lib/indexnow.ts @@ -0,0 +1,17 @@ +// IndexNow (https://www.indexnow.org/documentation) lets a site push URL +// changes to participating engines (Bing, Yandex, Seznam, Naver) instead of +// waiting to be recrawled. Ownership is proved by hosting a key file: a plain +// text file at `https:///.txt` whose body is EXACTLY the key, served +// as `text/plain`. An engine fetches it when a submission arrives; a missing, +// redirected, or differently-bodied file makes every submission fail. +// +// This module is the single source of truth for that key. The route directory +// `src/app/.txt/` must stay byte-identical to INDEXNOW_KEY — a rename in +// one place and not the other silently breaks verification, so +// `src/__tests__/indexnow.test.ts` pins the two together. +// +// Submissions are NOT made from this app. Serving the key is the prerequisite; +// pushing URLs is an operational step run separately, after this deploys. +export const INDEXNOW_KEY = '5cb30cbda540958e8d033652400e59e3' + +export const INDEXNOW_KEY_PATH = `/${INDEXNOW_KEY}.txt`