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
Changes
@@@ -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 trueif ((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' })@@@ -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()))returnnnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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']++}
@@ -20,6 +20,7 @@ import { resolveNetworkUser } from './net/networkAuth.js'
@@ -20,6 +20,7 @@ import { resolveNetworkUser } from './net/networkAuth.js'
import { attachWebSocket } from './net/wsTransport.js'
import { attachWebSocket } from './net/wsTransport.js'
import { ServerMetrics } from './observability/metrics.js'
import { ServerMetrics } from './observability/metrics.js'
import { createReadiness } from './observability/readiness.js'
import { createReadiness } from './observability/readiness.js'
+import { buildRelease, releaseHandler } from './observability/release.js'
import { ModRegistry } from './mods/registry.js'
import { ModRegistry } from './mods/registry.js'
import { handleModsRequest } from './mods/routes.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
import { ModRuntime } from './mods/runtime.js'
@@ -153,6 +154,9 @@ async function main(): Promise<void> {
@@ -153,6 +154,9 @@ async function main(): Promise<void> {
mods: modRuntime,
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).
// 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 schemaVersion = store.db.prepare('SELECT value FROM meta WHERE key = ?')
const readiness = createReadiness({
const readiness = createReadiness({
@@ -208,6 +212,7 @@ async function main(): Promise<void> {
@@ -208,6 +212,7 @@ async function main(): Promise<void> {
{
{
handle: (req, res) => {
handle: (req, res) => {
if (readiness.handle(req, res)) return true
if (readiness.handle(req, res)) return true
+ if (serveRelease(req, res)) return true
if ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {
if ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {
// Operational status of the platform adapters; never secrets.
// Operational status of the platform adapters; never secrets.
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
@@ -4,6 +4,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
@@ -4,6 +4,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, extname, join, normalize, resolve } from 'node:path'
import { dirname, extname, join, normalize, resolve } from 'node:path'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
import type { ServerMetrics } from '../observability/metrics.js'
import type { ServerMetrics } from '../observability/metrics.js'
+import { isDirectLoopback } from '../observability/release.js'
import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
import { canEditMap, fetchNetworkAccount, resolveNetworkUser } from './networkAuth.js'
import {
import {
LOGIN_STATE_COOKIE,
LOGIN_STATE_COOKIE,
@@ -615,6 +616,11 @@ export function createHttpServer(
@@ -615,6 +616,11 @@ export function createHttpServer(
return
return
}
}
if (url === '/metrics') {
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.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(metrics.snapshot()))
res.end(JSON.stringify(metrics.snapshot()))
return
return
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -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']
+}