openvibers/openvibe.sdk Commits

openvibe-sdk/menu: build and serve the network menu cards (0.97.0) (#132)

OP OpenVibers committed 6 days ago · 05919f47eb · 15 files, +765 −19

menuCard() validates one frame.menu-card@1 with the house rules (plain text,
the contract's six-item cap, https links on the site's own origin or an
openvibe.* host, an optional item picture). menuRoute() is the
GET /.well-known/openvibe-menu.json handler for Express and node:http: a
public cache and CORS *, and [] (never a 500) with one log line when the
cards cannot be built. Types, README, CHANGELOG, STATUS, the API surface,
the ESM entry and the pack test follow; the devDependency moves to
openvibe-contracts v0.324.0.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f8a4520..2826fa8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,20 @@
# Changelog
++## 0.97.0 (2026-11-07)
++
++- Add `openvibe-sdk/menu`: `menuCard({ id, site, title, kind, items, more, origin, ttlSeconds, updatedAt })`
++ builds one validated `frame.menu-card@1` with the house rules applied — plain text (markup and control
++ characters stripped) cut to the contract's lengths, at most six items (the contract's cap), https links on
++ the site's own origin or an `openvibe.*` host, and an optional item image (`{ src, w, h, alt }`) with the same
++ rule. A bad field throws a `TypeError` naming it; an empty `items` array is valid and Network skips the card.
++- Add `menuRoute(getCards, { maxAgeSeconds = 300, origin, log })`: the Express/node:http handler for
++ `GET /.well-known/openvibe-menu.json`. It calls `getCards()`, validates and caps the file at three cards, and
++ answers `Cache-Control: public, max-age=…` with CORS `*`; a build failure or an invalid card answers `[]`
++ (never a 500) and logs once. `MENU_PATH` is the route.
++- TypeScript types, README ("Put your content in the OpenVibe menu"), the API surface and the pack test follow;
++ the devDependency moves to openvibe-contracts v0.324.0, whose `frame.menu-card@1` tests validate every card
++ this module builds.
++
## 0.96.0 (2026-11-07)
- `openvibe-sdk/actor` gains `contacts`: `list()`, `presets()`, `get()`, `create()` (optionally from a preset),
ddiff --git a/README.md b/README.md
iindex feebcdd..0795665 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.96.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.97.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.96.0 is tagged:
++After v0.97.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@@ -214,6 +214,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/food` | server | `createFoodClient(client)`: OpenVibe.Food. Public: `places({kind, q, lat, lon, radius})` (food banks, pantries, free meals from OpenStreetMap), `foods.list/get` (the food list with store prices). The caller's own: `plans.create({people, days, budget, title})/list/get` (deterministic meal plans with a shopping list and costs), `pantry.get/set(items)` (with "what can I make" matches), `recipes.suggest({ask, people})/list` (AI recipe ideas, 10 a day). Plans and recipe ideas are never retried on their own. Audience `openvibe.food` |
| `openvibe-sdk/chat-apps` | server | The three chat apps (Telegram, Discord, Slack) in one place: `verifyTelegram()`, `verifySlack()`, `verifyDiscord()`, `verifyDelivery()` (the delivery's own proof), `signCallback()` / `verifyCallback()` (a button callback: signed once, used once, expiring), `parseTelegramUpdate()`, `parseDiscordInteraction()`, `parseSlackEvent()`, `parseSlackInteraction()` (each app's payload as the same shape: who said it, where, whether it was said to the bot) and `splitText()` (an answer split the app's way) |
| `openvibe-sdk/actor` | server | `createActorClient(client)`: OpenVibe.Actor. `agents.list()` and `route({task, mode})` (a dry run) are public. `tasks.create({task, mode, budget, agent, idempotency_key, webhooks})` (retried only with an idempotency_key; `webhook_secret` comes back once), `tasks.list/get/cancel`, `tasks.events(id, {lastEventId})` (SSE until the task ends). A person's own agents: `me.agents.list/create/get/update/remove/run/runs` and `me.agents.memory.list/add/update/remove/clear`. Audience `openvibe.actor` |
++| `openvibe-sdk/menu` | server | `menuCard({id, site, title, kind, items, more, origin, ttlSeconds, updatedAt})`: one validated `frame.menu-card@1` — plain text cut to the contract's lengths, at most six items (the contract's cap), https links on the site's own origin or an `openvibe.*` host, an optional item `image` (`{src, w, h, alt}`, a picture from elsewhere is dropped), and `more` (`{label, url}`). `menuRoute(getCards, {maxAgeSeconds = 300, origin, log})`: the Express/node:http `GET /.well-known/openvibe-menu.json` handler, answering an array of at most three cards with `Cache-Control: public, max-age=…` and CORS `*`; a `getCards()` failure is `[]` (never a 500) and logs once. `MENU_PATH` |
| `openvibe-sdk/run` | server | `createRunClient(client, {project?})`: OpenVibe.Run, jobs on worker nodes. `jobs.submit({class, artifact, args, limits, …})` (Idempotency-Key, so retried safely) → `{ created, job }`, `jobs.list({state, limit, cursor})`, `jobs.get/cancel`, `jobs.events(id, {lastEventId})` (SSE until `end`), `jobs.streamTicket(id)` (a one-use URL for a browser's EventSource); `forProject(prj_…)` sends X-OV-Project. Audience `openvibe.run` |
| `openvibe-sdk/video` | server | `createVideoClient(client)`: OpenVibe.Video's playlists of recordings and clips. `playlists.list/create/get/update/remove`, `playlists.items.add/reorder/remove` (`{ kind: 'vod' \| 'clip', id }`), `users.playlists(username)` (public). Private playlists are 404 to everyone but their owner. Audience `openvibe.download` (the media hub's) |
| `openvibe-sdk/work` | server | `createWorkClient(client)`: OpenVibe.Work. Public: `jobs.search({q, remote, type, location, cursor})`, `jobs.get`, `sources()`. A person's: `savedSearches.list/create/remove`, `applications.list/create/get/update/remove` (also an app with `work.application.manage`; tracking a listing twice answers the first), `applications.assist(id, {ask})` (their Actor agent helps). Audience `openvibe.work` |
@@@ -513,6 +514,33 @@ await camera.start(); // or let the person click the ele
the signed-in person, and only on openvibe.cam. `defineOvCamera({ src })` loads a self-hosted copy of the same file.
The full attribute, event and method reference is at [openvibe.cam/developers](https://openvibe.cam/developers).
++### Put your content in the OpenVibe menu
++
++Every open site can put its own cards in the network menu every site shares. Build the card with
++`menuCard` (it applies the house rules and validates against `frame.menu-card@1`), then mount the
++route — Express or plain `node:http`:
++
++```js
++const { menuCard, menuRoute } = require('openvibe-sdk/menu');
++
++const cards = async () => [menuCard({
++ id: 'new-worlds', site: 'games', title: 'New worlds', kind: 'rows',
++ origin: 'https://openvibe.games',
++ items: worlds.slice(0, 6).map((w) => ({ title: w.title, meta: `by ${w.ownerName}`, url: `https://openvibe.games/worlds/${w.id}` })),
++ more: { label: 'All worlds', url: 'https://openvibe.games/worlds' },
++})];
++
++app.get('/.well-known/openvibe-menu.json', menuRoute(cards, { maxAgeSeconds: 300 }));
++```
++
++`items` may be empty (the card is valid and Network skips it until something is there). `origin` is
++the site's own https origin for the link rule; without it, the host of `more.url` is used, and with
++neither only `openvibe.*` links pass. A bad field throws a `TypeError` naming it. `menuRoute` calls
++`getCards()` on every request, validates each card, and caps the file at three cards; it answers `[]`
++and logs once rather than a 500 when the build fails or a card does not validate, so a transient
++error cannot take the site's file down. `OpenVibe.Network` fetches this file every five minutes and
++shows the cards in `GET /api/frame`.
++
## Versioning
- `openvibe-sdk` follows semver. While it is 0.x, a minor release may break an API, and [CHANGELOG.md](CHANGELOG.md) says so. Pin a tag.
ddiff --git a/STATUS.json b/STATUS.json
iindex 543c338..bdfec9e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,12 +3,13 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.96.0",
++ "version": "0.97.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
-- "contracts": "openvibe-contracts v0.312.0",
++ "contracts": "openvibe-contracts v0.324.0",
"features": [
++ "menu (openvibe-sdk/menu): menuCard() builds one validated frame.menu-card@1 (plain text, the contract caps, https links on the site own origin or an openvibe.* host, an optional item picture) and menuRoute() is the Express/node:http GET /.well-known/openvibe-menu.json handler (public cache, CORS *, [] and one log line when the cards cannot be built)",
"cam (openvibe-sdk/cam): defineOvCamera({ src }) injects the <ov-camera> element from openvibe.cam once and resolves to its class, with typed attributes, events and methods (start/stop/capture/flip); browser-only, upload=\"off\" by default",
"streams (openvibe-sdk/streams, S7): schedule(), listSchedules(), cancelSchedule() and remindSchedule() for scheduled public agent streams; postStep() and steps.post() from any harness; digest.read() and poll.open()/poll.result() for a Co-pilot service token; onControl() for pause, curtain, mute and end",
"reports (openvibe-sdk/reports, S7): write() a session report with the person's own token, private by default and idempotent per source and task ref, and read() one the caller may see; examples/cli-agent-stream.js streams any CLI command's run with steps",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex be9e07c..03f3500 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.96.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.97.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.96.0';
++const SDK_VERSION = '0.97.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex c545b15..4c7ff91 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -804,6 +804,11 @@
"openvibe-sdk/meme": {
"createMemeClient": "function"
},
++ "openvibe-sdk/menu": {
++ "MENU_PATH": "string",
++ "menuCard": "function",
++ "menuRoute": "function"
++ },
"openvibe-sdk/modules": {
"createModulesClient": "function"
},
ddiff --git a/esm/menu.mjs b/esm/menu.mjs
nnew file mode 100644
iindex 0000000..aa011e9
---- /dev/null
++++ b/esm/menu.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/menu/index.js. Do not edit.
++import cjs from '../src/menu/index.js';
++
++export const {
++ menuCard, menuRoute, MENU_PATH,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex a48e967..9f32fae 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.96.0",
++ "version": "0.97.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.96.0",
++ "version": "0.97.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
@@@ -14,7 +14,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",
"pg": "^8.23.0"
},
"engines": {
@@@ -198,9 +198,9 @@
"license": "MIT"
},
"node_modules/openvibe-contracts": {
-- "version": "0.312.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
++ "version": "0.324.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",
++ "integrity": "sha512-fSeabmHcX74SxVKEfGXMgWhEDugYQh5KWB+5Vj86CTvr2q50e/7AfHBDtVVw2WYQ06wiH1wN0hctDYoc2DQi9Q==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {
ddiff --git a/package.json b/package.json
iindex dae949a..31f83bc 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.96.0",
++ "version": "0.97.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -270,6 +270,12 @@
"import": "./esm/frame.mjs",
"default": "./src/frame.js"
},
++ "./menu": {
++ "types": "./types/menu.d.ts",
++ "browser": null,
++ "import": "./esm/menu.mjs",
++ "default": "./src/menu/index.js"
++ },
"./geo": {
"types": "./types/geo.d.ts",
"import": "./esm/geo.mjs",
@@@ -549,7 +555,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",
"pg": "^8.23.0"
},
"peerDependencies": {
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 0ce74d6..97ba84b 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -97,6 +97,7 @@ const ENTRIES = {
'zone.mjs': '../src/zone.js',
'vip.mjs': '../src/vip.js',
'frame.mjs': '../src/frame.js',
++ 'menu.mjs': '../src/menu/index.js',
'service.mjs': '../src/service/index.js',
'testing.mjs': '../src/testing/index.js',
};
ddiff --git a/src/core/index.js b/src/core/index.js
iindex ddbf4dc..b19b3e4 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.96.0';
++const SDK_VERSION = '0.97.0';
module.exports = {
createClient,
ddiff --git a/src/menu/index.js b/src/menu/index.js
nnew file mode 100644
iindex 0000000..b41cd02
---- /dev/null
++++ b/src/menu/index.js
@@@ -0,0 +1,351 @@
++'use strict';
++/**
++ * openvibe-sdk/menu — the cards a site puts in the OpenVibe menu.
++ *
++ * Every open site may serve an array of frame.menu-card@1 cards at /.well-known/openvibe-menu.json
++ * (frame.menu-cards@1); OpenVibe.Network fetches that public file every five minutes and shows the
++ * cards in the Frame menu every site shares. Two calls cover a site's side:
++ *
++ * const { menuCard, menuRoute } = require('openvibe-sdk/menu');
++ *
++ * // 1. Build one validated card. The signature is the card's own fields; `site` and `origin` say
++ * // which site publishes it and which host its links may use.
++ * const cards = async () => [menuCard({
++ * id: 'new-worlds', site: 'games', title: 'New worlds', kind: 'rows',
++ * origin: 'https://openvibe.games',
++ * items: [{ title: world.title, meta: `by ${world.ownerName}`, url: `https://openvibe.games/worlds/${world.id}` }],
++ * more: { label: 'All worlds', url: 'https://openvibe.games/worlds' },
++ * })];
++ *
++ * // 2. Mount the route. Express and a plain node:http handler both work.
++ * app.get('/.well-known/openvibe-menu.json', menuRoute(cards, { maxAgeSeconds: 300 }));
++ *
++ * menuCard applies the house rules before it validates against frame.menu-card@1: plain text (markup
++ * and control characters stripped, whitespace collapsed, cut to the contract's lengths), at most six
++ * items (the contract's cap), https URLs on the site's own origin or an openvibe.* host, and an
++ * optional item image ({ src, w, h, alt }) with the same host rule. A bad field throws a TypeError
++ * that names it. An empty items array is valid: Network skips the card until something is there.
++ *
++ * menuRoute calls getCards() (it may be async), validates every card, and answers the array with
++ * `Cache-Control: public, max-age=<maxAgeSeconds>` and `Access-Control-Allow-Origin: *`. A getCards()
++ * failure or an invalid card never becomes a 500: the route answers an empty array (and logs once),
++ * so one bad build cannot take the site's file down. A picture from another host is the one thing
++ * menuCard drops rather than throws on: the item stays and only its image is left out, as Network
++ * itself would drop it.
++ *
++ * The contract is copied here rather than required: the package has no runtime dependencies. The
++ * tests check every shape this module builds against the pinned openvibe-contracts release; when
++ * that package is installed, menuRoute also runs it as a second check.
++ */
++
++const MENU_PATH = '/.well-known/openvibe-menu.json';
++const DEFAULT_MAX_AGE_SECONDS = 300;
++const MAX_ITEMS = 6; // frame.menu-card@1 items.maxItems
++const MAX_CARDS = 3; // frame.menu-cards@1 maxItems
++const KINDS = ['rail', 'rows', 'chips'];
++const ID_RE = /^[a-z][a-z0-9-]{1,39}$/;
++
++/** Plain words for a card: no markup, no control characters, one line, cut to the contract's limit. */
++function plain(value, max) {
++ return String(value == null ? '' : value)
++ .replace(/<[^>]*>/g, ' ')
++ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
++ .replace(/\s+/g, ' ')
++ .trim()
++ .slice(0, max);
++}
++
++/** The hostname an https origin names, without a leading www. Throws for anything else. */
++function hostOf(origin, field = 'origin') {
++ let u;
++ try { u = new URL(String(origin)); } catch { throw new TypeError(`openvibe-sdk/menu: ${field} must be an https URL, got ${JSON.stringify(origin)}`); }
++ if (u.protocol !== 'https:' || u.username || u.password) throw new TypeError(`openvibe-sdk/menu: ${field} must be an https URL with no credentials, got ${JSON.stringify(origin)}`);
++ return u.hostname.toLowerCase().replace(/^www\./, '');
++}
++
++/** The host a link is on, or null when it is not a plain https URL. */
++function hostIn(value) {
++ try {
++ const u = new URL(String(value));
++ if (u.protocol !== 'https:' || u.username || u.password) return null;
++ return u.hostname.toLowerCase().replace(/^www\./, '');
++ } catch { return null; }
++}
++
++/**
++ * `field` problem for a URL that is not https on `own` (when the site's own host is known) or an
++ * openvibe.* host, else null.
++ */
++function urlProblem(value, field, own) {
++ if (typeof value !== 'string' || !value) return `${field}: required`;
++ if (value.length > 400) return `${field}: at most 400 characters`;
++ const host = hostIn(value);
++ if (!host) return `${field}: must be https with no credentials`;
++ if (host === own || host.startsWith('openvibe.')) return null;
++ return own
++ ? `${field}: must be on ${own} or an openvibe.* host, got ${host}`
++ : `${field}: must be an openvibe.* host (or pass the site's origin for its own host), got ${host}`;
++}
++
++/** The card's own host: `origin`, else the host of `more.url` (the "see all" link is on the site), else null. */
++function ownHostOf(options) {
++ if (options.origin !== undefined) return hostOf(options.origin);
++ if (options.more && typeof options.more === 'object' && options.more.url !== undefined) return hostIn(options.more.url);
++ return null;
++}
++
++/** One item, built field by field. A bad field throws; a picture on another host is dropped. */
++function itemOf(raw, index, own) {
++ const field = `items[${index}]`;
++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new TypeError(`openvibe-sdk/menu: menuCard: ${field} must be an object`);
++ const title = plain(raw.title, 80);
++ if (!title) throw new TypeError(`openvibe-sdk/menu: menuCard: ${field}.title is required (1-80 plain characters)`);
++ const urlField = `${field}.url`;
++ const url = raw.url === undefined ? '' : String(raw.url).trim();
++ const problem = urlProblem(url, urlField, own);
++ if (problem) throw new TypeError(`openvibe-sdk/menu: menuCard: ${problem}`);
++ const item = { title, url };
++ if (raw.meta !== undefined) item.meta = plain(raw.meta, 120);
++ if (raw.image !== undefined) {
++ const image = imageOf(raw.image, `${field}.image`, own);
++ if (image) item.image = image;
++ }
++ return item;
++}
++
++/** One item image on the item's own host (or an openvibe.* host), or null when its src may not be shown. */
++function imageOf(raw, field, own) {
++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new TypeError(`openvibe-sdk/menu: menuCard: ${field} must be an object`);
++ const src = raw.src === undefined ? '' : String(raw.src).trim();
++ if (urlProblem(src, `${field}.src`, own)) return null;
++ const image = { src };
++ for (const k of ['w', 'h']) {
++ if (raw[k] === undefined) continue;
++ const n = Number(raw[k]);
++ if (!Number.isInteger(n) || n < 16 || n > 1920) throw new TypeError(`openvibe-sdk/menu: menuCard: ${field}.${k} must be an integer from 16 to 1920`);
++ image[k] = n;
++ }
++ if (raw.alt !== undefined) image.alt = plain(raw.alt, 120);
++ return image;
++}
++
++/** Contract problems for one card, as "field: reason" strings. Host rules apply when `own` is known. */
++function cardProblems(card, own = null) {
++ const problems = [];
++ if (!card || typeof card !== 'object' || Array.isArray(card)) return ['card must be an object'];
++ const extra = Object.keys(card).filter((k) => !['id', 'site', 'title', 'kind', 'items', 'more', 'updated_at', 'ttl_s'].includes(k));
++ if (extra.length) problems.push(`unknown field ${extra[0]}`);
++ if (typeof card.id !== 'string' || !ID_RE.test(card.id)) problems.push('id must match ^[a-z][a-z0-9-]{1,39}$');
++ if (typeof card.site !== 'string' || !ID_RE.test(card.site)) problems.push('site must match ^[a-z][a-z0-9-]{1,39}$');
++ if (typeof card.title !== 'string' || card.title.length < 1 || card.title.length > 60) problems.push('title must be 1-60 characters');
++ if (!KINDS.includes(card.kind)) problems.push('kind must be rail, rows or chips');
++ if (!Array.isArray(card.items) || card.items.length > MAX_ITEMS) problems.push(`items must be an array of at most ${MAX_ITEMS}`);
++ else {
++ card.items.forEach((item, i) => {
++ const f = `items[${i}]`;
++ if (!item || typeof item !== 'object' || Array.isArray(item)) { problems.push(`${f} must be an object`); return; }
++ const extraItem = Object.keys(item).filter((k) => !['title', 'meta', 'url', 'image'].includes(k));
++ if (extraItem.length) problems.push(`${f}.${extraItem[0]} is an unknown field`);
++ if (typeof item.title !== 'string' || item.title.length < 1 || item.title.length > 80) problems.push(`${f}.title must be 1-80 characters`);
++ if (item.meta !== undefined && (typeof item.meta !== 'string' || item.meta.length > 120)) problems.push(`${f}.meta must be at most 120 characters`);
++ if (typeof item.url !== 'string' || item.url.length > 400) problems.push(`${f}.url must be at most 400 characters`);
++ else {
++ const p = urlProblem(item.url, `${f}.url`, own);
++ if (p) problems.push(p);
++ }
++ if (item.image !== undefined) {
++ const f2 = `${f}.image`;
++ if (!item.image || typeof item.image !== 'object' || Array.isArray(item.image)) problems.push(`${f2} must be an object`);
++ else {
++ const extraImage = Object.keys(item.image).filter((k) => !['src', 'w', 'h', 'alt'].includes(k));
++ if (extraImage.length) problems.push(`${f2}.${extraImage[0]} is an unknown field`);
++ if (typeof item.image.src !== 'string' || item.image.src.length > 400) problems.push(`${f2}.src must be at most 400 characters`);
++ else {
++ const p = urlProblem(item.image.src, `${f2}.src`, own);
++ if (p) problems.push(p);
++ }
++ for (const k of ['w', 'h']) {
++ if (item.image[k] === undefined) continue;
++ const n = item.image[k];
++ if (!Number.isInteger(n) || n < 16 || n > 1920) problems.push(`${f2}.${k} must be an integer from 16 to 1920`);
++ }
++ if (item.image.alt !== undefined && (typeof item.image.alt !== 'string' || item.image.alt.length > 120)) problems.push(`${f2}.alt must be at most 120 characters`);
++ }
++ }
++ });
++ }
++ if (card.more !== undefined) {
++ if (!card.more || typeof card.more !== 'object' || Array.isArray(card.more)) problems.push('more must be an object');
++ else {
++ const extraMore = Object.keys(card.more).filter((k) => !['label', 'url'].includes(k));
++ if (extraMore.length) problems.push(`more.${extraMore[0]} is an unknown field`);
++ if (typeof card.more.label !== 'string' || card.more.label.length < 1 || card.more.label.length > 24) problems.push('more.label must be 1-24 characters');
++ if (typeof card.more.url !== 'string' || card.more.url.length > 400) problems.push('more.url must be at most 400 characters');
++ else {
++ const p = urlProblem(card.more.url, 'more.url', own);
++ if (p) problems.push(p);
++ }
++ }
++ }
++ if (typeof card.updated_at !== 'string' || Number.isNaN(Date.parse(card.updated_at))) problems.push('updated_at must be an ISO date-time');
++ if (!Number.isInteger(card.ttl_s) || card.ttl_s < 30 || card.ttl_s > 86400) problems.push('ttl_s must be an integer from 30 to 86400');
++ return problems;
++}
++
++/** The pinned openvibe-contracts check, when it is installed (and knows this contract); else null. */
++let contracts;
++function contractProblems(card) {
++ if (contracts === undefined) {
++ try { contracts = require('openvibe-contracts'); } catch { contracts = null; }
++ }
++ if (!contracts || typeof contracts.validate !== 'function') return null;
++ let result;
++ try { result = contracts.validate('frame.menu-card@1', card); } catch { return null; } // an older pin without the card: the local copy is the check
++ if (result.valid) return null;
++ return (result.errors || []).map((e) => `${(e.path || '/').replace(/^\//, '')}: ${e.message || 'invalid'}`);
++}
++
++/**
++ * Build one validated frame.menu-card@1.
++ *
++ * @param {object} options
++ * @param {string} options.id Card id in the site, e.g. `new-worlds` (^[a-z][a-z0-9-]{1,39}$).
++ * @param {string} options.site The site's Frame id, e.g. `games` (the site Network checks the host against).
++ * @param {string} options.title The card's heading, cut to 60.
++ * @param {'rail'|'rows'|'chips'} [options.kind='rows']
++ * @param {Array<{title: string, url: string, meta?: string, image?: {src: string, w?: number, h?: number, alt?: string}}>} [options.items]
++ * @param {{label: string, url: string}} [options.more]
++ * @param {string} [options.origin] The site's https origin, for the own-host rule. When omitted,
++ * `more.url`'s host is used; with neither, only openvibe.* links pass.
++ * @param {number} [options.ttlSeconds=300] How long the site considers the card fresh (30-86400).
++ * @param {string|number|Date} [options.updatedAt=now] When the site last built the card.
++ * @returns {object} the card, with no unknown fields, ready for frame.menu-cards@1
++ */
++function menuCard(options = {}) {
++ const o = options && typeof options === 'object' && !Array.isArray(options) ? options : {};
++ if (typeof o.id !== 'string' || !ID_RE.test(o.id)) throw new TypeError('openvibe-sdk/menu: menuCard: id must match ^[a-z][a-z0-9-]{1,39}$');
++ if (typeof o.site !== 'string' || !ID_RE.test(o.site)) throw new TypeError('openvibe-sdk/menu: menuCard: site must match ^[a-z][a-z0-9-]{1,39}$');
++ const title = plain(o.title, 60);
++ if (!title) throw new TypeError('openvibe-sdk/menu: menuCard: title is required (1-60 plain characters)');
++ const kind = o.kind === undefined ? 'rows' : o.kind;
++ if (!KINDS.includes(kind)) throw new TypeError(`openvibe-sdk/menu: menuCard: kind must be one of ${KINDS.join(', ')}, got ${JSON.stringify(kind)}`);
++ if (o.items !== undefined && !Array.isArray(o.items)) throw new TypeError('openvibe-sdk/menu: menuCard: items must be an array');
++ const own = ownHostOf(o);
++ const items = (o.items || []).slice(0, MAX_ITEMS).map((item, i) => itemOf(item, i, own));
++ const card = { id: o.id, site: o.site, title, kind, items };
++ if (o.more !== undefined) {
++ if (!o.more || typeof o.more !== 'object' || Array.isArray(o.more)) throw new TypeError('openvibe-sdk/menu: menuCard: more must be an object');
++ const label = plain(o.more.label, 24);
++ if (!label) throw new TypeError('openvibe-sdk/menu: menuCard: more.label is required (1-24 plain characters)');
++ const url = o.more.url === undefined ? '' : String(o.more.url).trim();
++ const problem = urlProblem(url, 'more.url', own);
++ if (problem) throw new TypeError(`openvibe-sdk/menu: menuCard: ${problem}`);
++ card.more = { label, url };
++ }
++ const at = o.updatedAt === undefined ? new Date() : new Date(o.updatedAt);
++ if (Number.isNaN(at.getTime())) throw new TypeError('openvibe-sdk/menu: menuCard: updatedAt must be a date');
++ card.updated_at = at.toISOString();
++ const ttl = o.ttlSeconds === undefined ? DEFAULT_MAX_AGE_SECONDS : Number(o.ttlSeconds);
++ if (!Number.isInteger(ttl) || ttl < 30 || ttl > 86400) throw new TypeError('openvibe-sdk/menu: menuCard: ttlSeconds must be an integer from 30 to 86400');
++ card.ttl_s = ttl;
++ const problems = cardProblems(card, own);
++ if (problems.length) throw new TypeError(`openvibe-sdk/menu: menuCard: ${problems[0]}`);
++ const byContract = contractProblems(card);
++ if (byContract && byContract.length) throw new TypeError(`openvibe-sdk/menu: menuCard: ${byContract[0]}`);
++ return card;
++}
++
++/** A logger that counts a getCards failure/invalid card once: the route never logs a flood. */
++function onceLogger(log) {
++ let logged = false;
++ return (err) => {
++ if (logged) return;
++ logged = true;
++ const target = log && typeof log.error === 'function' ? log : console;
++ target.error('openvibe-sdk/menu: menu cards unavailable; answering []', err && err.message ? err.message : err);
++ };
++}
++
++/**
++ * The GET /.well-known/openvibe-menu.json handler, for Express (res.set/res.json) or node:http
++ * (res.writeHead/res.end). It answers true when it handled the request, false for another path.
++ *
++ * @param {() => object | Array<object> | Promise<object | Array<object>>} getCards
++ * @param {{maxAgeSeconds?: number, log?: {error: Function}, origin?: string}} [options]
++ */
++function menuRoute(getCards, options = {}) {
++ const o = options && typeof options === 'object' ? options : {};
++ if (typeof getCards !== 'function') throw new TypeError('openvibe-sdk/menu: menuRoute needs a getCards() function');
++ const maxAge = o.maxAgeSeconds === undefined ? DEFAULT_MAX_AGE_SECONDS : Number(o.maxAgeSeconds);
++ if (!Number.isInteger(maxAge) || maxAge < 0) throw new TypeError('openvibe-sdk/menu: menuRoute: maxAgeSeconds must be a non-negative integer');
++ const own = o.origin === undefined ? null : hostOf(o.origin);
++ const logOnce = onceLogger(o.log);
++
++ function send(res, status, body, headers) {
++ if (res && typeof res.set === 'function' && typeof res.json === 'function') {
++ if (typeof res.status === 'function') res.status(status); else if (status !== 200) res.statusCode = status;
++ for (const [k, v] of Object.entries(headers)) res.set(k, v);
++ return res.json(body);
++ }
++ if (res && typeof res.writeHead === 'function') {
++ res.writeHead(status, Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])));
++ return res.end(status === 204 || body === undefined ? undefined : JSON.stringify(body));
++ }
++ throw new TypeError('openvibe-sdk/menu: menuRoute: no Express or node:http response object');
++ }
++
++ return async function menuHandler(req, res) {
++ const url = req && req.url ? String(req.url).split('?')[0] : '';
++ if (url && url !== MENU_PATH && url !== '/') return false;
++ const method = req && req.method ? String(req.method).toUpperCase() : 'GET';
++ if (method !== 'GET' && method !== 'HEAD') {
++ send(res, 405, { error: 'method_not_allowed' }, { 'Content-Type': 'application/json; charset=utf-8', Allow: 'GET, HEAD' });
++ return true;
++ }
++ const head = method === 'HEAD';
++ let cards;
++ try {
++ let value = await getCards();
++ if (value && typeof value === 'object' && !Array.isArray(value)) value = [value]; // one card is a one-card file
++ if (!Array.isArray(value)) throw new TypeError('getCards() must return an array of cards (or one card)');
++ cards = [];
++ for (const card of value.slice(0, MAX_CARDS)) {
++ const problems = cardProblems(card, own);
++ if (problems.length) { logOnce(new Error(problems[0])); continue; }
++ const byContract = contractProblems(card);
++ if (byContract && byContract.length) { logOnce(new Error(byContract[0])); continue; }
++ cards.push(card);
++ }
++ } catch (err) {
++ logOnce(err);
++ send(res, 200, [], {
++ 'Content-Type': 'application/json; charset=utf-8',
++ 'Cache-Control': 'no-store',
++ 'Access-Control-Allow-Origin': '*',
++ 'Cross-Origin-Resource-Policy': 'cross-origin',
++ });
++ return true;
++ }
++ const headers = {
++ 'Content-Type': 'application/json; charset=utf-8',
++ 'Cache-Control': `public, max-age=${maxAge}`,
++ 'Access-Control-Allow-Origin': '*',
++ 'Cross-Origin-Resource-Policy': 'cross-origin',
++ };
++ if (head) {
++ if (res && typeof res.set === 'function' && typeof res.json === 'function') {
++ if (typeof res.status === 'function') res.status(200);
++ for (const [k, v] of Object.entries(headers)) res.set(k, v);
++ res.end();
++ } else {
++ res.writeHead(200, Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])));
++ res.end();
++ }
++ return true;
++ }
++ send(res, 200, cards, headers);
++ return true;
++ };
++}
++
++module.exports = { menuCard, menuRoute, MENU_PATH };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 6e16b08..90cfbba 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './menu', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/cam/index.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
ddiff --git a/test/menu.test.js b/test/menu.test.js
nnew file mode 100644
iindex 0000000..96727e7
---- /dev/null
++++ b/test/menu.test.js
@@@ -0,0 +1,219 @@
++'use strict';
++/**
++ * openvibe-sdk/menu: menuCard builds a valid frame.menu-card@1 (plain text, the contract's caps, the
++ * own-origin/openvibe.* host rule and the optional picture), and menuRoute answers the array with a
++ * public cache and CORS * — [] and one log line, never a 500, when the cards cannot be built.
++ */
++const assert = require('node:assert/strict');
++const contracts = require('openvibe-contracts');
++const { menuCard, menuRoute, MENU_PATH } = require('../src/menu');
++
++const ORIGIN = 'https://openvibe.games';
++const at = Date.parse('2026-11-07T08:00:00Z');
++const base = {
++ id: 'new-worlds', site: 'games', title: 'New worlds', kind: 'rows', origin: ORIGIN,
++ more: { label: 'All worlds', url: 'https://openvibe.games/worlds' },
++ items: [
++ { title: 'Moon base', meta: 'by Builder', url: 'https://openvibe.games/worlds/a' },
++ { title: 'Night run', url: 'https://openvibe.media/t/x.jpg', image: { src: 'https://openvibe.media/t/x.jpg', w: 640, h: 360, alt: 'Night run' } },
++ ],
++ updatedAt: at,
++};
++
++function fakeRes() {
++ const headers = {};
++ let status = 0;
++ let body;
++ let ended = false;
++ const res = {
++ set(k, v) { headers[k.toLowerCase()] = v; return this; },
++ status(n) { status = n; return this; },
++ json(v) { body = v; ended = true; return this; },
++ writeHead(n, h = {}) { status = n; for (const [k, v] of Object.entries(h)) headers[k.toLowerCase()] = v; return this; },
++ end(v) { if (v !== undefined) body = JSON.parse(v); ended = true; return this; },
++ };
++ return { res, headers, status: () => status, body: () => body, ended: () => ended };
++}
++
++const check = (card) => assert.ok(contracts.validate('frame.menu-cards@1', [card]).valid, JSON.stringify(contracts.validate('frame.menu-cards@1', [card]).errors));
++
++(async () => {
++ const tests = [
++ ['menuCard builds a contract-valid card and drops unknown fields', async () => {
++ const card = menuCard({ ...base, extra: 'nope', items: [{ ...base.items[0], click: 'x' }, base.items[1]] });
++ check(card);
++ assert.deepEqual(Object.keys(card), ['id', 'site', 'title', 'kind', 'items', 'more', 'updated_at', 'ttl_s']);
++ assert.equal(card.updated_at, '2026-11-07T08:00:00.000Z');
++ assert.equal(card.ttl_s, 300);
++ assert.deepEqual(Object.keys(card.items[0]), ['title', 'url', 'meta']);
++ assert.ok(!('click' in card.items[0]));
++ assert.deepEqual(card.items[1].image, { src: 'https://openvibe.media/t/x.jpg', w: 640, h: 360, alt: 'Night run' });
++ assert.equal(menuCard({ ...base, kind: undefined }).kind, 'rows');
++ assert.equal(menuCard({ ...base, ttlSeconds: 30, items: [] }).ttl_s, 30);
++ assert.deepEqual(menuCard({ ...base, items: [], more: undefined }).items, []);
++ assert.ok(!('more' in menuCard({ ...base, more: undefined })));
++ }],
++
++ ['text is plain and cut to the contract lengths', async () => {
++ const card = menuCard({
++ ...base,
++ title: `<b>New</b>\n\t worlds ${'x'.repeat(100)}`,
++ items: [{ title: '<i>Moon</i>\n base ' + 'y'.repeat(120), meta: '<b>by</b> ' + 'z'.repeat(200), url: 'https://openvibe.games/w' }],
++ });
++ assert.ok(card.title.startsWith('New worlds '));
++ assert.equal(card.title.length, 60);
++ assert.ok(card.items[0].title.startsWith('Moon base '));
++ assert.equal(card.items[0].title.length, 80);
++ assert.ok(card.items[0].meta.startsWith('by '));
++ assert.equal(card.items[0].meta.length, 120);
++ check(card);
++ assert.throws(() => menuCard({ ...base, title: ' <b></b> ' }), /title is required/);
++ }],
++
++ ['items are capped at the contract maximum (six), and empty is allowed', async () => {
++ const many = Array.from({ length: 10 }, (_, i) => ({ title: `World ${i}`, url: `https://openvibe.games/worlds/${i}` }));
++ const card = menuCard({ ...base, items: many });
++ assert.equal(card.items.length, 6);
++ check(card);
++ assert.deepEqual(menuCard({ ...base, items: [] }).items, [], 'an empty card is valid; Network skips it');
++ }],
++
++ ['urls are https, on the site own origin or an openvibe.* host', async () => {
++ assert.ok(menuCard({ ...base, items: [{ title: 'A', url: 'https://openvibe.live/@nova' }] }));
++ assert.ok(menuCard({ ...base, items: [{ title: 'A', url: 'https://www.openvibe.games/x' }] }), 'www is the same host');
++ assert.throws(() => menuCard({ ...base, items: [{ title: 'A', url: 'https://example.com/x' }] }), /items\[0\]\.url: must be on openvibe\.games or an openvibe\.\* host/);
++ assert.throws(() => menuCard({ ...base, items: [{ title: 'A', url: 'http://openvibe.games/x' }] }), /items\[0\]\.url: must be https/);
++ assert.throws(() => menuCard({ ...base, items: [{ title: 'A', url: 'https://u:[email protected]/x' }] }), /credentials/);
++ assert.throws(() => menuCard({ ...base, items: [{ title: 'A', url: `https://openvibe.games/${'x'.repeat(390)}` }] }), /at most 400/);
++ assert.throws(() => menuCard({ ...base, more: { label: 'All', url: 'https://example.com/worlds' } }), /more\.url: must be on openvibe\.games/);
++ // No origin and no more: only openvibe.* links can be checked, so a third-party link is refused.
++ assert.throws(() => menuCard({ id: base.id, site: base.site, title: base.title, items: [{ title: 'A', url: 'https://example.com/x' }] }), /openvibe\.\* host/);
++ // more.url names the site when origin is omitted (a site's "see all" link is its own page).
++ assert.ok(menuCard({ id: base.id, site: base.site, title: base.title, more: base.more, items: [{ title: 'A', url: 'https://openvibe.games/x' }] }));
++ }],
++
++ ['a picture follows the host rule; one from elsewhere is dropped and the item stays', async () => {
++ const card = menuCard({ ...base, items: [
++ { title: 'Own', url: 'https://openvibe.games/a', image: { src: 'https://openvibe.games/a.jpg', w: 16, h: 1920 } },
++ { title: 'Elsewhere', url: 'https://openvibe.games/b', image: { src: 'https://example.com/b.jpg' } },
++ { title: 'Plain http', url: 'https://openvibe.games/c', image: { src: 'http://openvibe.games/c.jpg' } },
++ ] });
++ assert.deepEqual(card.items[0].image, { src: 'https://openvibe.games/a.jpg', w: 16, h: 1920 });
++ assert.ok(!('image' in card.items[1]) && !('image' in card.items[2]), 'a bad picture src is dropped, never a placeholder');
++ check(card);
++ assert.throws(() => menuCard({ ...base, items: [{ title: 'A', url: 'https://openvibe.games/a', image: { src: 'https://openvibe.games/a.jpg', w: 15 } }] }), /image\.w must be an integer from 16 to 1920/);
++ assert.throws(() => menuCard({ ...base, items: [{ title: 'A', url: 'https://openvibe.games/a', image: 'x' }] }), /image must be an object/);
++ }],
++
++ ['the card fields themselves are checked, naming the bad one', async () => {
++ assert.throws(() => menuCard({ ...base, id: 'Bad' }), /id must match/);
++ assert.throws(() => menuCard({ ...base, site: 'not a site' }), /site must match/);
++ assert.throws(() => menuCard({ ...base, kind: 'list' }), /kind must be one of/);
++ assert.throws(() => menuCard({ ...base, items: 'x' }), /items must be an array/);
++ assert.throws(() => menuCard({ ...base, items: [null] }), /items\[0\] must be an object/);
++ assert.throws(() => menuCard({ ...base, items: [{ title: ' ', url: 'https://openvibe.games/a' }] }), /items\[0\]\.title is required/);
++ assert.throws(() => menuCard({ ...base, more: { label: '', url: 'https://openvibe.games/' } }), /more\.label is required/);
++ assert.throws(() => menuCard({ ...base, ttlSeconds: 29 }), /ttlSeconds must be an integer from 30 to 86400/);
++ assert.throws(() => menuCard({ ...base, updatedAt: 'not a date' }), /updatedAt must be a date/);
++ assert.throws(() => menuCard({ ...base, origin: 'http://openvibe.games' }), /origin must be an https URL/);
++ }],
++
++ ['menuRoute answers Express with the cards, a public cache and CORS *', async () => {
++ const card = menuCard(base);
++ let calls = 0;
++ const handler = menuRoute(async () => { calls++; return [card]; }, { maxAgeSeconds: 30 });
++ assert.equal(MENU_PATH, '/.well-known/openvibe-menu.json');
++ const r = fakeRes();
++ assert.equal(await handler({ method: 'GET', url: MENU_PATH }, r.res), true);
++ assert.equal(calls, 1);
++ assert.equal(r.status(), 200);
++ assert.deepEqual(r.body(), [card]);
++ assert.equal(r.headers['cache-control'], 'public, max-age=30');
++ assert.equal(r.headers['access-control-allow-origin'], '*');
++ assert.match(r.headers['content-type'], /application\/json/);
++ assert.equal(r.headers['cross-origin-resource-policy'], 'cross-origin');
++ }],
++
++ ['menuRoute answers node:http the same way, HEAD has no body, other paths return false', async () => {
++ const card = menuCard(base);
++ const handler = menuRoute(() => [card], { maxAgeSeconds: 60 });
++ const r = fakeRes();
++ assert.equal(await handler({ method: 'GET', url: `${MENU_PATH}?x=1` }, r.res), true);
++ assert.equal(r.status(), 200);
++ assert.deepEqual(r.body(), [card]);
++ assert.equal(r.headers['cache-control'], 'public, max-age=60');
++ const head = fakeRes();
++ assert.equal(await handler({ method: 'HEAD', url: MENU_PATH }, head.res), true);
++ assert.equal(head.status(), 200);
++ assert.equal(head.ended(), true);
++ const other = fakeRes();
++ assert.equal(await handler({ method: 'GET', url: '/api/frame' }, other.res), false);
++ assert.equal(other.ended(), false);
++ const post = fakeRes();
++ assert.equal(await handler({ method: 'POST', url: MENU_PATH }, post.res), true);
++ assert.equal(post.status(), 405);
++ // A router mounted at '/' still answers.
++ const root = fakeRes();
++ assert.equal(await handler({ method: 'GET', url: '/' }, root.res), true);
++ assert.equal(root.status(), 200);
++ }],
++
++ ['a getCards failure is [] with no-store, never a 500, and logs once', async () => {
++ const logged = [];
++ const log = { error: (...a) => logged.push(a) };
++ let fail = true;
++ const card = menuCard(base);
++ const handler = menuRoute(async () => { if (fail) throw new Error('db down'); return [card]; }, { log });
++ const bad = fakeRes();
++ assert.equal(await handler({ method: 'GET', url: MENU_PATH }, bad.res), true);
++ assert.equal(bad.status(), 200);
++ assert.deepEqual(bad.body(), []);
++ assert.equal(bad.headers['access-control-allow-origin'], '*');
++ assert.equal(bad.headers['cache-control'], 'no-store');
++ const bad2 = fakeRes();
++ await handler({ method: 'GET', url: MENU_PATH }, bad2.res);
++ assert.equal(logged.length, 1, 'one build failure logs once, not once per request');
++ fail = false;
++ const good = fakeRes();
++ await handler({ method: 'GET', url: MENU_PATH }, good.res);
++ assert.equal(good.body()[0].site, 'games');
++ }],
++
++ ['an invalid card never reaches the file: it is left out, [] when none is left, logged once', async () => {
++ const card = menuCard(base);
++ const logged = [];
++ const handler = menuRoute(() => [{ ...card, ttl_s: 1 }, card], { log: { error: () => logged.push(1) } });
++ const r = fakeRes();
++ await handler({ method: 'GET', url: MENU_PATH }, r.res);
++ assert.deepEqual(r.body(), [card]);
++ assert.equal(logged.length, 1);
++ const none = fakeRes();
++ await menuRoute(() => [{ ...card, id: 'Bad' }], { log: { error: () => {} } })({ method: 'GET', url: MENU_PATH }, none.res);
++ assert.deepEqual(none.body(), []);
++ const single = fakeRes();
++ await menuRoute(() => card)({ method: 'GET', url: MENU_PATH }, single.res);
++ assert.deepEqual(single.body(), [card], 'one card is accepted as a one-card file');
++ const notArray = fakeRes();
++ await menuRoute(() => 'nope')({ method: 'GET', url: MENU_PATH }, notArray.res);
++ assert.deepEqual(notArray.body(), []);
++ const tooMany = Array.from({ length: 5 }, (_, i) => menuCard({ ...base, id: `card-${i}` }));
++ const capped = fakeRes();
++ await menuRoute(() => tooMany)({ method: 'GET', url: MENU_PATH }, capped.res);
++ assert.equal(capped.body().length, 3, 'frame.menu-cards@1 allows at most three cards');
++ }],
++
++ ['the ESM entry and the package export resolve by name', async () => {
++ const esm = await import('openvibe-sdk/menu');
++ assert.equal(esm.default, require('../src/menu'));
++ assert.equal(typeof esm.menuCard, 'function');
++ assert.equal(typeof esm.menuRoute, 'function');
++ assert.equal(esm.MENU_PATH, MENU_PATH);
++ assert.deepEqual(require('openvibe-sdk/menu'), { MENU_PATH, menuCard, menuRoute });
++ }],
++ ];
++ for (const [name, fn] of tests) {
++ await fn();
++ console.log(` ok ${name}`);
++ }
++ console.log(`menu: ${tests.length} checks, cards validate against openvibe-contracts ${require('openvibe-contracts/package.json').version}`);
++})().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 4b1a712..cdd0d2c 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -24,7 +24,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/exchange.js', 'esm/exchange.mjs', 'types/exchange.d.ts', 'docs/exchange.md', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/cam/index.js', 'esm/cam.mjs', 'types/cam.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'src/streams.js', 'esm/streams.mjs', 'types/streams.d.ts', 'docs/streams.md', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/exchange.js', 'esm/exchange.mjs', 'types/exchange.d.ts', 'docs/exchange.md', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/cam/index.js', 'esm/cam.mjs', 'types/cam.d.ts', 'src/menu/index.js', 'esm/menu.mjs', 'types/menu.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'src/streams.js', 'esm/streams.mjs', 'types/streams.d.ts', 'docs/streams.md', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@@ -59,8 +59,12 @@ run([
assert.equal(bundle.SDK_VERSION, ${JSON.stringify(pkg.version)});
assert.equal(typeof bundle.jobs.createJobsClient, 'function');
assert.equal(typeof bundle.tools.createToolsClient, 'function');
++ const { menuCard } = require('openvibe-sdk/menu');
const { createToolsClient } = require('openvibe-sdk/tools');
const tp = createMockPlatform({ tools: true });
++ const card = menuCard({ id: 'pack-card', site: 'pack', title: 'Pack', origin: 'https://pack.test', items: [] });
++ assert.equal(card.ttl_s, 300);
++ assert.equal(card.items.length, 0);
const ran = await createToolsClient(createClient({ fetch: tp.fetch })).run('jsonminify', { text: '{ "a": 1 }' });
assert.equal(ran.result.text, '{"a":1}');
assert.equal(typeof require.resolve('openvibe-sdk/browser/openvibe-sdk.mjs'), 'string');
@@@ -68,7 +72,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 87); // 0.90.0 assertions, 0.91.0 streams, 0.92.0 chat-apps, 0.94.0 reports, 0.95.0 cam
++ assert.equal(subpaths.length, 88); // 0.90.0 assertions, 0.91.0 streams, 0.92.0 chat-apps, 0.94.0 reports, 0.95.0 cam, 0.97.0 menu
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/types/menu.d.ts b/types/menu.d.ts
nnew file mode 100644
iindex 0000000..b2d9700
---- /dev/null
++++ b/types/menu.d.ts
@@@ -0,0 +1,109 @@
++// openvibe-sdk/menu — the cards a site publishes to the OpenVibe menu (frame.menu-card@1).
++
++import type { IncomingMessage, ServerResponse } from 'node:http';
++
++/** The public file every open site serves: an array of at most three cards. */
++export declare const MENU_PATH: '/.well-known/openvibe-menu.json';
++
++export type MenuCardKind = 'rail' | 'rows' | 'chips';
++
++/** A small picture of an item: the site's own host or an openvibe.* host, https only. */
++export interface MenuCardImage {
++ /** The picture's https address, at most 400 characters. */
++ src: string;
++ /** Natural width, 16-1920. */
++ w?: number;
++ /** Natural height, 16-1920. */
++ h?: number;
++ /** A short description, at most 120 characters. */
++ alt?: string;
++}
++
++export interface MenuCardItem {
++ /** Plain words, 1-80 characters after markup and control characters are stripped. */
++ title: string;
++ /** One short line, at most 120 characters ("12 watching"). */
++ meta?: string;
++ /** https on the site's own origin or an openvibe.* host, at most 400 characters. */
++ url: string;
++ /** Optional; a picture from another host is dropped and the item is kept. */
++ image?: MenuCardImage;
++}
++
++export interface MenuCardMore {
++ /** The link's words, 1-24 characters. */
++ label: string;
++ /** https on the site's own origin or an openvibe.* host. */
++ url: string;
++}
++
++export interface MenuCardOptions {
++ /** Stable card id in the site: ^[a-z][a-z0-9-]{1,39}$ (new-worlds, trending). */
++ id: string;
++ /** The site's Frame id: ^[a-z][a-z0-9-]{1,39}$ (live, games, media-hub). */
++ site: string;
++ /** The heading, cut to 60 plain characters. */
++ title: string;
++ /** Default `rows`. */
++ kind?: MenuCardKind;
++ /** At most six (the contract's cap); an empty list is valid and Network skips the card. */
++ items?: MenuCardItem[];
++ more?: MenuCardMore;
++ /**
++ * The site's https origin, for the own-host rule. When omitted, the host of `more.url` is used;
++ * with neither, only `openvibe.*` links pass.
++ */
++ origin?: string;
++ /** How long the site considers the card fresh, 30-86400 seconds. Default 300. */
++ ttlSeconds?: number;
++ /** When the site last built the card. Default now. */
++ updatedAt?: string | number | Date;
++}
++
++/** A frame.menu-card@1, with no unknown fields. */
++export interface MenuCard {
++ id: string;
++ site: string;
++ title: string;
++ kind: MenuCardKind;
++ items: MenuCardItem[];
++ more?: MenuCardMore;
++ updated_at: string;
++ ttl_s: number;
++}
++
++export type MenuLogger = Pick<Console, 'error'>;
++
++export interface MenuRouteOptions {
++ /** The response's `Cache-Control: public, max-age=…`. Default 300 seconds. */
++ maxAgeSeconds?: number;
++ /** Where a getCards() failure is reported, once. Default console. */
++ log?: MenuLogger;
++ /** When set, every card's links must also be on this https origin or an openvibe.* host. */
++ origin?: string;
++}
++
++/** Express's response shape and node:http's, both accepted by the handler. */
++export type MenuRequest = IncomingMessage & { url?: string; method?: string };
++export type MenuResponse = ServerResponse & {
++ status?: (code: number) => MenuResponse;
++ set?: (name: string, value: string) => MenuResponse;
++ json?: (body: unknown) => unknown;
++ [key: string]: unknown;
++};
++
++/** The handler answers true when it handled the request, false for another path. */
++export type MenuHandler = (req: MenuRequest, res: MenuResponse) => Promise<boolean>;
++
++/**
++ * Build one validated frame.menu-card@1: text stripped and cut, at most six items, https links on
++ * the site's own origin or an openvibe.* host. Throws a TypeError naming the bad field.
++ */
++export declare function menuCard(options: MenuCardOptions): MenuCard;
++
++/**
++ * The GET /.well-known/openvibe-menu.json handler, for Express or node:http. It calls getCards(),
++ * validates the cards and answers them with a public cache and CORS *; a failure or an invalid card
++ * answers [] (never a 500) and is logged once.
++ */
++export declare function menuRoute(getCards: () => MenuCard | MenuCard[] | Promise<MenuCard | MenuCard[]>, options?: MenuRouteOptions): MenuHandler;