openvibers/openvibe.games Commits

Agent layer: /.well-known/agent.json and Markdown twins of the portal and the game (#200)

OP OpenVibers committed 15 days ago · 7d7c42530f · 6 files, +481 −15

openvibe-shared v4.34.0 (was v4.33.0). The game server is raw node:http, not Express, so
apps/server/src/net/agent.ts uses the same openvibe-shared/agent-layer builders the Express mount uses
(agentManifest, markdownTwin, linkHeaderTags). /.well-known/agent.json describes OpenVibe.Games — the
public pages and /api as they really are, auth none + Network OIDC, no invented docs or MCP endpoint —
and the portal, the game (the play host's root, or /play on the apex) and each casual game page get a
Markdown twin of the page the server renders. /worlds is twinned when the build has it. A twin request
(.md, ?format=markdown, or an explicit text/markdown preference) and the manifest are the only requests
the layer answers; the HTML pages get the discovery <link> tags and the Link header as they are served
(addAgentHead/agentLinkFor), so a page is never replaced by a signed-out render. robots.txt names the
manifest. The surf embed keeps its frame-ancestors rule on the agent's path too, and a new describe
block proves the manifest, the twins, both link forms and a 404 twin.

Changes

ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex 9cc633f..e1f0ffd 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -24,7 +24,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0",
"quickjs-emscripten": "0.32.0",
"tsx": "^4.19.3",
ddiff --git a/apps/server/src/net/agent.ts b/apps/server/src/net/agent.ts
nnew file mode 100644
iindex 0000000..b5bb7e1
---- /dev/null
++++ b/apps/server/src/net/agent.ts
@@@ -0,0 +1,192 @@
++import { existsSync, readFileSync, statSync } from 'node:fs'
++import { join } from 'node:path'
++import { createRequire } from 'node:module'
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { casualGameOf, casualPageHtml } from '../casual/page.js'
++
++/**
++ * The agent layer (openvibe-shared/agent-layer) for the game server. The server is raw node:http, not
++ * Express, so this uses the same builders the Express mount uses — agentManifest, markdownTwin and
++ * linkHeaderTags — instead of app.use. /.well-known/agent.json and the Markdown twins of the portal, the
++ * game and the world pages are answered here; the HTML pages themselves get the same discovery <link>
++ * tags and Link header as they are served (addAgentHead, agentLinkFor).
++ */
++const require = createRequire(import.meta.url)
++const agent = require('openvibe-shared/agent-layer') as {
++ agentManifest: (descriptor: Record<string, unknown>) => unknown
++ markdownTwin: (o: {
++ html: string
++ title?: string
++ canonical: string
++ updated?: string | null
++ }) => string
++ linkHeaderTags: (canonical: string, llms?: string) => { head: string; header: string }
++}
++
++const SITE = 'https://openvibe.games'
++const PLAY_SITE = 'https://play.openvibe.games'
++const LLMS = `${SITE}/llms.txt`
++
++/** What openvibe.games tells an agent about itself. Every field is something this site really serves. */
++export const AGENT_DESCRIPTOR: Record<string, unknown> = {
++ id: 'games',
++ name: 'OpenVibe.Games',
++ purpose:
++ 'Browser games on the OpenVibe network: the games portal, Scraplandia (a persistent multiplayer survival-building world), Salvage Rush, Vibe Heroes and the worlds players create.',
++ home: SITE,
++ site: 'openvibe.games',
++ api: '/api',
++ policy: {
++ auth: ['none', 'Network OIDC'],
++ rateLimit:
++ 'The public pages are not rate-limited; the editor, map-asset and websocket-ticket routes are limited per address and per signed-in actor',
++ },
++}
++
++/** The public pages that have a Markdown twin: the portal, the game and the world pages. */
++const STATIC_PAGES: Record<string, { file: string; canonical: string }> = {
++ '/': { file: 'index.html', canonical: `${SITE}/` },
++ '/play': { file: 'play.html', canonical: `${PLAY_SITE}/` },
++ '/worlds': { file: 'worlds.html', canonical: `${SITE}/worlds` },
++}
++
++/** The client build's own files that are pages with a twin (the portal is served through portalPage). */
++export const AGENT_PAGE_FILES = new Set(['index.html', 'play.html', 'worlds.html', 'casual.html'])
++
++/** What the twin renderer needs from the server: the built client, the portal's live parts and the host. */
++export interface AgentContext {
++ root: string | null
++ /** The request's host (the play host serves the game at its root). */
++ host: string
++ /** The portal page as the HTML route renders it (with its live stats and sections), for the twin. */
++ portalHtml?: () => Promise<string | null>
++}
++
++/** The Link header a page with a twin sends. */
++export function agentLinkFor(canonical: string): string {
++ return agent.linkHeaderTags(canonical, LLMS).header
++}
++
++/** Add the discovery <link> tags to a page's head (the page already links its own canonical). */
++export function addAgentHead(html: string, canonical: string): string {
++ if (/rel="alternate" type="text\/markdown"/.test(html)) return html
++ const tags = agent.linkHeaderTags(canonical, LLMS).head
++ return html.includes('</head>') ? html.replace('</head>', `${tags}\n</head>`) : html
++}
++
++/** The canonical a page names for itself, when it names one. */
++export function canonicalOf(html: string): string | null {
++ const m = /<link[^>]+rel="canonical"[^>]+href="([^"]+)"/i.exec(html)
++ return m ? m[1]! : null
++}
++
++// The built client is read once per file version: the game's event loop never reads a file per request.
++const memo = new Map<string, { path: string; mtimeMs: number; html: string }>()
++export function readPageFile(filePath: string): string | null {
++ try {
++ const mtimeMs = statSync(filePath).mtimeMs
++ const hit = memo.get(filePath)
++ if (hit && hit.path === filePath && hit.mtimeMs === mtimeMs) return hit.html
++ const html = readFileSync(filePath, 'utf8')
++ memo.set(filePath, { path: filePath, mtimeMs, html })
++ return html
++ } catch {
++ return null
++ }
++}
++
++/** The page a path names, as the HTML route renders it: { html, canonical } or null when it is not public. */
++export async function agentPageFor(
++ path: string,
++ ctx: AgentContext,
++): Promise<{ html: string; canonical: string } | null> {
++ const { root } = ctx
++ if (!root) return null
++ const clean = String(path || '/').replace(/\/+$/, '') || '/'
++ // On the play host the game is the root page (the apex root is the games portal).
++ if (clean === '/' && ctx.host.toLowerCase().startsWith('play.')) {
++ const html = readPageFile(join(root, 'play.html'))
++ return html ? { html, canonical: `${PLAY_SITE}/` } : null
++ }
++ // A casual game is its own page (the build's casual.html with that game's own head and copy).
++ const casual = casualGameOf(clean)
++ if (casual) {
++ const file = join(root, 'casual.html')
++ if (!existsSync(file)) return null
++ return { html: casualPageHtml(file, casual), canonical: `${SITE}/play/${casual}` }
++ }
++ if (clean === '/') {
++ const portal = ctx.portalHtml ? await ctx.portalHtml() : null
++ if (!portal) return null
++ return { html: portal, canonical: `${SITE}/` }
++ }
++ const spec = STATIC_PAGES[clean]
++ if (!spec) return null
++ const html = readPageFile(join(root, spec.file))
++ return html ? { html, canonical: spec.canonical } : null
++}
++
++function send(
++ res: ServerResponse,
++ status: number,
++ type: string,
++ body: string,
++ canonical?: string,
++ method?: string,
++): void {
++ res.writeHead(status, {
++ 'content-type': type,
++ 'cache-control': status === 200 ? 'public, max-age=300' : 'public, max-age=60',
++ ...(canonical ? { link: agentLinkFor(canonical) } : {}),
++ vary: 'Accept',
++ })
++ res.end(method === 'HEAD' ? undefined : body)
++}
++
++/** True when this request is the agent manifest or a Markdown twin: the agent layer answers it. */
++export function isAgentRequest(req: IncomingMessage): boolean {
++ const method = String(req.method ?? 'GET')
++ if (!['GET', 'HEAD'].includes(method)) return false
++ const url = new URL(req.url ?? '/', SITE)
++ if (url.pathname === '/.well-known/agent.json') return true
++ return (
++ url.pathname.endsWith('.md') ||
++ url.searchParams.get('format') === 'markdown' ||
++ /\btext\/markdown\b/.test(String(req.headers.accept ?? ''))
++ )
++}
++
++/** True when this request was the agent manifest or a Markdown twin (and has been answered). */
++export async function handleAgent(
++ req: IncomingMessage,
++ res: ServerResponse,
++ ctx: AgentContext,
++): Promise<boolean> {
++ const method = String(req.method ?? 'GET')
++ if (!['GET', 'HEAD'].includes(method)) return false
++ const url = new URL(req.url ?? '/', SITE)
++ if (url.pathname === '/.well-known/agent.json') {
++ res.writeHead(200, {
++ 'content-type': 'application/json; charset=utf-8',
++ 'cache-control': 'public, max-age=3600',
++ })
++ res.end(method === 'HEAD' ? undefined : JSON.stringify(agent.agentManifest(AGENT_DESCRIPTOR)))
++ return true
++ }
++ const accept = String(req.headers.accept ?? '')
++ const wantsMarkdown =
++ url.pathname.endsWith('.md') ||
++ url.searchParams.get('format') === 'markdown' ||
++ /\btext\/markdown\b/.test(accept)
++ if (!wantsMarkdown) return false
++ let path = url.pathname === '/index.md' ? '/' : url.pathname.endsWith('.md') ? url.pathname.slice(0, -3) : url.pathname
++ path = path.replace(/\/+$/, '') || '/'
++ const page = await agentPageFor(path, ctx)
++ if (!page) {
++ send(res, 404, 'text/markdown; charset=utf-8', '# Not found\n', undefined, method)
++ return true
++ }
++ const body = agent.markdownTwin({ html: page.html, canonical: page.canonical })
++ send(res, 200, 'text/markdown; charset=utf-8', body, page.canonical, method)
++ return true
++}
ddiff --git a/apps/server/src/net/discovery.ts b/apps/server/src/net/discovery.ts
iindex ca901f1..8548ff1 100644
---- a/apps/server/src/net/discovery.ts
++++ b/apps/server/src/net/discovery.ts
@@@ -388,7 +388,7 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
send(
res,
'text/plain',
-- seo.robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`], disallow: DISALLOW }),
++ seo.robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`], disallow: DISALLOW }) + `# Agent manifest: ${SITE}/.well-known/agent.json\n`,
)
return true
}
ddiff --git a/apps/server/src/net/httpServer.test.ts b/apps/server/src/net/httpServer.test.ts
iindex d838ea3..7370d7c 100644
---- a/apps/server/src/net/httpServer.test.ts
++++ b/apps/server/src/net/httpServer.test.ts
@@@ -78,8 +78,9 @@ beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'ovg-http-'))
const dist = join(dir, 'dist')
mkdirSync(join(dist, 'assets', 'tex'), { recursive: true })
-- writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')
-- writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')
++ // A real page has a head to carry the discovery tags into (the agent layer adds them there).
++ writeFileSync(join(dist, 'index.html'), '<!doctype html><html><head><title>portal page</title></head><body><h1>portal page</h1></body></html>')
++ writeFileSync(join(dist, 'play.html'), '<!doctype html><html><head><title>game page</title></head><body><h1>game page</h1></body></html>')
writeFileSync(join(dist, 'editor.html'), '<!doctype html><title>editor page</title>')
writeFileSync(join(dist, 'frame.js'), 'void 0')
writeFileSync(join(dist, ASSET.slice(1)), 'export {}')
@@@ -384,3 +385,58 @@ describe('/api/ws-ticket is rate limited per address', () => {
}
})
})
++
++describe('the agent layer', () => {
++ it('serves the manifest on the apex and on the play host', async () => {
++ for (const host of [APEX, PLAY]) {
++ const r = await send('/.well-known/agent.json', { host })
++ expect(r.status).toBe(200)
++ expect(r.headers['content-type']).toBe('application/json; charset=utf-8')
++ const d = JSON.parse(r.body)
++ expect(d).toMatchObject({ id: 'games', site: 'openvibe.games', home: 'https://openvibe.games' })
++ expect(d.api).toBe('https://openvibe.games/api')
++ expect(d.policy.auth).toEqual(['none', 'Network OIDC'])
++ }
++ })
++
++ it('twins the portal, the game and a world page', async () => {
++ const index = await send('/index.md')
++ expect(index.status).toBe(200)
++ expect(index.headers['content-type']).toBe('text/markdown; charset=utf-8')
++ expect(index.headers.link).toContain('rel="alternate"')
++ expect(index.body).toContain('canonical: "https://openvibe.games/"')
++ expect(index.body).toContain('# portal page')
++ // The game's canonical home is the play host's root (play.html names it), so its twin does too,
++ // wherever the twin is asked for.
++ const play = await send('/play.md')
++ expect(play.status).toBe(200)
++ expect(play.body).toContain('canonical: "https://play.openvibe.games/"')
++ expect(play.body).toContain('# game page')
++ // The play host's root IS the game.
++ const root = await send('/index.md', { host: PLAY })
++ expect(root.status).toBe(200)
++ expect(root.body).toContain('canonical: "https://play.openvibe.games/"')
++ expect(root.body).toContain('# game page')
++ })
++
++ it('names the twin on the HTML page, and answers a markdown preference', async () => {
++ // The portal names its twin: the stub pages carry no canonical, so the apex root is the fallback.
++ const page = await send('/')
++ expect(page.headers.link).toContain('<https://openvibe.games/index.md>; rel="alternate"; type="text/markdown"')
++ expect(page.body).toContain('<link rel="alternate" type="text/markdown" href="https://openvibe.games/index.md">')
++ expect(page.body).toContain('<link rel="describedby" href="https://openvibe.games/llms.txt">')
++ const md = await send('/play', { headers: { accept: 'text/markdown' } })
++ expect(md.headers['content-type']).toBe('text/markdown; charset=utf-8')
++ expect(md.body).toContain('# game page')
++ // A browser's request is the page itself, never the twin.
++ const browser = await send('/play', { headers: { accept: 'text/html,application/xhtml+xml' } })
++ expect(browser.headers['content-type']).toBe('text/html; charset=utf-8')
++ })
++
++ it('has no twin for a page that is not public', async () => {
++ const r = await send('/nothing.md')
++ expect(r.status).toBe(404)
++ expect(r.headers['content-type']).toBe('text/markdown; charset=utf-8')
++ expect(r.body).toBe('# Not found\n')
++ })
++})
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 396adbf..5f18125 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -1,7 +1,7 @@
import { randomBytes } from 'node:crypto'
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
--import { dirname, extname, join, normalize, resolve } from 'node:path'
++import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
import type { Logger } from '@openvibe/shared'
import type { ServerMetrics } from '../observability/metrics.js'
import { isDirectLoopback, prometheusText, wantsPrometheus } from '../observability/release.js'
@@@ -30,6 +30,16 @@ import type { GamesActorLimits } from './actorLimits.js'
import { resolveClientAddress } from './clientAddress.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import { discoveryHandler, homeJsonLdTags, jsonLdTagsOf } from './discovery.js'
++import {
++ addAgentHead,
++ agentLinkFor,
++ canonicalOf,
++ handleAgent,
++ isAgentRequest,
++ readPageFile,
++ AGENT_PAGE_FILES,
++ type AgentContext,
++} from './agent.js'
import type { HomeParts } from './portalHome.js'
import { legalHandler } from './legalPages.js'
import { updatesHandler } from './updatesPage.js'
@@@ -243,6 +253,20 @@ export function createHttpServer(
mapPathFor?: (place: string | undefined) => string | undefined,
): Server {
const root = staticDir ? resolve(staticDir) : null
++ // The agent layer (openvibe-shared/agent-layer): the Markdown twins of the public pages render through the
++ // same builders the HTML routes use, so a twin cannot drift from its page.
++ const agentCtx: AgentContext = {
++ root,
++ host: '',
++ portalHtml: async () => {
++ if (!root) return null
++ const file = join(root, 'index.html')
++ if (!existsSync(file)) return null
++ const live = platform?.homeSections
++ const parts = live ? await live().catch(() => null) : null
++ return portalPage(file, parts)
++ },
++ }
// Sessions the Network confirmed recently: lets a silent login on a signed-in
// browser answer without the Network round trip. See /auth/login.
const knownSessions = new SessionCheckCache()
@@@ -798,6 +822,20 @@ export function createHttpServer(
notFound()
return
}
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
++ // portal, the game and the world pages. Before the page routes, so a twin is the page's own render and
++ // never a static file; after the API routes, so /api keeps answering for itself.
++ if (isAgentRequest(req)) {
++ const ctx: AgentContext = { ...agentCtx, host: String(req.headers.host ?? '') }
++ void handleAgent(req, res, ctx).catch((err: unknown) => {
++ log.warn('agent layer failed', { error: String(err) })
++ if (!res.headersSent) {
++ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
++ res.end('Something went wrong on our side. Try again in a moment.')
++ }
++ })
++ return
++ }
const surfRoute = surfPageRoute(url, new URL(req.url ?? '/', 'http://games.local').searchParams)
if (surfRoute?.redirect) {
res.writeHead(301, { location: surfRoute.redirect, 'cache-control': 'no-store' })
@@@ -893,16 +931,42 @@ export function createHttpServer(
if (pageAlias === 'index.html' || url === '/index.html') {
const live = platform?.homeSections
void (live ? live().catch(() => null) : Promise.resolve(null)).then((parts) => {
-- const body = portalPage(filePath, parts)
++ const page = portalPage(filePath, parts)
++ // The portal has a Markdown twin, so the page names it (the agent layer's discovery tags).
++ const canonical = canonicalOf(page) ?? `${DEFAULT_PORTAL_URL.replace(/\/+$/, '')}/`
++ const body = addAgentHead(page, canonical)
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-cache',
'content-length': String(Buffer.byteLength(body)),
++ link: agentLinkFor(canonical),
})
res.end(req.method === 'HEAD' ? undefined : body)
})
return
}
++ // The other pages with a twin (the game, the world list, a casual game) carry the same discovery tags
++ // and the Link header as they are served.
++ const fileName = filePath.slice(filePath.lastIndexOf(sep) + 1)
++ if (AGENT_PAGE_FILES.has(fileName)) {
++ const page = readPageFile(filePath)
++ if (page) {
++ const canonical = canonicalOf(page) ?? `${DEFAULT_PORTAL_URL.replace(/\/+$/, '')}/`
++ const body = addAgentHead(page, canonical)
++ res.writeHead(200, {
++ 'content-type': 'text/html; charset=utf-8',
++ 'cache-control': 'no-cache',
++ 'content-length': String(Buffer.byteLength(body)),
++ link: agentLinkFor(canonical),
++ // The embeddable surf route keeps its frame rule on this path too (it is play.html).
++ ...(url === '/surf/embed'
++ ? { 'content-security-policy': "frame-ancestors https://openvibe.surf 'self'" }
++ : {}),
++ })
++ res.end(req.method === 'HEAD' ? undefined : body)
++ return
++ }
++ }
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
res.writeHead(200, {
'content-type': type,
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 9768b1b..4062922 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -128,13 +128,13 @@ importers:
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0
openvibe-publishing:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0
-- version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0)
++ version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0)
openvibe-sdk:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])
openvibe-shared:
-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
-- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0
++ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0
pg:
specifier: ^8.23.0
version: 8.23.0
@@@ -223,6 +223,9 @@ importers:
packages:
++ '@acemir/[email protected]':
++ resolution: {integrity: sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==}
++
'@asamuzakjp/[email protected]':
resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
engines: {node: ^22.13.0 || >=24.0.0}
@@@ -992,6 +995,10 @@ packages:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
++ resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==}
++ engines: {node: '>=20.19.0'}
++
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
@@@ -1035,10 +1042,18 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
++ resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==}
++ engines: {node: '>=20.19.0'}
++
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
++ resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==}
++ engines: {node: '>=20.19.0'}
++
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@@ -1062,9 +1077,46 @@ packages:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
++ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
++
++ resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==}
++ engines: {node: '>=20.19.0'}
++
++ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
++
++ resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}
++ engines: {node: '>=20.19.0'}
++
++ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
++ engines: {node: '>= 4'}
++
++ resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}
++ engines: {node: '>=20.19.0'}
++
++ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
++
++ resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
++ engines: {node: '>=20.19.0'}
++
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
++ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
++ engines: {node: '>=0.12'}
++
++ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
++ engines: {node: '>=0.12'}
++
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
@@@ -1202,6 +1254,12 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
++ resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
++
++ resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
++
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@@ -1278,6 +1336,15 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
++ resolution: {integrity: sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==}
++ engines: {node: '>=16'}
++ peerDependencies:
++ canvas: '>= 2'
++ peerDependenciesMeta:
++ canvas:
++ optional: true
++
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@@ -1340,6 +1407,10 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
++ resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==}
++ engines: {node: '>=20.19.0'}
++
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
@@@ -1378,9 +1449,9 @@ packages:
pg:
optional: true
-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0:
-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0}
-- version: 4.33.0
++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0:
++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0}
++ version: 4.34.0
hasBin: true
@@@ -1633,6 +1704,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
++ resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==}
++
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@@ -1794,6 +1868,8 @@ packages:
snapshots:
++ '@acemir/[email protected]': {}
++
'@asamuzakjp/[email protected]':
dependencies:
'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
@@@ -2377,6 +2453,8 @@ snapshots:
dependencies:
require-from-string: 2.0.2
++
dependencies:
balanced-match: 1.0.2
@@@ -2415,11 +2493,21 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
++ dependencies:
++ boolbase: 2.0.0
++ css-what: 8.0.0
++ domhandler: 6.0.1
++ domutils: 4.0.2
++ nth-check: 3.0.1
++
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
++
dependencies:
whatwg-mimetype: 5.0.0
@@@ -2437,10 +2525,50 @@ snapshots:
++ dependencies:
++ domelementtype: 2.3.0
++ domhandler: 5.0.3
++ entities: 4.5.0
++
++ dependencies:
++ domelementtype: 3.0.0
++ domhandler: 6.0.1
++ entities: 8.0.0
++
++
++
++ dependencies:
++ domelementtype: 2.3.0
++
++ dependencies:
++ domelementtype: 3.0.0
++
++ dependencies:
++ dom-serializer: 2.0.0
++ domelementtype: 2.3.0
++ domhandler: 5.0.3
++
++ dependencies:
++ dom-serializer: 3.1.1
++ domelementtype: 3.0.0
++ domhandler: 6.0.1
++
dependencies:
safe-buffer: 5.2.1
++
++
@@@ -2627,6 +2755,15 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
++
++ dependencies:
++ domelementtype: 2.3.0
++ domhandler: 5.0.3
++ domutils: 3.2.2
++ entities: 7.0.1
++
@@@ -2733,6 +2870,14 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
++ dependencies:
++ '@acemir/cssom': 0.9.32
++ css-select: 7.0.0
++ html-escaper: 3.0.3
++ htmlparser2: 10.1.0
++ uhyphen: 0.2.0
++
dependencies:
p-locate: 5.0.0
@@@ -2779,6 +2924,10 @@ snapshots:
++ dependencies:
++ boolbase: 2.0.0
++
openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0:
@@@ -2786,11 +2935,11 @@ snapshots:
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
-- openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0):
++ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0):
dependencies:
openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])
optionalDependencies:
-- openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
++ openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0
openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]):
optionalDependencies:
@@@ -2798,9 +2947,12 @@ snapshots:
iovalkey: 0.4.0
pg: 8.23.0
-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0:
++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0:
dependencies:
jsonwebtoken: 9.0.3
++ linkedom: 0.18.14
++ transitivePeerDependencies:
++ - canvas
dependencies:
@@@ -3042,6 +3194,8 @@ snapshots:
++