openvibers/openvibe.live Commits
Live: the viewer's picture-in-picture layout follows them to another device (#808)
Changes
@@@ -149,3 +149,7 @@ ws.on('message', (data) => {- Set an expiration for tokens used in shared environments- Revoke tokens immediately if compromised- The `last_used_at` field in the token list helps identify unused tokens for cleanup++++## Account layout++++`GET|PATCH /api/me/layout` reads or merges the signed-in person's picture-in-picture layout (`{ "pip": { "<slot>:<source>": place | null } }`: null removes a place, the 60 newest by `at` are kept); the route answers 404 until the account copy is available, so the browser keeps its own.@@@ -823,6 +823,9 @@ app.use('/data/offline', express.static(paths.data('offline'), {// API stays under /api/auth, where /api/auth/health also answers.app.get('/auth/health', authRoutes.health);app.use('/api/auth', authRoutes);++// The signed-in person's picture-in-picture layout, in their Network account (server/me/routes.js); 404 until the++// pinned contracts carry live.layout, so the browser's own copy keeps working.++app.use('/api/me', require('./me/routes').createLayoutRouter());app.use('/api/bench-stream', require('./bench-stream/routes'));// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).app.use('/api/streams', require('./bot/routes'));nnew file mode 100644@@@ -0,0 +1,151 @@++'use strict';++/**++ * The signed-in person's picture-in-picture layout, kept in their OpenVibe.Network account.++ *++ * GET /api/me/layout the record's data, or {} when there is none++ * PATCH /api/me/layout merge { pip: { "<slot>:<source>": place | null } } into it, answer the merged data++ *++ * Live's browser keeps its own copy too (public/js/stream-pip.js); this is the copy that follows a person to++ * another device. Writes go through Network's service module API as the person's subject++ * (PUT /internal/modules/live.layout/:subject with If-Match, one retry on 412 by re-reading), the same client++ * Live's live.profile / live.stats summaries use (server/auth/module-summaries.js).++ *++ * The route answers 404 while the pinned contracts do not know the namespace, so a browser keeps its local copy++ * and nothing breaks during the pin window. Network needs the same contracts pin and the namespace in its Live++ * principal grant (OpenVibe.Network server/identity/principals.js) before its module API accepts the write.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { requireAuth } = require('../auth/auth');++const identity = require('../auth/identity-sync');++++const NAMESPACE = 'live.layout';++const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');++const CLIENT_ID = process.env.OV_OAUTH_CLIENT_ID || 'live';++const PLACE_KEY = /^[0-9]{1,12}:[0-9]{1,12}$/;++const MAX_PLACES = 60;++++class LayoutError extends Error {++ constructor(status, message, errors) {++ super(message);++ this.name = 'LayoutError';++ this.status = status;++ if (errors) this.errors = errors;++ }++}++++const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);++++let modulesClient;++/** Network's module service client, or null when Live has no service credential (then the route is off). */++function client() {++ if (modulesClient !== undefined) return modulesClient;++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';++ if (!clientSecret) return (modulesClient = null);++ const { createClient } = require('openvibe-sdk/core');++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ const { createModulesClient } = require('openvibe-sdk/modules');++ const tokens = createServiceTokenClient({ tokenUrl: `${NETWORK_INTERNAL_URL}/oauth/token`, clientId: CLIENT_ID, clientSecret });++ const core = createClient({ baseUrls: { network: NETWORK_INTERNAL_URL }, tokenProvider: tokens, retries: 1 });++ modulesClient = createModulesClient(core, { baseUrl: NETWORK_INTERNAL_URL }).forSubject;++ return modulesClient;++}++++/** The request's pip entries: only `pip`, every key <slot>:<source>, null meaning "remove". */++function patchOf(body) {++ if (!isPlainObject(body)) throw new LayoutError(400, 'The body must be a JSON object');++ for (const key of Object.keys(body)) if (key !== 'pip') throw new LayoutError(400, `Unknown field "${key}"`);++ if (!isPlainObject(body.pip)) throw new LayoutError(400, '"pip" must be an object of <slot>:<source> places');++ for (const key of Object.keys(body.pip)) if (!PLACE_KEY.test(key)) throw new LayoutError(400, `"${key}" is not a <slot>:<source> key`);++ return body.pip;++}++++/** The record's places with the request's entries applied; null deletes one. */++function mergePlaces(data, patch) {++ const pip = isPlainObject(data && data.pip) ? { ...data.pip } : {};++ for (const [key, value] of Object.entries(patch)) {++ if (value === null) delete pip[key];++ else pip[key] = value;++ }++ return pip;++}++++/** The 60 newest places by `at` (epoch ms); a place without a usable at sorts last. */++function newestPlaces(pip) {++ const entries = Object.entries(pip);++ if (entries.length <= MAX_PLACES) return pip;++ const at = ([, place]) => (place && Number.isFinite(place.at) ? place.at : -Infinity);++ entries.sort((a, b) => (at(b) - at(a)) || String(a[0]).localeCompare(String(b[0])));++ return Object.fromEntries(entries.slice(0, MAX_PLACES));++}++++/** The namespace's schema and quota; 413 is over the quota, 400 is anything the schema refuses. */++function checked(modules, data) {++ const ns = modules.get(NAMESPACE);++ const size = Buffer.byteLength(JSON.stringify(data));++ if (ns && size > ns.quotaBytes) throw new LayoutError(413, `The layout is ${size} bytes; the quota is ${ns.quotaBytes}`);++ const result = modules.validateData(NAMESPACE, data);++ if (!result || !result.valid) throw new LayoutError(400, 'The layout is not valid', result && result.errors);++ return data;++}++++/** Read, merge, validate, write with the revision read; a 412 reads again and applies the same patch once more. */++async function writeLayout(store, subject, patch, modules) {++ for (let attempt = 0; attempt < 2; attempt++) {++ const rec = await store.get(NAMESPACE, subject);++ const data = checked(modules, { pip: newestPlaces(mergePlaces(rec && rec.data, patch)) });++ try {++ const saved = await store.put(NAMESPACE, subject, data, { revision: rec ? rec.revision : 0 });++ return saved && saved.data ? saved.data : data;++ } catch (err) {++ if (err && err.status === 412 && attempt === 0) continue;++ throw err;++ }++ }++}++++const notAvailable = (res) => res.status(404).json({ error: 'The account layout is not available yet' });++const sendLayoutError = (res, err) => res.status(err.status).json({ error: err.message, ...(err.errors ? { errors: err.errors } : {}) });++++/**++ * The router. `modules`, `store` and `auth` are injectable so the route test runs the same code on a stubbed++ * Network and a contracts registry that carries the namespace (the pinned one only will, after the release).++ */++function createLayoutRouter({ modules = contracts.modules, store, auth = requireAuth, subjectOf = identity.subjectOf } = {}) {++ const router = express.Router();++ const layoutStore = () => store || client();++ const storeMissing = (res) => res.status(503).json({ error: 'The account layout store is not configured' });++++ router.get('/layout', auth, async (req, res, next) => {++ try {++ if (!modules.get(NAMESPACE)) return notAvailable(res);++ const subject = await subjectOf(req.user.id);++ if (!subject) return res.json({});++ const service = layoutStore();++ if (!service) return storeMissing(res);++ const rec = await service.get(NAMESPACE, subject);++ res.json(rec && rec.data ? rec.data : {});++ } catch (err) { next(err); }++ });++++ router.patch('/layout', auth, async (req, res, next) => {++ try {++ if (!modules.get(NAMESPACE)) return notAvailable(res);++ let patch;++ try { patch = patchOf(req.body); } catch (err) { return sendLayoutError(res, err); }++ const subject = await subjectOf(req.user.id);++ if (!subject) return res.status(404).json({ error: 'No account to keep this layout in' });++ const service = layoutStore();++ if (!service) return storeMissing(res);++ res.json(await writeLayout(service, subject, patch, modules));++ } catch (err) {++ if (err instanceof LayoutError) return sendLayoutError(res, err);++ if (err && err.code === 'modules.unknown_namespace') return notAvailable(res);++ next(err);++ }++ });++++ return router;++}++++module.exports = { createLayoutRouter, NAMESPACE, mergePlaces, newestPlaces, patchOf, _reset() { modulesClient = undefined; }, LayoutError };nnew file mode 100644@@@ -0,0 +1,227 @@++'use strict';++/**++ * The account picture-in-picture layout (Live's half of the Person's place ask).++ *++ * GET /api/me/layout the record's data, or {} when there is none++ * PATCH /api/me/layout merge { pip: { "<slot>:<source>": place | null } }, answer the merged data++ *++ * Network is stubbed with an in-memory module store and the contracts registry is injected with the live.layout++ * manifest (the pinned one does not carry it until the release is pinned). Sign-in is stubbed with an x-test-user++ * header as agent-streams.test.js does; the subject comes from linked_accounts, as in production.++ *++ * node test/me-layout.test.js++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++console.error = () => {};++++const SID = {++ ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1',++ bob: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2',++ cat: 'usr_01JAB2C3D4E5F6G7H8J9K0MNC3',++};++const USER = Object.fromEntries(Object.entries(SID).map(([name, subject], i) => [i + 1, { id: i + 1, username: name, subject }]));++++const KEY_RE = /^[0-9]{1,12}:[0-9]{1,12}$/;++const MANIFEST = { namespace: 'live.layout', quotaBytes: 8192 };++++/** The manifest's rules, as the pinned openvibe-contracts would apply them after the release. */++function validateLayout(data) {++ const errors = [];++ const bad = (path, message) => errors.push({ path, message });++ if (!data || typeof data !== 'object' || Array.isArray(data)) return { valid: false, errors: [{ path: '/', message: 'must be an object' }] };++ for (const key of Object.keys(data)) if (key !== 'pip') bad('/', `unknown property ${key}`);++ if (!data.pip || typeof data.pip !== 'object' || Array.isArray(data.pip)) bad('/pip', 'must be an object');++ else {++ const entries = Object.entries(data.pip);++ if (entries.length > 60) bad('/pip', 'at most 60 places');++ for (const [key, value] of entries) {++ if (!KEY_RE.test(key)) { bad(`/pip/${key}`, 'key must be <slot>:<source>'); continue; }++ if (!value || typeof value !== 'object' || Array.isArray(value)) { bad(`/pip/${key}`, 'must be a place'); continue; }++ for (const field of Object.keys(value)) if (!['x', 'y', 'w', 'mode', 'tuck', 'muted', 'at'].includes(field)) bad(`/pip/${key}/${field}`, 'unknown field');++ for (const field of ['x', 'y', 'w']) {++ if (typeof value[field] !== 'number' || !(value[field] >= 0 && value[field] <= 1)) bad(`/pip/${key}/${field}`, 'must be a number 0..1');++ }++ if (!['dock', 'float'].includes(value.mode)) bad(`/pip/${key}/mode`, 'must be dock or float');++ if (![null, 'left', 'right'].includes(value.tuck)) bad(`/pip/${key}/tuck`, 'must be left, right or null');++ if (typeof value.muted !== 'boolean') bad(`/pip/${key}/muted`, 'must be a boolean');++ if (!Number.isInteger(value.at) || value.at < 0) bad(`/pip/${key}/at`, 'must be an integer millisecond');++ for (const field of ['x', 'y', 'w', 'mode', 'tuck', 'muted', 'at']) if (!(field in value)) bad(`/pip/${key}/${field}`, 'is required');++ }++ }++ return { valid: errors.length === 0, errors };++}++++const layoutModules = {++ get: (ns) => (ns === 'live.layout' ? MANIFEST : undefined),++ validateData: (ns, data) => (ns === 'live.layout' ? validateLayout(data) : { valid: false, errors: [{ path: '/', message: 'unknown namespace' }] }),++};++const missingModules = { ...layoutModules, get: () => undefined };++++/** Network's module store: one record per subject, revisions guarded as the real PUT does. */++function moduleStore() {++ const rows = new Map();++ const key = (ns, subject) => `${ns}|${subject}`;++ return {++ rows,++ async get(ns, subject) {++ const row = rows.get(key(ns, subject));++ return row ? { data: JSON.parse(JSON.stringify(row.data)), revision: row.revision } : null;++ },++ async put(ns, subject, data, { revision } = {}) {++ const row = rows.get(key(ns, subject));++ if ((row ? row.revision : 0) !== revision) {++ const err = new Error('revision conflict');++ err.status = 412;++ throw err;++ }++ const saved = { data: JSON.parse(JSON.stringify(data)), revision: (row ? row.revision : 0) + 1 };++ rows.set(key(ns, subject), saved);++ return { data: JSON.parse(JSON.stringify(saved.data)), revision: saved.revision };++ },++ };++}++++const place = (at, extra = {}) => ({ x: 0.1, y: 0.2, w: 0.3, mode: 'dock', tuck: null, muted: true, at, ...extra });++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ const raw = db.getDb();++ for (const [id, user] of Object.entries(USER)) {++ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE++ VALUES (?, ?, ?, ?, 'x', 'user', 0)`).run(Number(id), user.username, user.username, `${user.username}@x`);++ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)").run(Number(id), String(100 + Number(id)), user.subject);++ }++++ const { createLayoutRouter } = require('../server/me/routes');++ const auth = (req, res, next) => {++ const user = USER[Number(req.get('x-test-user') || 0)];++ if (!user) return res.status(401).json({ error: 'Authentication required' });++ req.user = user;++ next();++ };++++ const store = moduleStore();++ const app = express();++ app.use(express.json());++ app.use('/api/me', createLayoutRouter({ modules: layoutModules, store, auth }));++ app.use('/api/missing', createLayoutRouter({ modules: missingModules, store, auth }));++ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = async (method, path, { as = 1, body, headers = {} } = {}) => {++ const res = await fetch(`${base}${path}`, {++ method,++ headers: { 'Content-Type': 'application/json', ...(as ? { 'x-test-user': String(as) } : {}), ...headers },++ body: body === undefined ? undefined : JSON.stringify(body),++ });++ const text = await res.text();++ let data; try { data = JSON.parse(text); } catch { data = text; }++ return { status: res.status, data };++ };++++ try {++ await check('GET with no record answers {}', async () => {++ const r = await call('GET', '/api/me/layout');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.data, {});++ });++++ await check('PATCH adds a place and answers the merged data', async () => {++ const r = await call('PATCH', '/api/me/layout', { body: { pip: { '12:34': place(100) } } });++ assert.strictEqual(r.status, 200, JSON.stringify(r.data));++ assert.deepStrictEqual(r.data.pip['12:34'], place(100));++ const got = await call('GET', '/api/me/layout');++ assert.deepStrictEqual(got.data.pip['12:34'], place(100), 'the place is in the account record');++ });++++ await check('PATCH merges into the record and a newer at replaces the place', async () => {++ const r = await call('PATCH', '/api/me/layout', { body: { pip: { '12:34': place(200, { w: 0.5 }), '56:78': place(150, { mode: 'float' }) } } });++ assert.strictEqual(r.status, 200, JSON.stringify(r.data));++ assert.strictEqual(Object.keys(r.data.pip).length, 2);++ assert.deepStrictEqual(r.data.pip['12:34'], place(200, { w: 0.5 }));++ assert.deepStrictEqual(r.data.pip['56:78'], place(150, { mode: 'float' }));++ });++++ await check('a null entry removes a place', async () => {++ const r = await call('PATCH', '/api/me/layout', { body: { pip: { '12:34': null } } });++ assert.strictEqual(r.status, 200, JSON.stringify(r.data));++ assert.deepStrictEqual(Object.keys(r.data.pip), ['56:78']);++ assert.strictEqual((await call('GET', '/api/me/layout')).data.pip['12:34'], undefined);++ });++++ await check('over 60 places keeps the 60 newest by at', async () => {++ const pip = {};++ for (let i = 1; i <= 61; i++) pip[`1:${i}`] = place(i);++ const r = await call('PATCH', '/api/me/layout', { as: 2, body: { pip } });++ assert.strictEqual(r.status, 200, JSON.stringify(r.data));++ assert.strictEqual(Object.keys(r.data.pip).length, 60, 'trimmed to 60');++ assert.strictEqual(r.data.pip['1:1'], undefined, 'the oldest place went');++ assert.ok(r.data.pip['1:61'], 'the newest place stayed');++ assert.strictEqual((await call('GET', '/api/me/layout', { as: 2 })).data.pip['1:61'].at, 61);++ });++++ await check('a place key that is not <slot>:<source> is a 400', async () => {++ for (const pip of [{ 'slot:source': place(1) }, { '1:2:3': place(1) }, { '1:2': { ...place(1), x: 1.5 } }, { '1:2': { ...place(1), extra: true } }]) {++ const r = await call('PATCH', '/api/me/layout', { body: { pip } });++ assert.strictEqual(r.status, 400, JSON.stringify(r.data));++ }++ const extra = await call('PATCH', '/api/me/layout', { body: { pip: {}, other: true } });++ assert.strictEqual(extra.status, 400);++ });++++ await check('over the quota is a 413', async () => {++ const big = {};++ for (let i = 0; i < 60; i++) big[`${100000000000 + i}:${200000000000 + i}`] = place(123456789012345, { x: 0.123456789012345, y: 0.123456789012345, w: 0.123456789012345, mode: 'float', tuck: 'right', muted: false });++ const r = await call('PATCH', '/api/me/layout', { as: 3, body: { pip: big } });++ assert.strictEqual(r.status, 413, JSON.stringify(r.data));++ });++++ await check('a signed-out request is a 401', async () => {++ assert.strictEqual((await call('GET', '/api/me/layout', { as: 0 })).status, 401);++ assert.strictEqual((await call('PATCH', '/api/me/layout', { as: 0, body: { pip: {} } })).status, 401);++ });++++ await check('without the namespace the route answers 404 for both verbs', async () => {++ assert.strictEqual((await call('GET', '/api/missing/layout')).status, 404);++ assert.strictEqual((await call('PATCH', '/api/missing/layout', { body: { pip: {} } })).status, 404);++ });++++ await check('a revision conflict reads again and applies the same patch', async () => {++ assert.strictEqual((await call('PATCH', '/api/me/layout', { as: 3, body: { pip: { '9:9': place(1) } } })).status, 200);++ const row = store.rows.get(`live.layout|${SID.cat}`);++ assert.ok(row, 'the subject has a record to race');++ // Give the next write a moved revision once: the PUT is refused, the retry re-reads and succeeds.++ const realPut = store.put;++ let conflicts = 0;++ store.put = async (...args) => {++ if (conflicts++ === 0) { row.revision += 1; const err = new Error('revision conflict'); err.status = 412; throw err; }++ return realPut(...args);++ };++ try {++ const r = await call('PATCH', '/api/me/layout', { as: 3, body: { pip: { '3:4': place(7) } } });++ assert.strictEqual(r.status, 200, JSON.stringify(r.data));++ assert.deepStrictEqual(r.data.pip['3:4'], place(7));++ } finally {++ store.put = realPut;++ }++ });++ } finally {++ server.close();++ }++++ if (failures) { quiet(`me-layout: ${failures} failed`); process.exit(1); }++ quiet('me-layout: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -149,3 +149,7 @@ ws.on('message', (data) => {
@@ -149,3 +149,7 @@ ws.on('message', (data) => {
- Set an expiration for tokens used in shared environments
- Set an expiration for tokens used in shared environments
- Revoke tokens immediately if compromised
- Revoke tokens immediately if compromised
- The `last_used_at` field in the token list helps identify unused tokens for cleanup
- The `last_used_at` field in the token list helps identify unused tokens for cleanup
+
+## Account layout
+
+`GET|PATCH /api/me/layout` reads or merges the signed-in person's picture-in-picture layout (`{ "pip": { "<slot>:<source>": place | null } }`: null removes a place, the 60 newest by `at` are kept); the route answers 404 until the account copy is available, so the browser keeps its own.
@@ -823,6 +823,9 @@ app.use('/data/offline', express.static(paths.data('offline'), {
@@ -823,6 +823,9 @@ app.use('/data/offline', express.static(paths.data('offline'), {
// API stays under /api/auth, where /api/auth/health also answers.
// API stays under /api/auth, where /api/auth/health also answers.
app.get('/auth/health', authRoutes.health);
app.get('/auth/health', authRoutes.health);
app.use('/api/auth', authRoutes);
app.use('/api/auth', authRoutes);
+// The signed-in person's picture-in-picture layout, in their Network account (server/me/routes.js); 404 until the
+// pinned contracts carry live.layout, so the browser's own copy keeps working.
+app.use('/api/me', require('./me/routes').createLayoutRouter());
app.use('/api/bench-stream', require('./bench-stream/routes'));
app.use('/api/bench-stream', require('./bench-stream/routes'));
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
app.use('/api/streams', require('./bot/routes'));
app.use('/api/streams', require('./bot/routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+'use strict';
+/**
+ * The signed-in person's picture-in-picture layout, kept in their OpenVibe.Network account.
+ *
+ * GET /api/me/layout the record's data, or {} when there is none
+ * PATCH /api/me/layout merge { pip: { "<slot>:<source>": place | null } } into it, answer the merged data
+ *
+ * Live's browser keeps its own copy too (public/js/stream-pip.js); this is the copy that follows a person to
+ * another device. Writes go through Network's service module API as the person's subject
+ * (PUT /internal/modules/live.layout/:subject with If-Match, one retry on 412 by re-reading), the same client
+ * Live's live.profile / live.stats summaries use (server/auth/module-summaries.js).
+ *
+ * The route answers 404 while the pinned contracts do not know the namespace, so a browser keeps its local copy
+ * and nothing breaks during the pin window. Network needs the same contracts pin and the namespace in its Live
+ * principal grant (OpenVibe.Network server/identity/principals.js) before its module API accepts the write.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { requireAuth } = require('../auth/auth');
+const identity = require('../auth/identity-sync');
+
+const NAMESPACE = 'live.layout';
+const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+const CLIENT_ID = process.env.OV_OAUTH_CLIENT_ID || 'live';
+const PLACE_KEY = /^[0-9]{1,12}:[0-9]{1,12}$/;
+const MAX_PLACES = 60;
+
+class LayoutError extends Error {
+ constructor(status, message, errors) {
+ super(message);
+ this.name = 'LayoutError';
+ this.status = status;
+ if (errors) this.errors = errors;
+ }
+}
+
+const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+
+let modulesClient;
+/** Network's module service client, or null when Live has no service credential (then the route is off). */
+function client() {
+ if (modulesClient !== undefined) return modulesClient;
+ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
+ if (!clientSecret) return (modulesClient = null);
+ const { createClient } = require('openvibe-sdk/core');
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ const { createModulesClient } = require('openvibe-sdk/modules');
+ const tokens = createServiceTokenClient({ tokenUrl: `${NETWORK_INTERNAL_URL}/oauth/token`, clientId: CLIENT_ID, clientSecret });
+ const core = createClient({ baseUrls: { network: NETWORK_INTERNAL_URL }, tokenProvider: tokens, retries: 1 });
+ modulesClient = createModulesClient(core, { baseUrl: NETWORK_INTERNAL_URL }).forSubject;
+ return modulesClient;
+}
+
+/** The request's pip entries: only `pip`, every key <slot>:<source>, null meaning "remove". */
+function patchOf(body) {
+ if (!isPlainObject(body)) throw new LayoutError(400, 'The body must be a JSON object');
+ for (const key of Object.keys(body)) if (key !== 'pip') throw new LayoutError(400, `Unknown field "${key}"`);
+ if (!isPlainObject(body.pip)) throw new LayoutError(400, '"pip" must be an object of <slot>:<source> places');
+ for (const key of Object.keys(body.pip)) if (!PLACE_KEY.test(key)) throw new LayoutError(400, `"${key}" is not a <slot>:<source> key`);
+ return body.pip;
+}
+
+/** The record's places with the request's entries applied; null deletes one. */
+function mergePlaces(data, patch) {
+ const pip = isPlainObject(data && data.pip) ? { ...data.pip } : {};
+ for (const [key, value] of Object.entries(patch)) {
+ if (value === null) delete pip[key];
+ else pip[key] = value;
+ }
+ return pip;
+}
+
+/** The 60 newest places by `at` (epoch ms); a place without a usable at sorts last. */
+function newestPlaces(pip) {
+ const entries = Object.entries(pip);
+ if (entries.length <= MAX_PLACES) return pip;
+ const at = ([, place]) => (place && Number.isFinite(place.at) ? place.at : -Infinity);
+ entries.sort((a, b) => (at(b) - at(a)) || String(a[0]).localeCompare(String(b[0])));
+ return Object.fromEntries(entries.slice(0, MAX_PLACES));
+}
+
+/** The namespace's schema and quota; 413 is over the quota, 400 is anything the schema refuses. */
+function checked(modules, data) {
+ const ns = modules.get(NAMESPACE);
+ const size = Buffer.byteLength(JSON.stringify(data));
+ if (ns && size > ns.quotaBytes) throw new LayoutError(413, `The layout is ${size} bytes; the quota is ${ns.quotaBytes}`);
+ const result = modules.validateData(NAMESPACE, data);
+ if (!result || !result.valid) throw new LayoutError(400, 'The layout is not valid', result && result.errors);
+ return data;
+}
+
+/** Read, merge, validate, write with the revision read; a 412 reads again and applies the same patch once more. */
+async function writeLayout(store, subject, patch, modules) {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const rec = await store.get(NAMESPACE, subject);
+ const data = checked(modules, { pip: newestPlaces(mergePlaces(rec && rec.data, patch)) });
+ try {
+ const saved = await store.put(NAMESPACE, subject, data, { revision: rec ? rec.revision : 0 });
+ return saved && saved.data ? saved.data : data;
+ } catch (err) {
+ if (err && err.status === 412 && attempt === 0) continue;
+ throw err;
+ }
+ }
+}
+
+const notAvailable = (res) => res.status(404).json({ error: 'The account layout is not available yet' });
+const sendLayoutError = (res, err) => res.status(err.status).json({ error: err.message, ...(err.errors ? { errors: err.errors } : {}) });
+
+/**
+ * The router. `modules`, `store` and `auth` are injectable so the route test runs the same code on a stubbed
+ * Network and a contracts registry that carries the namespace (the pinned one only will, after the release).
+ */
+function createLayoutRouter({ modules = contracts.modules, store, auth = requireAuth, subjectOf = identity.subjectOf } = {}) {
+ const router = express.Router();
+ const layoutStore = () => store || client();
+ const storeMissing = (res) => res.status(503).json({ error: 'The account layout store is not configured' });
+
+ router.get('/layout', auth, async (req, res, next) => {
+ try {
+ if (!modules.get(NAMESPACE)) return notAvailable(res);
+ const subject = await subjectOf(req.user.id);
+ if (!subject) return res.json({});
+ const service = layoutStore();
+ if (!service) return storeMissing(res);
+ const rec = await service.get(NAMESPACE, subject);
+ res.json(rec && rec.data ? rec.data : {});
+ } catch (err) { next(err); }
+ });
+
+ router.patch('/layout', auth, async (req, res, next) => {
+ try {
+ if (!modules.get(NAMESPACE)) return notAvailable(res);
+ let patch;
+ try { patch = patchOf(req.body); } catch (err) { return sendLayoutError(res, err); }
+ const subject = await subjectOf(req.user.id);
+ if (!subject) return res.status(404).json({ error: 'No account to keep this layout in' });
+ const service = layoutStore();
+ if (!service) return storeMissing(res);
+ res.json(await writeLayout(service, subject, patch, modules));
+ } catch (err) {
+ if (err instanceof LayoutError) return sendLayoutError(res, err);
+ if (err && err.code === 'modules.unknown_namespace') return notAvailable(res);
+ next(err);
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createLayoutRouter, NAMESPACE, mergePlaces, newestPlaces, patchOf, _reset() { modulesClient = undefined; }, LayoutError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
+'use strict';
+/**
+ * The account picture-in-picture layout (Live's half of the Person's place ask).
+ *
+ * GET /api/me/layout the record's data, or {} when there is none
+ * PATCH /api/me/layout merge { pip: { "<slot>:<source>": place | null } }, answer the merged data
+ *
+ * Network is stubbed with an in-memory module store and the contracts registry is injected with the live.layout
+ * manifest (the pinned one does not carry it until the release is pinned). Sign-in is stubbed with an x-test-user
+ * header as agent-streams.test.js does; the subject comes from linked_accounts, as in production.
+ *
+ * node test/me-layout.test.js
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+console.error = () => {};
+
+const SID = {
+ ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1',
+ bob: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2',
+ cat: 'usr_01JAB2C3D4E5F6G7H8J9K0MNC3',
+};
+const USER = Object.fromEntries(Object.entries(SID).map(([name, subject], i) => [i + 1, { id: i + 1, username: name, subject }]));
+
+const KEY_RE = /^[0-9]{1,12}:[0-9]{1,12}$/;
+const MANIFEST = { namespace: 'live.layout', quotaBytes: 8192 };
+
+/** The manifest's rules, as the pinned openvibe-contracts would apply them after the release. */
+function validateLayout(data) {
+ const errors = [];
+ const bad = (path, message) => errors.push({ path, message });
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return { valid: false, errors: [{ path: '/', message: 'must be an object' }] };
+ for (const key of Object.keys(data)) if (key !== 'pip') bad('/', `unknown property ${key}`);
+ if (!data.pip || typeof data.pip !== 'object' || Array.isArray(data.pip)) bad('/pip', 'must be an object');
+ else {
+ const entries = Object.entries(data.pip);
+ if (entries.length > 60) bad('/pip', 'at most 60 places');
+ for (const [key, value] of entries) {
+ if (!KEY_RE.test(key)) { bad(`/pip/${key}`, 'key must be <slot>:<source>'); continue; }
+ if (!value || typeof value !== 'object' || Array.isArray(value)) { bad(`/pip/${key}`, 'must be a place'); continue; }
+ for (const field of Object.keys(value)) if (!['x', 'y', 'w', 'mode', 'tuck', 'muted', 'at'].includes(field)) bad(`/pip/${key}/${field}`, 'unknown field');
+ for (const field of ['x', 'y', 'w']) {
+ if (typeof value[field] !== 'number' || !(value[field] >= 0 && value[field] <= 1)) bad(`/pip/${key}/${field}`, 'must be a number 0..1');
+ }
+ if (!['dock', 'float'].includes(value.mode)) bad(`/pip/${key}/mode`, 'must be dock or float');
+ if (![null, 'left', 'right'].includes(value.tuck)) bad(`/pip/${key}/tuck`, 'must be left, right or null');
+ if (typeof value.muted !== 'boolean') bad(`/pip/${key}/muted`, 'must be a boolean');
+ if (!Number.isInteger(value.at) || value.at < 0) bad(`/pip/${key}/at`, 'must be an integer millisecond');
+ for (const field of ['x', 'y', 'w', 'mode', 'tuck', 'muted', 'at']) if (!(field in value)) bad(`/pip/${key}/${field}`, 'is required');
+ }
+ }
+ return { valid: errors.length === 0, errors };
+}
+
+const layoutModules = {
+ get: (ns) => (ns === 'live.layout' ? MANIFEST : undefined),
+ validateData: (ns, data) => (ns === 'live.layout' ? validateLayout(data) : { valid: false, errors: [{ path: '/', message: 'unknown namespace' }] }),
+};
+const missingModules = { ...layoutModules, get: () => undefined };
+
+/** Network's module store: one record per subject, revisions guarded as the real PUT does. */
+function moduleStore() {
+ const rows = new Map();
+ const key = (ns, subject) => `${ns}|${subject}`;
+ return {
+ rows,
+ async get(ns, subject) {
+ const row = rows.get(key(ns, subject));
+ return row ? { data: JSON.parse(JSON.stringify(row.data)), revision: row.revision } : null;
+ },
+ async put(ns, subject, data, { revision } = {}) {
+ const row = rows.get(key(ns, subject));
+ if ((row ? row.revision : 0) !== revision) {
+ const err = new Error('revision conflict');
+ err.status = 412;
+ throw err;
+ }
+ const saved = { data: JSON.parse(JSON.stringify(data)), revision: (row ? row.revision : 0) + 1 };
+ rows.set(key(ns, subject), saved);
+ return { data: JSON.parse(JSON.stringify(saved.data)), revision: saved.revision };
+ },
+ };
+}
+
+const place = (at, extra = {}) => ({ x: 0.1, y: 0.2, w: 0.3, mode: 'dock', tuck: null, muted: true, at, ...extra });
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ const raw = db.getDb();
+ for (const [id, user] of Object.entries(USER)) {
+ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE
+ VALUES (?, ?, ?, ?, 'x', 'user', 0)`).run(Number(id), user.username, user.username, `${user.username}@x`);
+ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)").run(Number(id), String(100 + Number(id)), user.subject);
+ }
+
+ const { createLayoutRouter } = require('../server/me/routes');
+ const auth = (req, res, next) => {
+ const user = USER[Number(req.get('x-test-user') || 0)];
+ if (!user) return res.status(401).json({ error: 'Authentication required' });
+ req.user = user;
+ next();
+ };
+
+ const store = moduleStore();
+ const app = express();
+ app.use(express.json());
+ app.use('/api/me', createLayoutRouter({ modules: layoutModules, store, auth }));
+ app.use('/api/missing', createLayoutRouter({ modules: missingModules, store, auth }));
+ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = async (method, path, { as = 1, body, headers = {} } = {}) => {
+ const res = await fetch(`${base}${path}`, {
+ method,
+ headers: { 'Content-Type': 'application/json', ...(as ? { 'x-test-user': String(as) } : {}), ...headers },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ const text = await res.text();
+ let data; try { data = JSON.parse(text); } catch { data = text; }
+ return { status: res.status, data };
+ };
+
+ try {
+ await check('GET with no record answers {}', async () => {
+ const r = await call('GET', '/api/me/layout');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.data, {});
+ });
+
+ await check('PATCH adds a place and answers the merged data', async () => {
+ const r = await call('PATCH', '/api/me/layout', { body: { pip: { '12:34': place(100) } } });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.data));
+ assert.deepStrictEqual(r.data.pip['12:34'], place(100));
+ const got = await call('GET', '/api/me/layout');
+ assert.deepStrictEqual(got.data.pip['12:34'], place(100), 'the place is in the account record');
+ });
+
+ await check('PATCH merges into the record and a newer at replaces the place', async () => {
+ const r = await call('PATCH', '/api/me/layout', { body: { pip: { '12:34': place(200, { w: 0.5 }), '56:78': place(150, { mode: 'float' }) } } });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.data));
+ assert.strictEqual(Object.keys(r.data.pip).length, 2);
+ assert.deepStrictEqual(r.data.pip['12:34'], place(200, { w: 0.5 }));
+ assert.deepStrictEqual(r.data.pip['56:78'], place(150, { mode: 'float' }));
+ });
+
+ await check('a null entry removes a place', async () => {
+ const r = await call('PATCH', '/api/me/layout', { body: { pip: { '12:34': null } } });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.data));
+ assert.deepStrictEqual(Object.keys(r.data.pip), ['56:78']);
+ assert.strictEqual((await call('GET', '/api/me/layout')).data.pip['12:34'], undefined);
+ });
+
+ await check('over 60 places keeps the 60 newest by at', async () => {
+ const pip = {};
+ for (let i = 1; i <= 61; i++) pip[`1:${i}`] = place(i);
+ const r = await call('PATCH', '/api/me/layout', { as: 2, body: { pip } });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.data));
+ assert.strictEqual(Object.keys(r.data.pip).length, 60, 'trimmed to 60');
+ assert.strictEqual(r.data.pip['1:1'], undefined, 'the oldest place went');
+ assert.ok(r.data.pip['1:61'], 'the newest place stayed');
+ assert.strictEqual((await call('GET', '/api/me/layout', { as: 2 })).data.pip['1:61'].at, 61);
+ });
+
+ await check('a place key that is not <slot>:<source> is a 400', async () => {
+ for (const pip of [{ 'slot:source': place(1) }, { '1:2:3': place(1) }, { '1:2': { ...place(1), x: 1.5 } }, { '1:2': { ...place(1), extra: true } }]) {
+ const r = await call('PATCH', '/api/me/layout', { body: { pip } });
+ assert.strictEqual(r.status, 400, JSON.stringify(r.data));
+ }
+ const extra = await call('PATCH', '/api/me/layout', { body: { pip: {}, other: true } });
+ assert.strictEqual(extra.status, 400);
+ });
+
+ await check('over the quota is a 413', async () => {
+ const big = {};
+ for (let i = 0; i < 60; i++) big[`${100000000000 + i}:${200000000000 + i}`] = place(123456789012345, { x: 0.123456789012345, y: 0.123456789012345, w: 0.123456789012345, mode: 'float', tuck: 'right', muted: false });
+ const r = await call('PATCH', '/api/me/layout', { as: 3, body: { pip: big } });
+ assert.strictEqual(r.status, 413, JSON.stringify(r.data));
+ });
+
+ await check('a signed-out request is a 401', async () => {
+ assert.strictEqual((await call('GET', '/api/me/layout', { as: 0 })).status, 401);
+ assert.strictEqual((await call('PATCH', '/api/me/layout', { as: 0, body: { pip: {} } })).status, 401);
+ });
+
+ await check('without the namespace the route answers 404 for both verbs', async () => {
+ assert.strictEqual((await call('GET', '/api/missing/layout')).status, 404);
+ assert.strictEqual((await call('PATCH', '/api/missing/layout', { body: { pip: {} } })).status, 404);
+ });
+
+ await check('a revision conflict reads again and applies the same patch', async () => {
+ assert.strictEqual((await call('PATCH', '/api/me/layout', { as: 3, body: { pip: { '9:9': place(1) } } })).status, 200);
+ const row = store.rows.get(`live.layout|${SID.cat}`);
+ assert.ok(row, 'the subject has a record to race');
+ // Give the next write a moved revision once: the PUT is refused, the retry re-reads and succeeds.
+ const realPut = store.put;
+ let conflicts = 0;
+ store.put = async (...args) => {
+ if (conflicts++ === 0) { row.revision += 1; const err = new Error('revision conflict'); err.status = 412; throw err; }
+ return realPut(...args);
+ };
+ try {
+ const r = await call('PATCH', '/api/me/layout', { as: 3, body: { pip: { '3:4': place(7) } } });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.data));
+ assert.deepStrictEqual(r.data.pip['3:4'], place(7));
+ } finally {
+ store.put = realPut;
+ }
+ });
+ } finally {
+ server.close();
+ }
+
+ if (failures) { quiet(`me-layout: ${failures} failed`); process.exit(1); }
+ quiet('me-layout: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });