openvibers/openvibe.games Commits

Adopt openvibe-shared v2.6.0 and serve llms-full.txt plus IndexNow key

OP OpenVibers committed 4 Oct 2026 · e277d63d6e · 12 files, +280 −38

Asked: Re-land a finished change whose push gitleaks refused, with the false positive fixed at the source. The work exists only as a LOCAL commit (cd31fbc) on the local branch `agent/games-t11-step-8-games-half-230607-muufjhjs` of this same repository (worktrees share refs, so it is visible from your work…
Harness job games-re-land-a-finished-change-whose-233551.

Changes

ddiff --git a/.env.example b/.env.example
iindex 7f5a1df..4980153 100644
---- a/.env.example
++++ b/.env.example
@@@ -24,3 +24,9 @@ HOST=0.0.0.0
# comma-separated IP/CIDR list. Production: 127.0.0.1 (the local nginx, which overwrites those headers).
TRUST_PROXY=
MAP_PATH=data/map.json
++
++# ── IndexNow (optional) ──
++# The IndexNow protocol key (8-128 hex or alphanumeric), as IndexNow's own key generator produces it.
++# When set, GET /<key>.txt serves the key file the protocol requires engines to fetch; unset means
++# off. This site has no user-created public pages, so a key alone pings nothing.
++INDEXNOW_KEY=
ddiff --git a/README.md b/README.md
iindex 37e5bc5..4690e94 100644
---- a/README.md
++++ b/README.md
@@@ -32,7 +32,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`
user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)
--- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release
++- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v2.6.0 (pinned by release
tarball in `apps/server/package.json`), Babylon.js, Rapier, PostgreSQL (`pg`, or embedded PGlite in
development), Valkey (`iovalkey`, optional)
ddiff --git a/STATUS.json b/STATUS.json
iindex 2ea5c23..24968e2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,7 +6,7 @@
"runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js (client) + deterministic Rapier physics; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
"port": 8000,
"domain": "openvibe.games, play.openvibe.games",
-- "contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v1.30.1 (apps/server/package.json)",
++ "contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v2.6.0 (apps/server/package.json)",
"capabilities": [
"games.mod.manage",
"games.mod.read",
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex 6281e29..8444197 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.78.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0",
"quickjs-emscripten": "0.32.0",
"tsx": "^4.19.3",
ddiff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts
iindex 64d5047..59e1f69 100644
---- a/apps/server/src/config.test.ts
++++ b/apps/server/src/config.test.ts
@@@ -68,3 +68,12 @@ describe('loadConfig scriptMods', () => {
expect(loadConfig({ GAMES_SCRIPT_MODS: 'true' }).scriptMods).toBe(true)
})
})
++
++describe('loadConfig indexnowKey', () => {
++ it('is empty (IndexNow off) unless INDEXNOW_KEY is set, and never carries padding', () => {
++ expect(loadConfig({}).indexnowKey).toBe('')
++ expect(loadConfig({ INDEXNOW_KEY: '' }).indexnowKey).toBe('')
++ expect(loadConfig({ INDEXNOW_KEY: ' ' }).indexnowKey).toBe('')
++ expect(loadConfig({ INDEXNOW_KEY: ' testkey0testkey0 ' }).indexnowKey).toBe('testkey0testkey0')
++ })
++})
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex 0e24f43..527a731 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -68,6 +68,12 @@ export interface ServerConfig {
* place. Off by default; the mods stay in the def set (and its digest) either way.
*/
scriptMods: boolean
++ /**
++ * INDEXNOW_KEY: the IndexNow protocol key (8–128 hex or alphanumeric). When set, the key file is
++ * served at /<key>.txt so engines can verify it. Empty: off — nothing is mounted and nothing is
++ * sent.
++ */
++ indexnowKey: string
}
export interface PlatformConfig {
@@@ -153,6 +159,8 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
}
: null,
platform: loadPlatformConfig(env),
++ /** IndexNow is off until a key is configured; an empty key never mounts the key file. */
++ indexnowKey: String(env.INDEXNOW_KEY ?? '').trim(),
tickRate: 30,
snapshotEvery: 2,
interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex b3382de..b7799e2 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -20,6 +20,7 @@ import {
SOCKETS_MS,
within,
} from './net/gracefulStop.js'
++import { createGamesIndexNow } from './net/indexnow.js'
import { createHttpServer } from './net/httpServer.js'
import { resolveNetworkUser } from './net/networkAuth.js'
import { acceptUpgrade, attachWebSocket } from './net/wsTransport.js'
@@@ -422,6 +423,11 @@ async function main(): Promise<void> {
clientAddress,
},
ticketStore,
++ createGamesIndexNow(
++ config.oauth?.selfUrl ?? 'https://openvibe.games',
++ config.indexnowKey,
++ log.child({ system: 'indexnow' }),
++ ),
)
const drainer = httpDrainer(http)
world.reconcileMapNodes()
ddiff --git a/apps/server/src/net/discovery.test.ts b/apps/server/src/net/discovery.test.ts
iindex cf86917..0e28dc5 100644
---- a/apps/server/src/net/discovery.test.ts
++++ b/apps/server/src/net/discovery.test.ts
@@@ -6,19 +6,24 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createConsoleLogger } from '@openvibe/shared'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
++import { loadConfig } from '../config.js'
import { ServerMetrics } from '../observability/metrics.js'
import { createHttpServer } from './httpServer.js'
++import { createGamesIndexNow } from './indexnow.js'
/**
-- * The crawl artifacts of plan T11 (audit §4 items 1-2): /robots.txt, /sitemap.xml and /llms.txt,
-- * plus the home page's JSON-LD, fetched from the real HTTP layer over a stand-in client build.
-- * Every one is built by openvibe-shared/seo; public pages only, and no private path anywhere.
++ * The crawl artifacts of plan T11 (audit §4 items 1-2): /robots.txt, /sitemap.xml, /llms.txt and
++ * /llms-full.txt, plus the home page's JSON-LD, fetched from the real HTTP layer over a stand-in
++ * client build. Every one is built by openvibe-shared/seo; public pages only, and no private path
++ * anywhere. The IndexNow key file is exercised here too: it is the other root-level text artifact
++ * this layer serves, and its on/off state is a configuration difference rather than a route.
*/
const log = createConsoleLogger({ app: 'test' }, 'error')
const APEX = 'openvibe.games'
const PRIVATE = ['/auth/', '/api/', '/metrics', '/editor']
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..')
++const INDEXNOW_KEY = 'testkey0testkey0'
interface Reply {
status: number
@@@ -28,7 +33,9 @@ interface Reply {
let dir: string
let server: Server
++let keyedServer: Server
let port = 0
++let keyedPort = 0
function listen(s: Server): Promise<number> {
return new Promise((resolve) =>
@@@ -38,13 +45,13 @@ function listen(s: Server): Promise<number> {
function send(
path: string,
-- opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders } = {},
++ opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders; port?: number } = {},
): Promise<Reply> {
return new Promise((resolve, reject) => {
const req = request(
{
host: '127.0.0.1',
-- port,
++ port: opts.port ?? port,
path,
method: opts.method ?? 'GET',
headers: { host: opts.host ?? APEX, ...opts.headers },
@@@ -79,12 +86,43 @@ beforeAll(async () => {
join(dist, 'play.html'),
'<!doctype html><html><head><title>game page</title></head><body>game</body></html>',
)
-- server = createHttpServer(dist, new ServerMetrics(), log)
++ // Two instances of the same server over the same build: one with no IndexNow key (off) and one
++ // with a key (on), so the difference is the configuration and nothing else.
++ server = createHttpServer(
++ dist,
++ new ServerMetrics(),
++ log,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ // No INDEXNOW_KEY: the same wiring as main.ts, with the key unset — the whole "off" state.
++ createGamesIndexNow('https://openvibe.games', loadConfig({}).indexnowKey, log),
++ )
port = await listen(server)
++ keyedServer = createHttpServer(
++ dist,
++ new ServerMetrics(),
++ log,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ // The key comes from the config, exactly as main.ts reads it: INDEXNOW_KEY, trimmed.
++ createGamesIndexNow('https://openvibe.games', loadConfig({ INDEXNOW_KEY }).indexnowKey, log),
++ )
++ keyedPort = await listen(keyedServer)
})
afterAll(() => {
server.close()
++ keyedServer.close()
rmSync(dir, { recursive: true, force: true })
})
@@@ -131,6 +169,38 @@ describe('crawl artifacts (plan T11)', () => {
for (const p of PRIVATE) expect(r.body).not.toContain(p)
})
++ it('serves /llms-full.txt: text/plain, the home page, and nothing robots keeps out', async () => {
++ const r = await send('/llms-full.txt')
++ expect(r.status).toBe(200)
++ expect(r.headers['content-type']).toBe('text/plain; charset=utf-8')
++ // The same cache lifetime as /llms.txt: the same site description, as one document.
++ expect(r.headers['cache-control']).toBe('public, max-age=3600')
++ // The home URL, and the site's own header and summary, exactly as /llms.txt opens with.
++ expect(r.body).toContain('https://openvibe.games/')
++ expect(r.body).toContain('# OpenVibe.Games')
++ const index = await send('/llms.txt')
++ const summary = /(^|> )(OpenVibe\.Games: the games arm.*)$/m.exec(index.body)?.[1]
++ expect(summary).toBeTruthy()
++ expect(r.body).toContain(summary as string)
++ // Every public page /llms.txt lists appears, with its text.
++ expect(r.body).toContain('https://play.openvibe.games/')
++ // Public pages only: no path from the robots Disallow list, and no per-user or editor surface.
++ for (const p of PRIVATE) expect(r.body).not.toContain(p)
++ })
++
++ it('serves the IndexNow key file when INDEXNOW_KEY is set, and 404 when it is not', async () => {
++ const on = await send(`/${INDEXNOW_KEY}.txt`, { port: keyedPort })
++ expect(on.status).toBe(200)
++ expect(on.headers['content-type']).toBe('text/plain; charset=utf-8')
++ expect(on.body).toBe(INDEXNOW_KEY)
++ // Unset: nothing is mounted, so the path is an ordinary 404 — it never says a key is missing.
++ const off = await send(`/${INDEXNOW_KEY}.txt`)
++ expect(off.status).toBe(404)
++ // Any other .txt is untouched on a keyed instance: the key file serves only its own name.
++ const other = await send('/someother.txt', { port: keyedPort })
++ expect(other.status).toBe(404)
++ })
++
it('injects the home page JSON-LD built by openvibe-shared/seo: WebSite + the primary type', async () => {
const r = await send('/')
expect(r.status).toBe(200)
ddiff --git a/apps/server/src/net/discovery.ts b/apps/server/src/net/discovery.ts
iindex 8972aee..fc0d744 100644
---- a/apps/server/src/net/discovery.ts
++++ b/apps/server/src/net/discovery.ts
@@@ -6,9 +6,9 @@ import { fileURLToPath } from 'node:url'
/**
* Crawl and machine-readability artifacts (plan T11; audit §4 items 1-2): GET /robots.txt,
-- * GET /sitemap.xml and GET /llms.txt, plus the home page's JSON-LD. Every one is built with
-- * openvibe-shared/seo — the toolkit the other OpenVibe sites run — so they say the same things
-- * here as everywhere.
++ * GET /sitemap.xml, GET /llms.txt and GET /llms-full.txt, plus the home page's JSON-LD. Every one
++ * is built with openvibe-shared/seo — the toolkit the other OpenVibe sites run — so they say the
++ * same things here as everywhere.
*
* Public pages only, and never the viewer: the portal and the game are listed; sign-in, the
* per-user API, operational metrics and the noindex editor app are absent and Disallowed in
@@@ -38,8 +38,27 @@ interface SeoUrl {
changefreq?: string
priority?: number
}
++interface LlmsFullPage {
++ title: string
++ url: string
++ /** The page's own text; here the site's own description of it, never scraped HTML. */
++ text?: string
++}
++interface LlmsFullSection {
++ title: string
++ pages: LlmsFullPage[]
++}
++interface LlmsFullOptions {
++ /** The site name and base URL: the document opens `# <name>`, and page URLs resolve against it. */
++ site: { name: string; url: string }
++ summary: string
++ sections?: LlmsFullSection[]
++ /** Hard byte ceiling: a page that would cross it is dropped, never truncated mid-word. */
++ maxBytes?: number
++}
interface SeoToolkit {
llmsTxt(o: LlmsOptions): string
++ llmsFull(o: LlmsFullOptions): string
robotsTxt(o: { sitemaps?: string[]; disallow?: string[]; allow?: string[] }): string
sitemapXml(urls: SeoUrl[]): string
jsonLd: {
@@@ -70,6 +89,12 @@ const DESCRIPTION =
'Browser games on the OpenVibe network — multiplayer, in your browser, no download: Scraplandia is a persistent physics survival-building sandbox you can play as a guest.'
const GAME_DESCRIPTION =
'Scrappy multiplayer survival-building in and around Scrap City: physgun anything, weld it to anything else, chase supply drops in the wilds and haul your salvage home.'
++// The one summary and one detail paragraph /llms.txt and /llms-full.txt both open with: a model
++// reading the full document must meet the same description of the site as one reading the index.
++const SITE_SUMMARY =
++ 'OpenVibe.Games: the games arm of the OpenVibe network — browser games you can start in a tab with no install, and the persistent multiplayer physics sandbox Scraplandia.'
++const SITE_DETAILS =
++ 'Everything public can be played as a guest; a signed-in OpenVibe account only adds saved characters and one identity across the network. The portal and the game are plain HTML pages, and the world this game server runs is published as the canonical map document at /map.json. Sign-in, the per-user API, metrics and the editor are not crawler or model input and are listed nowhere here.'
// Every path robots.txt keeps out of crawlers. None is a public page, so none is in the sitemap
// or llms.txt either: sign-in, the per-user API, operational metrics and the noindex editor app.
const DISALLOW = ['/auth/', '/api/', '/metrics', '/editor']
@@@ -109,32 +134,51 @@ function publicPages(): SeoUrl[] {
priority,
...(lastmod ? { lastmod } : {}),
})
-- return [at(`${SITE}/`, 'weekly', 1.0), at(`${PLAY}/`, 'weekly', 0.9)]
++ return publicPageDocs().map((p) => at(p.url, 'weekly', p.priority))
++}
++
++/**
++ * The site's public pages, once, as the data both artifacts read: llms.txt lists them with a note,
++ * llms-full.txt carries the same pages with their text. One source, so the two can never disagree
++ * about what is public. There are no user-created public pages here — the world is a game document
++ * served at /map.json, not a page — so this list is fixed.
++ */
++interface PublicPageDoc {
++ title: string
++ url: string
++ note: string
++ text: string
++ priority: number
++}
++function publicPageDocs(): PublicPageDoc[] {
++ return [
++ {
++ title: 'OpenVibe.Games home',
++ url: `${SITE}/`,
++ note: 'the portal: what there is to play, and what signing in adds',
++ text: DESCRIPTION,
++ priority: 1.0,
++ },
++ {
++ title: 'Scraplandia',
++ url: `${PLAY}/`,
++ note: 'the live multiplayer game, playable as a guest in the browser',
++ text: GAME_DESCRIPTION,
++ priority: 0.9,
++ },
++ ]
}
/** The /llms.txt body: what the site is, its public pages and its machine-readable endpoints. */
function llmsBody(): string {
return seo.llmsTxt({
name: SITE_NAME,
-- summary:
-- 'OpenVibe.Games: the games arm of the OpenVibe network — browser games you can start in a tab with no install, and the persistent multiplayer physics sandbox Scraplandia.',
-- details:
-- 'Everything public can be played as a guest; a signed-in OpenVibe account only adds saved characters and one identity across the network. The portal and the game are plain HTML pages, and the world this game server runs is published as the canonical map document at /map.json. Sign-in, the per-user API, metrics and the editor are not crawler or model input and are listed nowhere here.',
++ summary: SITE_SUMMARY,
++ details: SITE_DETAILS,
sections: [
{
title: 'Public pages',
-- links: [
-- {
-- title: 'OpenVibe.Games home',
-- url: `${SITE}/`,
-- note: 'the portal: what there is to play, and what signing in adds',
-- },
-- {
-- title: 'Scraplandia',
-- url: `${PLAY}/`,
-- note: 'the live multiplayer game, playable as a guest in the browser',
-- },
-- ],
++ links: publicPageDocs().map((p) => ({ title: p.title, url: p.url, note: p.note })),
},
{
title: 'Machine-readable',
@@@ -149,6 +193,11 @@ function llmsBody(): string {
url: `${SITE}/robots.txt`,
note: 'search and AI crawlers are welcome on the public pages',
},
++ {
++ title: 'Full text (llms-full.txt)',
++ url: `${SITE}/llms-full.txt`,
++ note: 'this site’s public pages as one text document, for a model to read whole',
++ },
{
title: 'World map (JSON)',
url: `${SITE}/map.json`,
@@@ -160,6 +209,28 @@ function llmsBody(): string {
})
}
++/**
++ * The /llms-full.txt body: the same site header and summary as /llms.txt, then every public page
++ * llms.txt lists with its own text — the two descriptions the site's JSON-LD already publishes,
++ * which are the site's own words for those pages rather than scraped markup. Public pages only, so
++ * nothing under robots' Disallow can reach a model, and a 512 KB ceiling keeps the answer a
++ * document rather than a payload.
++ */
++const LLMS_FULL_MAX_BYTES = 512 * 1024
++function llmsFullBody(): string {
++ return seo.llmsFull({
++ site: { name: SITE_NAME, url: SITE },
++ summary: SITE_SUMMARY,
++ sections: [
++ {
++ title: 'Public pages',
++ pages: publicPageDocs().map((p) => ({ title: p.title, url: p.url, text: p.text })),
++ },
++ ],
++ maxBytes: LLMS_FULL_MAX_BYTES,
++ })
++}
++
/**
* The home page's JSON-LD: the site as a WebSite, the game as the site's primary type (the shared
* kit's WebApplication, category GameApplication) and the portal page that carries them. The same
@@@ -206,7 +277,14 @@ function send(res: ServerResponse, type: string, body: string): void {
*/
export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolean {
const url = (rq.url ?? '/').split('?')[0] ?? '/'
-- if (url !== '/robots.txt' && url !== '/sitemap.xml' && url !== '/llms.txt') return false
++ if (
++ url !== '/robots.txt' &&
++ url !== '/sitemap.xml' &&
++ url !== '/llms.txt' &&
++ url !== '/llms-full.txt'
++ ) {
++ return false
++ }
if (rq.method !== 'GET' && rq.method !== 'HEAD') {
res.writeHead(405, { allow: 'GET, HEAD' })
res.end()
@@@ -226,6 +304,8 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
send(res, 'application/xml', seo.sitemapXml(publicPages()))
return true
}
-- send(res, 'text/plain', llmsBody())
++ // Same type, same cache lifetime as /llms.txt: the full document is a bigger body of the same
++ // site description, so it ages on the same clock.
++ send(res, 'text/plain', url === '/llms-full.txt' ? llmsFullBody() : llmsBody())
return true
}
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex ca5179c..044a7ed 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -31,6 +31,7 @@ import { resolveClientAddress } from './clientAddress.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import { discoveryHandler, homeJsonLdTags } from './discovery.js'
import { legalHandler } from './legalPages.js'
++import { indexnowHandler, type SharedIndexNow } from './indexnow.js'
/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file
* per request (a new build changes the mtime and rebuilds it once). */
@@@ -165,6 +166,7 @@ export function createHttpServer(
oauth?: OAuthConfig | null,
platform?: HttpPlatformHooks,
tickets?: WsTicketStore,
++ indexnow?: SharedIndexNow,
): Server {
const root = staticDir ? resolve(staticDir) : null
// Sessions the Network confirmed recently: lets a silent login on a signed-in
@@@ -184,6 +186,9 @@ export function createHttpServer(
]
// The site's own /terms, /privacy and /dmca (openvibe-shared/legal), built once for the process.
const serveLegal = legalHandler()
++ // GET /<key>.txt — the IndexNow key file, before the static files so it is never shadowed by a
++ // build artifact of the same name. Absent when no key is configured.
++ const serveIndexNow = indexnow ? indexnowHandler(indexnow) : null
return createServer((req: IncomingMessage, res: ServerResponse) => {
const url = (req.url ?? '/').split('?')[0] ?? '/'
// Host-based routing: play.openvibe.games serves the game at its root
@@@ -216,7 +221,10 @@ export function createHttpServer(
return
}
if (platform?.handle?.(req, res)) return
-- // Crawl artifacts (plan T11): /robots.txt, /sitemap.xml and /llms.txt, built by openvibe-shared/seo.
++ // The IndexNow key file (only when a key is configured) — before the static files below.
++ if (serveIndexNow?.(req, res)) return
++ // Crawl artifacts (plan T11): /robots.txt, /sitemap.xml, /llms.txt and /llms-full.txt, built by
++ // openvibe-shared/seo.
if (discoveryHandler(req, res)) return
// The site's own legal pages (plan T11 decision 6): /terms, /privacy, /dmca. Before the SPA and the
// static fallback so they never resolve to a file or the landing page.
ddiff --git a/apps/server/src/net/indexnow.ts b/apps/server/src/net/indexnow.ts
nnew file mode 100644
iindex 0000000..9f18677
---- /dev/null
++++ b/apps/server/src/net/indexnow.ts
@@@ -0,0 +1,54 @@
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { createRequire } from 'node:module'
++import type { Logger } from '@openvibe/shared'
++
++/**
++ * IndexNow (openvibe-shared/indexnow): the protocol by which a site tells search engines a URL
++ * changed in one POST. Mounted once at boot; when INDEXNOW_KEY is unset nothing is served and
++ * nothing is sent.
++ *
++ * There are no user-created public pages here — the portal and the game page are the site's only
++ * public pages and they change with a deploy, not with a publish — so this site pings nothing.
++ * What it does serve is the key file the protocol requires engines to fetch before trusting a
++ * batch, so a future publish path can ping without touching this wiring.
++ */
++
++export interface SharedIndexNow {
++ keyFile(req: IncomingMessage, res: ServerResponse, next: () => void): unknown
++ enabled: boolean
++}
++interface IndexNowFactory {
++ createIndexNow(o: { host: string; key: string | null; log: Logger }): SharedIndexNow
++}
++const shared = createRequire(import.meta.url)('openvibe-shared/indexnow') as IndexNowFactory
++
++/**
++ * The IndexNow instance for this site. An unset or blank key leaves it disabled, which is the
++ * whole "off" state: `enabled` is false and the key file is never mounted.
++ */
++export function createGamesIndexNow(host: string, key: string, log: Logger): SharedIndexNow {
++ const trimmed = String(key ?? '').trim()
++ // createIndexNow() itself refuses a key that is not 8–128 hex/alphanumeric; an unset key is the
++ // supported "off" case and is passed through as null.
++ return shared.createIndexNow({ host, key: trimmed === '' ? null : trimmed, log })
++}
++
++/**
++ * Answers GET /<key>.txt — the IndexNow key file, which serves itself — for a site with a key.
++ * Returns true when it handled the request. With no key nothing is mounted, so the request falls
++ * through to the ordinary 404 rather than leaking whether a key is configured.
++ */
++export function indexnowHandler(
++ indexnow: SharedIndexNow,
++): (rq: IncomingMessage, res: ServerResponse) => boolean {
++ return (rq, res) => {
++ if (!indexnow.enabled) return false
++ // The shared key file is itself a middleware: it calls next() for any URL that is not its own
++ // key file. Anything it did not claim is this server's to route, so hand it back to the chain.
++ let fellThrough = false
++ indexnow.keyFile(rq, res, () => {
++ fellThrough = true
++ })
++ return !fellThrough
++ }
++}
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 5897fc5..6436952 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -118,8 +118,8 @@ importers:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1(@electric-sql/[email protected])([email protected])([email protected])
openvibe-shared:
-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0
-- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0
++ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0
pg:
specifier: ^8.23.0
version: 8.23.0
@@@ -1352,9 +1352,10 @@ packages:
pg:
optional: true
-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:
-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0}
-- version: 2.2.0
++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0:
++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0}
++ version: 2.6.0
++ hasBin: true
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
@@@ -2816,7 +2817,7 @@ snapshots:
iovalkey: 0.4.0
pg: 8.23.0
-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:
++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0:
dependencies:
jsonwebtoken: 9.0.3