openvibers/openvibe.games Commits

Release manifest and private metrics (Track R D43, Track O): GET /release.json answers the deployed commit (RELEASE_SHA, else read from .git without a child process), openvibe-sdk/contracts versions and components in the shape every OpenVibe service serves; GET /metrics answers only direct loopback callers (it was public through the proxy: tick, sessions, entities). observability/release.ts + test

OP OpenVibers committed 24 Sept 2026 · 35a06a07cd · 4 files, +173 −0


Changes

ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 16fb2bc..4080779 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -20,6 +20,7 @@ import { resolveNetworkUser } from './net/networkAuth.js'
import { attachWebSocket } from './net/wsTransport.js'
import { ServerMetrics } from './observability/metrics.js'
import { createReadiness } from './observability/readiness.js'
++import { buildRelease, releaseHandler } from './observability/release.js'
import { ModRegistry } from './mods/registry.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
@@@ -153,6 +154,9 @@ async function main(): Promise<void> {
mods: modRuntime,
})
++ // GET /release.json (D43): the deployed commit and package versions, as every OpenVibe service serves it.
++ const serveRelease = releaseHandler(buildRelease(process.cwd()))
++
// GET /api/ready: world.db answers and the simulation ticks (/healthz stays liveness only).
const schemaVersion = store.db.prepare('SELECT value FROM meta WHERE key = ?')
const readiness = createReadiness({
@@@ -208,6 +212,7 @@ async function main(): Promise<void> {
{
handle: (req, res) => {
if (readiness.handle(req, res)) return true
++ if (serveRelease(req, res)) return true
if ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {
// Operational status of the platform adapters; never secrets.
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex f2f8afa..7c525f5 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -4,6 +4,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, extname, join, normalize, resolve } from 'node:path'
import type { Logger } from '@openvibe/shared'
import type { ServerMetrics } from '../observability/metrics.js'
++import { isDirectLoopback } from '../observability/release.js'
import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
import {
LOGIN_STATE_COOKIE,
@@@ -615,6 +616,11 @@ export function createHttpServer(
return
}
if (url === '/metrics') {
++ // Operational numbers are for this machine's scrapers only (Track O), never the public site.
++ if (!isDirectLoopback(req)) {
++ notFound()
++ return
++ }
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(metrics.snapshot()))
return
ddiff --git a/apps/server/src/observability/release.test.ts b/apps/server/src/observability/release.test.ts
nnew file mode 100644
iindex 0000000..799a0ba
---- /dev/null
++++ b/apps/server/src/observability/release.test.ts
@@@ -0,0 +1,57 @@
++import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
++import { tmpdir } from 'node:os'
++import { join } from 'node:path'
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { describe, expect, it } from 'vitest'
++import { buildRelease, gitCommit, isDirectLoopback, releaseHandler } from './release.js'
++
++const SHA = 'a'.repeat(40)
++function checkout(kind: 'loose' | 'packed' | 'detached'): string {
++ const root = mkdtempSync(join(tmpdir(), 'games-release-'))
++ mkdirSync(join(root, '.git', 'refs', 'heads'), { recursive: true })
++ mkdirSync(join(root, 'apps', 'server'), { recursive: true })
++ if (kind === 'detached') writeFileSync(join(root, '.git', 'HEAD'), `${SHA}\n`)
++ else writeFileSync(join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n')
++ if (kind === 'loose') writeFileSync(join(root, '.git', 'refs', 'heads', 'main'), `${SHA}\n`)
++ if (kind === 'packed') writeFileSync(join(root, '.git', 'packed-refs'), `# pack-refs\n${SHA} refs/heads/main\n`)
++ return root
++}
++
++describe('release manifest (D43)', () => {
++ it('reads the checked-out commit from a loose ref, packed-refs or a detached HEAD, from a subdirectory too', () => {
++ for (const kind of ['loose', 'packed', 'detached'] as const) {
++ const root = checkout(kind)
++ expect(gitCommit(root)).toBe(SHA)
++ expect(gitCommit(join(root, 'apps', 'server'))).toBe(SHA)
++ }
++ expect(gitCommit(mkdtempSync(join(tmpdir(), 'no-git-')))).toBeNull()
++ })
++
++ it('builds the shared manifest shape; RELEASE_SHA wins', () => {
++ const m = buildRelease(checkout('loose'), { env: {}, now: () => new Date('2026-09-24T00:00:00Z') })
++ expect(m.service).toBe('games')
++ expect(m.release).toBe(SHA.slice(0, 12))
++ expect(m.components.server).toEqual({ kind: 'server', version: SHA.slice(0, 12) })
++ expect(m.booted_at).toBe('2026-09-24T00:00:00.000Z')
++ expect(buildRelease('/nowhere', { env: { RELEASE_SHA: 'b'.repeat(40) } }).release).toBe('b'.repeat(12))
++ })
++
++ it('answers GET /release.json only', () => {
++ const handle = releaseHandler(buildRelease('/nowhere', { env: { RELEASE_SHA: 'c'.repeat(40) } }))
++ const out: { status?: number; body?: string | undefined } = {}
++ const res = { writeHead: (s: number) => { out.status = s }, end: (b?: string) => { out.body = b } } as unknown as ServerResponse
++ expect(handle({ url: '/api/x', method: 'GET' } as IncomingMessage, res)).toBe(false)
++ expect(handle({ url: '/release.json?x=1', method: 'GET' } as IncomingMessage, res)).toBe(true)
++ expect(out.status).toBe(200)
++ expect(JSON.parse(out.body ?? '{}').release).toBe('c'.repeat(12))
++ })
++
++ it('metrics are for direct loopback callers only', () => {
++ const req = (addr: string, headers: Record<string, string> = {}) => ({ socket: { remoteAddress: addr }, headers }) as unknown as IncomingMessage
++ expect(isDirectLoopback(req('127.0.0.1'))).toBe(true)
++ expect(isDirectLoopback(req('::1'))).toBe(true)
++ expect(isDirectLoopback(req('127.0.0.1', { 'x-forwarded-for': '1.2.3.4' }))).toBe(false)
++ expect(isDirectLoopback(req('127.0.0.1', { 'x-real-ip': '1.2.3.4' }))).toBe(false)
++ expect(isDirectLoopback(req('203.0.113.9'))).toBe(false)
++ })
++})
ddiff --git a/apps/server/src/observability/release.ts b/apps/server/src/observability/release.ts
nnew file mode 100644
iindex 0000000..109df82
---- /dev/null
++++ b/apps/server/src/observability/release.ts
@@@ -0,0 +1,105 @@
++import { existsSync, readFileSync } from 'node:fs'
++import { createRequire } from 'node:module'
++import { dirname, join } from 'node:path'
++import type { IncomingMessage, ServerResponse } from 'node:http'
++
++/**
++ * GET /release.json (ADR-016, roadmap D43): what this deployment is, in the manifest shape the other
++ * OpenVibe services serve through openvibe-shared/release. The release id is RELEASE_SHA when set,
++ * otherwise the checked-out commit read from .git (no child process). Public deployment facts only.
++ */
++export interface ReleaseManifest {
++ service: string
++ release: string
++ released_at: string | null
++ booted_at: string
++ contracts_version: string | null
++ packages: Record<string, string>
++ min_client_release: string | null
++ mixed_version_window_hours: number
++ components: Record<string, { kind: string; version: string }>
++ schema_generation: null
++ schema_compatible_from: null
++ contract_ranges: Record<string, string>
++}
++
++/** The commit checked out at `start` or the nearest parent with a .git (HEAD, a loose ref, or packed-refs), or null. */
++export function gitCommit(start: string): string | null {
++ let root = start
++ for (let i = 0; i < 5 && !existsSync(join(root, '.git')); i++) root = dirname(root)
++ try {
++ const head = readFileSync(join(root, '.git', 'HEAD'), 'utf8').trim()
++ if (/^[0-9a-f]{40}$/.test(head)) return head
++ const ref = /^ref: (refs\/[\w./-]+)$/.exec(head)?.[1]
++ if (!ref) return null
++ try {
++ const sha = readFileSync(join(root, '.git', ref), 'utf8').trim()
++ if (/^[0-9a-f]{40}$/.test(sha)) return sha
++ } catch {
++ /* packed */
++ }
++ const packed = readFileSync(join(root, '.git', 'packed-refs'), 'utf8')
++ for (const line of packed.split('\n')) {
++ const [sha, name] = line.split(' ')
++ if (name === ref && sha && /^[0-9a-f]{40}$/.test(sha)) return sha
++ }
++ } catch {
++ /* not a checkout */
++ }
++ return null
++}
++
++function versionOf(req: NodeRequire, name: string): string | null {
++ try {
++ return (req(`${name}/package.json`) as { version?: string }).version ?? null
++ } catch {
++ return null
++ }
++}
++
++export function buildRelease(
++ root: string,
++ { env = process.env, now = () => new Date() }: { env?: NodeJS.ProcessEnv; now?: () => Date } = {},
++): ReleaseManifest {
++ const sha = (env.RELEASE_SHA && /^[0-9a-f]{7,40}$/.test(env.RELEASE_SHA) ? env.RELEASE_SHA : gitCommit(root)) ?? 'unknown'
++ const release = sha.slice(0, 12)
++ const req = createRequire(import.meta.url)
++ const packages: Record<string, string> = {}
++ for (const name of ['openvibe-sdk', 'openvibe-contracts']) {
++ const v = versionOf(req, name)
++ if (v) packages[name] = v
++ }
++ return {
++ service: 'games',
++ release,
++ released_at: null,
++ booted_at: now().toISOString(),
++ contracts_version: packages['openvibe-contracts'] ?? null,
++ packages,
++ min_client_release: env.MIN_CLIENT_RELEASE ?? null,
++ mixed_version_window_hours: 24,
++ components: { client: { kind: 'script', version: release }, server: { kind: 'server', version: release } },
++ schema_generation: null,
++ schema_compatible_from: null,
++ contract_ranges: {},
++ }
++}
++
++/** Answers GET /release.json (returns true), else false. */
++export function releaseHandler(manifest: ReleaseManifest) {
++ const body = JSON.stringify(manifest)
++ return (req: IncomingMessage, res: ServerResponse): boolean => {
++ if ((req.url ?? '').split('?')[0] !== '/release.json' || (req.method !== 'GET' && req.method !== 'HEAD')) return false
++ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-cache', 'access-control-allow-origin': '*' })
++ res.end(req.method === 'HEAD' ? undefined : body)
++ return true
++ }
++}
++
++/** A request from this machine that did not come through the proxy (nginx sets the client-IP headers). */
++export function isDirectLoopback(req: IncomingMessage): boolean {
++ const a = req.socket.remoteAddress ?? ''
++ const loop = a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1'
++ const h = req.headers
++ return loop && !h['x-forwarded-for'] && !h['x-real-ip'] && !h['cf-connecting-ip']
++}