openvibers/openvibe.network Commits
Menu feeds follow the person across sites (#636)
Changes
@@@ -6,7 +6,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4000,"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",-- "contracts": "openvibe-contracts v0.324.0",++ "contracts": "openvibe-contracts v0.326.0","sdk": "openvibe-sdk v0.94.0","shared": "openvibe-shared v4.133.0","capabilities": [@@@ -51,7 +51,8 @@"universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)","the Dock's state per person: GET and PUT /api/v1/me/dock (network.dock-state@1, network.dock-result@1)","the machines hub (/api/v1/machines, plan L34): a person's machines, their roles and what they do right now, with Bot as the registry underneath (Network calls it as the person) and network.machine.online/offline/paired/roles_changed/revoked published through OpenVibe.Events",-- "Space's builder as a task of the person's one agent (plan 2026-10-22 §Z stage Z1): the grants that let Space start a task of the person's agent on OpenVibe.Actor and let that agent work on the person's own spaces through OpenVibe.Space's builder API"++ "Space's builder as a task of the person's one agent (plan 2026-10-22 §Z stage Z1): the grants that let Space start a task of the person's agent on OpenVibe.Actor and let that agent work on the person's own spaces through OpenVibe.Space's builder API",++ "the menu's feeds per person: GET and PUT /api/v1/me/menu-feeds (network.menu-feeds@1) keep the OpenVibe menu's Choose your feeds choice across every site"],"updated": "2026-11-07"}nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- The OpenVibe menu's "Choose your feeds" (2026-11-07): the sites' menu cards a person chose to show, in their++-- order, follow the account to every OpenVibe site, the way site_prefs does. One JSON value per person,++-- { on: ['<site>:<cardId>', …], updated_at }, in the row they already have; an older release never reads it.++ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS menu_feeds text COLLATE "C";@@@ -20,7 +20,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.326.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0","pg": "^8.23.0",@@@ -2303,9 +2303,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.324.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",-- "integrity": "sha512-fSeabmHcX74SxVKEfGXMgWhEDugYQh5KWB+5Vj86CTvr2q50e/7AfHBDtVVw2WYQ06wiH1wN0hctDYoc2DQi9Q==",++ "version": "0.326.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.326.0",++ "integrity": "sha512-syArEGWcPUwApDcazsLXXfiXZ4ThPhVo1+Q9zplYplEKeqAFxc8fV8ADHhsjE4rClS4jw4z0eqB7yKemU7U1Bg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.326.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0","pg": "^8.23.0",@@@ -177,8 +177,9 @@ async function networkPart(db, userId) {await byUser('sessions.json', 'user_sessions');await byUser('oauth_grants.json', 'oauth_tokens');await byUser('notifications.json', 'notifications');++ const prefs = (await rowsOf(db, 'user_preferences', 'user_id = ?', [u.id])).rows[0] || null;add('preferences.json', {-- display: (await rowsOf(db, 'user_preferences', 'user_id = ?', [u.id])).rows[0] || null,++ display: prefs,notifications: (await rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id])).rows,quiet_hours: (await rowsOf(db, 'notification_quiet_hours', 'user_id = ?', [u.id])).rows[0] || null,effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,@@@ -186,6 +187,12 @@ async function networkPart(db, userId) {theme_history: (await rowsOf(db, 'user_theme_history', 'user_id = ?', [u.id])).rows,theme_site_overrides: (await rowsOf(db, 'user_theme_site_overrides', 'user_id = ?', [u.id])).rows,});++ // The menu's "Choose your feeds" (2026-11-07): the choice sits in the preferences row above; its own file makes++ // it readable without opening preferences.json. Never chosen reads as { on: null }, exactly as the route answers.++ let feeds = prefs && prefs.menu_feeds;++ if (typeof feeds === 'string') { try { feeds = JSON.parse(feeds); } catch { feeds = null; } }++ add('menu-feeds.json', feeds && typeof feeds === 'object' && Array.isArray(feeds.on)++ ? { on: feeds.on, updated_at: feeds.updated_at || null } : { on: null });await byUser('history.json', 'user_history');// The Dock's state (the Dock plan 2026-10-30, K-D1): the little floating surface every OpenVibe site draws.add('dock.json', await tableExists(db, 'dock_states')@@@ -792,6 +792,11 @@ require('./developer/confirmations').start(db);// the Action Center's own, so a page view never fans out further than the aggregator's 10 s cache already does.const dock = require('./dock').createDock({ db, actions, notifications: notificationService });app.use('/api/v1/me/dock', require('./dock').router({ dock, requireAuth: requireDock('dock') }));++ // The menu's "Choose your feeds" (2026-11-07): the sites' menu cards a person chose, in their order, follow them++ // across every site (localStorage is per site). Stored in their preferences row, one JSON value; 60 writes a++ // minute per person, like the Dock's. GET answers { on: null } for a person who has never chosen.++ const menuFeeds = require('./menu-feeds').createMenuFeeds({ db });++ app.use('/api/v1/me/menu-feeds', require('./menu-feeds').router({ menuFeeds, requireAuth }));// The Dock's timer, for a first-party service and not for a person (plan L48 R2): Vibe starts the focus timer of// the person it is talking with, and it counts down in the Dock's pill on every OpenVibe site. The service names// the person and writes the timer and nothing else (network.dock.timer.write, granted to Chat alone).nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++/**++ * The OpenVibe menu's "Choose your feeds" (2026-11-07): the sites' menu cards a person chose to show, in their++ * order, so the menu shows the same feeds on every OpenVibe site. localStorage is per site, so the choice follows++ * the person here, in the Network.++ *++ * GET /api/v1/me/menu-feeds network.menu-feeds@1: the stored choice, or { on: null } when never set++ * PUT /api/v1/me/menu-feeds { on: ['<site>:<cardId>', …] }: replace the choice; answers the stored contract++ *++ * The person comes from the token and nowhere else, so one person never reads or writes another's choice. The body++ * is checked against network.menu-feeds@1 (at most four `<site>:<cardId>` ids) before anything is stored, and a++ * body the contract refuses is a 400 with the field and the fault in `detail` — never a quietly changed choice.++ * A write is limited to 60 a minute per person, like the Dock's, so a runaway tab cannot fill the row.++ *++ * The column, not a table: the choice belongs in the preferences row the person already has, and an account's++ * export and erasure already carry that row (migrations/0048_menu_feeds.sql, expand-only).++ */++const express = require('express');++const { validate } = require('openvibe-contracts');++++const CONTRACT = 'network.menu-feeds@1';++const WRITE_LIMIT = 60;++const WRITE_WINDOW_MS = 60_000;++// The two fields a PUT body may carry. `updated_at` is the server's to set; a client that echoes it back is++// accepted, but it never chooses the time.++const BODY_FIELDS = new Set(['on', 'updated_at']);++++const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);++++/** The first fault in a contract result, as `field: message`, for the 400's detail. */++function firstFault(errors) {++ const first = (errors || [])[0];++ if (!first) return 'not a menu-feeds choice';++ const field = String(first.instancePath || '').replace(/^\//, '').replace(/\//g, '.');++ return field ? `${field}: ${first.message}` : String(first.message || 'not a menu-feeds choice');++}++++/** A value the contract accepts, or null when it does not. */++function checked(value) {++ const r = validate(CONTRACT, value);++ return r.valid ? null : (r.errors || []);++}++++/**++ * The menu-feeds choice for one person.++ * db the Network database++ * now the clock, injectable for the tests++ */++function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT, writeWindowMs = WRITE_WINDOW_MS, log = console } = {}) {++ if (!db) throw new TypeError('createMenuFeeds: db is required');++ const writes = new Map();++++ /** The stored choice, exactly as the contract has it, or { on: null } when there is none. */++ async function read(userId) {++ const row = await db.prepare('SELECT menu_feeds FROM user_preferences WHERE user_id = ?').get(userId);++ let value = row && row.menu_feeds;++ if (typeof value === 'string') {++ try { value = JSON.parse(value); } catch { value = null; }++ }++ // A row written by an older release (or by hand) is shown only when the contract accepts it; anything else++ // reads as "never set" rather than as a choice the menu cannot draw.++ if (!isObject(value) || checked(value)) {++ if (row && row.menu_feeds != null) log.warn(`[MenuFeeds] user ${userId} has a choice the contract refuses; showing none`);++ return { on: null };++ }++ return { on: value.on, updated_at: value.updated_at };++ }++++ /** 60 writes a minute, per person. */++ function mayWrite(userId, at) {++ const kept = (writes.get(userId) || []).filter((t) => at - t < writeWindowMs);++ if (kept.length >= writeLimit) { writes.set(userId, kept); return false; }++ kept.push(at);++ writes.set(userId, kept);++ if (writes.size > 5000) writes.delete(writes.keys().next().value);++ return true;++ }++++ /** Replace the person's choice and store it. → network.menu-feeds@1 */++ async function write(userId, body, { ctx = null } = {}) {++ const at = now();++ if (!mayWrite(userId, at)) {++ throw Object.assign(new Error('too many writes; wait a minute'), { status: 429, code: 'menu-feeds.rate_limited' });++ }++ if (!isObject(body)) {++ throw Object.assign(new Error('the body must be { on: [\'<site>:<cardId>\', …] }'), { status: 400, code: 'menu-feeds.invalid' });++ }++ const unknown = Object.keys(body).find((k) => !BODY_FIELDS.has(k));++ if (unknown) {++ throw Object.assign(new Error(`${unknown}: is not part of a menu-feeds choice`), { status: 400, code: 'menu-feeds.invalid' });++ }++ const value = { on: body.on, updated_at: new Date(at).toISOString() };++ const errors = checked(value);++ if (errors) {++ throw Object.assign(new Error(firstFault(errors)), { status: 400, code: 'menu-feeds.invalid', errors });++ }++ await db.prepare(`INSERT INTO user_preferences (user_id, menu_feeds, updated_at) VALUES (?, ?, ov_now())++ ON CONFLICT (user_id) DO UPDATE SET menu_feeds = excluded.menu_feeds, updated_at = ov_now()`)++ .run(userId, JSON.stringify(value));++ return value;++ }++++ return { read, write, _writes: writes };++}++++/** The routes, behind Network's session check (the cookie on openvibe.network, a Bearer token elsewhere). */++function router({ menuFeeds, requireAuth }) {++ const r = express.Router();++ r.use(express.json({ limit: '2kb' }));++ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });++ const userIdOf = (req) => req.user.id ?? req.user.sub;++ r.get('/', requireAuth, async (req, res) => {++ try {++ res.json(await menuFeeds.read(userIdOf(req)));++ } catch (err) {++ console.error('[MenuFeeds] read:', err);++ res.status(500).json({ error: 'menu-feeds.error', detail: 'could not read your menu feeds' });++ }++ });++ r.put('/', requireAuth, async (req, res) => {++ try {++ res.json(await menuFeeds.write(userIdOf(req), req.body, { ctx: req.ov }));++ } catch (err) {++ if (err && err.status) return res.status(err.status).json({ error: err.code || 'menu-feeds.error', detail: err.message, ...(err.errors ? { errors: err.errors } : {}) });++ console.error('[MenuFeeds] write:', err);++ res.status(500).json({ error: 'menu-feeds.error', detail: 'could not save your menu feeds' });++ }++ });++ return r;++}++++module.exports = { createMenuFeeds, router, CONTRACT, WRITE_LIMIT, WRITE_WINDOW_MS };@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++// The menu's "Choose your feeds" (server/menu-feeds, 2026-11-07): GET and PUT /api/v1/me/menu-feeds answer++// network.menu-feeds@1. The choice is the sites' menu cards a person picked, in order, and it follows them across++// every OpenVibe site because localStorage is per site.++//++// What this proves:++// - a person who never chose gets { on: null }; a PUT stores the choice and a GET reads it back, order included;++// - the body is checked against the contract: five cards, a bad id, a missing on, a non-array, an unknown field++// and an over-long id are 400s with a clear detail, and nothing is stored;++// - a session is needed, and one person never reads or writes another's choice;++// - 60 writes a minute per person;++// - CORS: https://openvibe.live may ask (preflight included), another origin is refused and its write never runs;++// - the choice goes into the person's export and is erased with them;++// - the migration is expand-only.++// node --import ./test/helpers/pg-preload.mjs test/menu-feeds.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const http = require('http');++const path = require('path');++const express = require('express');++const { ids, validate } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const menuFeedsMod = require('../server/menu-feeds');++const accountData = require('../server/identity/account-data');++const publicCors = require('../server/public-cors');++const authRoutes = require('../server/auth/routes');++++(async () => {++const db = getDb();++const log = console.log; console.log = () => {};++++const mk = async (name) => {++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?) RETURNING id').run(name, `${name}@example.test`, 'x')).lastInsertRowid;++ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);++};++const ana = await mk('feedsana');++const bob = await mk('feedsbob');++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);++const tokenOf = (u) => authRoutes.signToken(u, keys.privateKey, config);++++const menuFeeds = menuFeedsMod.createMenuFeeds({ db });++const app = express();++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++// The CORS the whole app runs behind (server/index.js): the first-party allow-list, and a foreign origin refused.++const allowed = new Set(['https://openvibe.live', 'https://openvibe.network']);++const corsGuard = publicCors.originGuard((origin) => allowed.has(origin), { log: { warn() {} } });++app.use(publicCors.gate(publicCors.restrictedCors(corsGuard.origin)));++app.use(corsGuard.denied);++app.use('/api/v1/me/menu-feeds', menuFeedsMod.router({ menuFeeds, requireAuth }));++const server = http.createServer(app);++await new Promise((r) => server.listen(0, '127.0.0.1', r));++const api = `http://127.0.0.1:${server.address().port}/api/v1/me/menu-feeds`;++const call = (method, body, { as = ana, origin = null } = {}) => fetch(api, {++ method,++ headers: { authorization: `Bearer ${tokenOf(as)}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(origin ? { origin } : {}) },++ ...(body === undefined ? {} : { body: JSON.stringify(body) }),++}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})), headers: r.headers }));++++try {++ // Never chosen: { on: null } and nothing else, private and not cached.++ let r = await call('GET');++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.deepStrictEqual(r.body, { on: null }, 'never chosen reads as no choice');++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++++ // A choice is stored whole, order included, and the answer is the contract.++ const two = ['today:today', 'games:new-worlds'];++ r = await call('PUT', { on: two });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.ok(validate(menuFeedsMod.CONTRACT, r.body).valid, JSON.stringify(validate(menuFeedsMod.CONTRACT, r.body).errors));++ assert.deepStrictEqual(r.body.on, two);++ assert.ok(r.body.updated_at, 'the answer carries when it changed');++ r = await call('GET');++ assert.deepStrictEqual(r.body.on, two, 'the choice is stored');++++ // Replacing and clearing: the order is the person's, and an empty array is a cleared choice.++ r = await call('PUT', { on: ['news:top', 'today:today'] });++ assert.deepStrictEqual(r.body.on, ['news:top', 'today:today'], 'the new order is the stored one');++ r = await call('PUT', { on: [] });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.body.on, [], 'an empty array is a choice, not "never set"');++ assert.deepStrictEqual((await call('GET')).body.on, [], 'and it reads back');++++ // The contract refuses a body, with the field and the fault in detail; nothing is stored.++ const before = (await call('GET')).body;++ for (const [body, why] of [++ [{ on: Array.from({ length: 5 }, (_, i) => `site${i}:card${i}`) }, 'five cards'],++ [{ on: ['Today:Top'] }, 'an upper-case id'],++ [{ on: ['today'] }, 'an id without a card'],++ [{ on: 'today:today' }, 'on is not an array'],++ [{}, 'no on'],++ [{ on: ['today:today'], extra: true }, 'a field the contract does not know'],++ [{ on: [`${'a'.repeat(31)}:card`] }, 'a site over 30 characters'],++ ]) {++ r = await call('PUT', body);++ assert.strictEqual(r.status, 400, `${why}: ${JSON.stringify(r.body)}`);++ assert.strictEqual(r.body.error, 'menu-feeds.invalid', why);++ assert.ok(typeof r.body.detail === 'string' && r.body.detail.length, `${why}: the detail says what is wrong`);++ assert.deepStrictEqual((await call('GET')).body, before, `${why}: nothing was stored`);++ }++++ // A session is needed; another person's token reads and writes only their own choice.++ assert.strictEqual((await fetch(api, { method: 'GET' })).status, 401, 'a session is needed');++ assert.strictEqual((await fetch(api, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: '{"on":[]}' })).status, 401, 'a session is needed to write');++ r = await call('GET', undefined, { as: bob });++ assert.deepStrictEqual(r.body, { on: null }, "bob has not chosen");++ r = await call('PUT', { on: ['live:live-now'] }, { as: bob });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.body.on, ['live:live-now'], "bob's own choice");++ assert.deepStrictEqual((await call('GET')).body.on, [], "bob's write left ana's choice alone");++++ // CORS: https://openvibe.live may read, and its preflight is answered; another origin is refused, and its++ // write never runs the route.++ let cors = await fetch(api, { headers: { origin: 'https://openvibe.live', authorization: `Bearer ${tokenOf(ana)}` } });++ assert.strictEqual(cors.status, 200, 'openvibe.live may read the choice');++ assert.strictEqual(cors.headers.get('access-control-allow-origin'), 'https://openvibe.live', 'the answer names the origin');++ cors = await fetch(api, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'PUT', 'access-control-request-headers': 'authorization,content-type' } });++ assert.ok(cors.status === 204 || cors.status === 200, `the PUT preflight is answered: ${cors.status}`);++ assert.strictEqual(cors.headers.get('access-control-allow-origin'), 'https://openvibe.live', 'the preflight names the origin');++ cors = await fetch(api, { headers: { origin: 'https://evil.example', authorization: `Bearer ${tokenOf(ana)}` } });++ assert.strictEqual(cors.status, 403, 'another origin is refused');++ assert.strictEqual((await cors.json()).code, 'cors.origin_denied');++ cors = await fetch(api, { method: 'PUT', headers: { origin: 'https://evil.example', authorization: `Bearer ${tokenOf(ana)}`, 'content-type': 'application/json' }, body: JSON.stringify({ on: ['news:top'] }) });++ assert.strictEqual(cors.status, 403, 'a cross-origin write never runs the route');++ assert.deepStrictEqual((await call('GET')).body.on, [], 'the refused write changed nothing');++++ // 60 writes a minute per person (a small window here so the test is quick).++ const limitedMod = menuFeedsMod.createMenuFeeds({ db, writeLimit: 2 });++ const limitApp = express();++ Object.assign(limitApp.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++ limitApp.use('/api/v1/me/menu-feeds', menuFeedsMod.router({ menuFeeds: limitedMod, requireAuth }));++ const limitServer = http.createServer(limitApp);++ await new Promise((r) => limitServer.listen(0, '127.0.0.1', r));++ const limitApi = `http://127.0.0.1:${limitServer.address().port}/api/v1/me/menu-feeds`;++ const limited = (body) => fetch(limitApi, { method: 'PUT', headers: { authorization: `Bearer ${tokenOf(bob)}`, 'content-type': 'application/json' }, body: JSON.stringify(body) });++ assert.strictEqual((await limited({ on: ['today:today'] })).status, 200, 'write 1');++ assert.strictEqual((await limited({ on: ['today:today'] })).status, 200, 'write 2');++ const over = await limited({ on: ['today:today'] });++ assert.strictEqual(over.status, 429, 'the third write in the minute is refused');++ assert.strictEqual((await over.json()).error, 'menu-feeds.rate_limited');++ limitServer.close();++++ // The choice goes into the person's export, and it is erased with the person.++ const part = await accountData.networkPart(db, ana.id);++ const feedsFile = part.files.find((f) => f.name === 'menu-feeds.json');++ assert.ok(feedsFile, "ana's export carries her menu feeds");++ const storedFeeds = JSON.parse((await db.prepare('SELECT menu_feeds FROM user_preferences WHERE user_id = ?').get(ana.id)).menu_feeds);++ assert.deepStrictEqual(feedsFile.content, storedFeeds, 'the exported choice is the stored one');++ const bobPart = await accountData.networkPart(db, bob.id);++ assert.deepStrictEqual(bobPart.files.find((f) => f.name === 'menu-feeds.json').content.on, ['today:today'], "bob's export carries only his own choice");++++ const delId = `del_${ids.ulid()}`;++ await db.prepare(`INSERT INTO account_deletions (id, user_id, subject, status, requested_at, delete_after) VALUES (?, ?, ?, 'scheduled', ?, ?)`)++ .run(delId, ana.id, ana.subject_id, '2026-11-07T12:00:00.000Z', '2026-12-07T12:00:00.000Z');++ const deletion = await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(delId);++ await accountData.erase(db, deletion, { now: Date.parse('2026-12-07T12:00:00Z') });++ assert.strictEqual(await db.prepare('SELECT menu_feeds FROM user_preferences WHERE user_id = ?').get(ana.id), undefined, 'the choice is erased with the person');++++ // The migration is expand-only.++ const migration = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0048_menu_feeds.sql'), 'utf8');++ assert.match(migration, /^-- phase: expand$/m, 'the migration is expand-only');++} finally {++ server.close();++ console.log = log;++}++console.log('menu-feeds: ok');++process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
- "contracts": "openvibe-contracts v0.324.0",
+ "contracts": "openvibe-contracts v0.326.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.133.0",
"shared": "openvibe-shared v4.133.0",
"capabilities": [
"capabilities": [
@@ -51,7 +51,8 @@
@@ -51,7 +51,8 @@
"universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)",
"universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)",
"the Dock's state per person: GET and PUT /api/v1/me/dock (network.dock-state@1, network.dock-result@1)",
"the Dock's state per person: GET and PUT /api/v1/me/dock (network.dock-state@1, network.dock-result@1)",
"the machines hub (/api/v1/machines, plan L34): a person's machines, their roles and what they do right now, with Bot as the registry underneath (Network calls it as the person) and network.machine.online/offline/paired/roles_changed/revoked published through OpenVibe.Events",
"the machines hub (/api/v1/machines, plan L34): a person's machines, their roles and what they do right now, with Bot as the registry underneath (Network calls it as the person) and network.machine.online/offline/paired/roles_changed/revoked published through OpenVibe.Events",
- "Space's builder as a task of the person's one agent (plan 2026-10-22 §Z stage Z1): the grants that let Space start a task of the person's agent on OpenVibe.Actor and let that agent work on the person's own spaces through OpenVibe.Space's builder API"
+ "Space's builder as a task of the person's one agent (plan 2026-10-22 §Z stage Z1): the grants that let Space start a task of the person's agent on OpenVibe.Actor and let that agent work on the person's own spaces through OpenVibe.Space's builder API",
+ "the menu's feeds per person: GET and PUT /api/v1/me/menu-feeds (network.menu-feeds@1) keep the OpenVibe menu's Choose your feeds choice across every site"
],
],
"updated": "2026-11-07"
"updated": "2026-11-07"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- The OpenVibe menu's "Choose your feeds" (2026-11-07): the sites' menu cards a person chose to show, in their
+-- order, follow the account to every OpenVibe site, the way site_prefs does. One JSON value per person,
+-- { on: ['<site>:<cardId>', …], updated_at }, in the row they already have; an older release never reads it.
+ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS menu_feeds text COLLATE "C";
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.326.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2303,9 +2303,9 @@
@@ -2303,9 +2303,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.324.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",
- "integrity": "sha512-fSeabmHcX74SxVKEfGXMgWhEDugYQh5KWB+5Vj86CTvr2q50e/7AfHBDtVVw2WYQ06wiH1wN0hctDYoc2DQi9Q==",
+ "version": "0.326.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.326.0",
+ "integrity": "sha512-syArEGWcPUwApDcazsLXXfiXZ4ThPhVo1+Q9zplYplEKeqAFxc8fV8ADHhsjE4rClS4jw4z0eqB7yKemU7U1Bg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.326.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -177,8 +177,9 @@ async function networkPart(db, userId) {
@@ -177,8 +177,9 @@ async function networkPart(db, userId) {
await byUser('sessions.json', 'user_sessions');
await byUser('sessions.json', 'user_sessions');
await byUser('oauth_grants.json', 'oauth_tokens');
await byUser('oauth_grants.json', 'oauth_tokens');
await byUser('notifications.json', 'notifications');
await byUser('notifications.json', 'notifications');
+ const prefs = (await rowsOf(db, 'user_preferences', 'user_id = ?', [u.id])).rows[0] || null;
add('preferences.json', {
add('preferences.json', {
- display: (await rowsOf(db, 'user_preferences', 'user_id = ?', [u.id])).rows[0] || null,
+ display: prefs,
notifications: (await rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id])).rows,
notifications: (await rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id])).rows,
quiet_hours: (await rowsOf(db, 'notification_quiet_hours', 'user_id = ?', [u.id])).rows[0] || null,
quiet_hours: (await rowsOf(db, 'notification_quiet_hours', 'user_id = ?', [u.id])).rows[0] || null,
effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,
effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,
@@ -186,6 +187,12 @@ async function networkPart(db, userId) {
@@ -186,6 +187,12 @@ async function networkPart(db, userId) {
theme_history: (await rowsOf(db, 'user_theme_history', 'user_id = ?', [u.id])).rows,
theme_history: (await rowsOf(db, 'user_theme_history', 'user_id = ?', [u.id])).rows,
theme_site_overrides: (await rowsOf(db, 'user_theme_site_overrides', 'user_id = ?', [u.id])).rows,
theme_site_overrides: (await rowsOf(db, 'user_theme_site_overrides', 'user_id = ?', [u.id])).rows,
});
});
+ // The menu's "Choose your feeds" (2026-11-07): the choice sits in the preferences row above; its own file makes
+ // it readable without opening preferences.json. Never chosen reads as { on: null }, exactly as the route answers.
+ let feeds = prefs && prefs.menu_feeds;
+ if (typeof feeds === 'string') { try { feeds = JSON.parse(feeds); } catch { feeds = null; } }
+ add('menu-feeds.json', feeds && typeof feeds === 'object' && Array.isArray(feeds.on)
+ ? { on: feeds.on, updated_at: feeds.updated_at || null } : { on: null });
await byUser('history.json', 'user_history');
await byUser('history.json', 'user_history');
// The Dock's state (the Dock plan 2026-10-30, K-D1): the little floating surface every OpenVibe site draws.
// The Dock's state (the Dock plan 2026-10-30, K-D1): the little floating surface every OpenVibe site draws.
add('dock.json', await tableExists(db, 'dock_states')
add('dock.json', await tableExists(db, 'dock_states')
@@ -792,6 +792,11 @@ require('./developer/confirmations').start(db);
@@ -792,6 +792,11 @@ require('./developer/confirmations').start(db);
// the Action Center's own, so a page view never fans out further than the aggregator's 10 s cache already does.
// the Action Center's own, so a page view never fans out further than the aggregator's 10 s cache already does.
const dock = require('./dock').createDock({ db, actions, notifications: notificationService });
const dock = require('./dock').createDock({ db, actions, notifications: notificationService });
app.use('/api/v1/me/dock', require('./dock').router({ dock, requireAuth: requireDock('dock') }));
app.use('/api/v1/me/dock', require('./dock').router({ dock, requireAuth: requireDock('dock') }));
+ // The menu's "Choose your feeds" (2026-11-07): the sites' menu cards a person chose, in their order, follow them
+ // across every site (localStorage is per site). Stored in their preferences row, one JSON value; 60 writes a
+ // minute per person, like the Dock's. GET answers { on: null } for a person who has never chosen.
+ const menuFeeds = require('./menu-feeds').createMenuFeeds({ db });
+ app.use('/api/v1/me/menu-feeds', require('./menu-feeds').router({ menuFeeds, requireAuth }));
// The Dock's timer, for a first-party service and not for a person (plan L48 R2): Vibe starts the focus timer of
// The Dock's timer, for a first-party service and not for a person (plan L48 R2): Vibe starts the focus timer of
// the person it is talking with, and it counts down in the Dock's pill on every OpenVibe site. The service names
// the person it is talking with, and it counts down in the Dock's pill on every OpenVibe site. The service names
// the person and writes the timer and nothing else (network.dock.timer.write, granted to Chat alone).
// the person and writes the timer and nothing else (network.dock.timer.write, granted to Chat alone).
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * The OpenVibe menu's "Choose your feeds" (2026-11-07): the sites' menu cards a person chose to show, in their
+ * order, so the menu shows the same feeds on every OpenVibe site. localStorage is per site, so the choice follows
+ * the person here, in the Network.
+ *
+ * GET /api/v1/me/menu-feeds network.menu-feeds@1: the stored choice, or { on: null } when never set
+ * PUT /api/v1/me/menu-feeds { on: ['<site>:<cardId>', …] }: replace the choice; answers the stored contract
+ *
+ * The person comes from the token and nowhere else, so one person never reads or writes another's choice. The body
+ * is checked against network.menu-feeds@1 (at most four `<site>:<cardId>` ids) before anything is stored, and a
+ * body the contract refuses is a 400 with the field and the fault in `detail` — never a quietly changed choice.
+ * A write is limited to 60 a minute per person, like the Dock's, so a runaway tab cannot fill the row.
+ *
+ * The column, not a table: the choice belongs in the preferences row the person already has, and an account's
+ * export and erasure already carry that row (migrations/0048_menu_feeds.sql, expand-only).
+ */
+const express = require('express');
+const { validate } = require('openvibe-contracts');
+
+const CONTRACT = 'network.menu-feeds@1';
+const WRITE_LIMIT = 60;
+const WRITE_WINDOW_MS = 60_000;
+// The two fields a PUT body may carry. `updated_at` is the server's to set; a client that echoes it back is
+// accepted, but it never chooses the time.
+const BODY_FIELDS = new Set(['on', 'updated_at']);
+
+const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+
+/** The first fault in a contract result, as `field: message`, for the 400's detail. */
+function firstFault(errors) {
+ const first = (errors || [])[0];
+ if (!first) return 'not a menu-feeds choice';
+ const field = String(first.instancePath || '').replace(/^\//, '').replace(/\//g, '.');
+ return field ? `${field}: ${first.message}` : String(first.message || 'not a menu-feeds choice');
+}
+
+/** A value the contract accepts, or null when it does not. */
+function checked(value) {
+ const r = validate(CONTRACT, value);
+ return r.valid ? null : (r.errors || []);
+}
+
+/**
+ * The menu-feeds choice for one person.
+ * db the Network database
+ * now the clock, injectable for the tests
+ */
+function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT, writeWindowMs = WRITE_WINDOW_MS, log = console } = {}) {
+ if (!db) throw new TypeError('createMenuFeeds: db is required');
+ const writes = new Map();
+
+ /** The stored choice, exactly as the contract has it, or { on: null } when there is none. */
+ async function read(userId) {
+ const row = await db.prepare('SELECT menu_feeds FROM user_preferences WHERE user_id = ?').get(userId);
+ let value = row && row.menu_feeds;
+ if (typeof value === 'string') {
+ try { value = JSON.parse(value); } catch { value = null; }
+ }
+ // A row written by an older release (or by hand) is shown only when the contract accepts it; anything else
+ // reads as "never set" rather than as a choice the menu cannot draw.
+ if (!isObject(value) || checked(value)) {
+ if (row && row.menu_feeds != null) log.warn(`[MenuFeeds] user ${userId} has a choice the contract refuses; showing none`);
+ return { on: null };
+ }
+ return { on: value.on, updated_at: value.updated_at };
+ }
+
+ /** 60 writes a minute, per person. */
+ function mayWrite(userId, at) {
+ const kept = (writes.get(userId) || []).filter((t) => at - t < writeWindowMs);
+ if (kept.length >= writeLimit) { writes.set(userId, kept); return false; }
+ kept.push(at);
+ writes.set(userId, kept);
+ if (writes.size > 5000) writes.delete(writes.keys().next().value);
+ return true;
+ }
+
+ /** Replace the person's choice and store it. → network.menu-feeds@1 */
+ async function write(userId, body, { ctx = null } = {}) {
+ const at = now();
+ if (!mayWrite(userId, at)) {
+ throw Object.assign(new Error('too many writes; wait a minute'), { status: 429, code: 'menu-feeds.rate_limited' });
+ }
+ if (!isObject(body)) {
+ throw Object.assign(new Error('the body must be { on: [\'<site>:<cardId>\', …] }'), { status: 400, code: 'menu-feeds.invalid' });
+ }
+ const unknown = Object.keys(body).find((k) => !BODY_FIELDS.has(k));
+ if (unknown) {
+ throw Object.assign(new Error(`${unknown}: is not part of a menu-feeds choice`), { status: 400, code: 'menu-feeds.invalid' });
+ }
+ const value = { on: body.on, updated_at: new Date(at).toISOString() };
+ const errors = checked(value);
+ if (errors) {
+ throw Object.assign(new Error(firstFault(errors)), { status: 400, code: 'menu-feeds.invalid', errors });
+ }
+ await db.prepare(`INSERT INTO user_preferences (user_id, menu_feeds, updated_at) VALUES (?, ?, ov_now())
+ ON CONFLICT (user_id) DO UPDATE SET menu_feeds = excluded.menu_feeds, updated_at = ov_now()`)
+ .run(userId, JSON.stringify(value));
+ return value;
+ }
+
+ return { read, write, _writes: writes };
+}
+
+/** The routes, behind Network's session check (the cookie on openvibe.network, a Bearer token elsewhere). */
+function router({ menuFeeds, requireAuth }) {
+ const r = express.Router();
+ r.use(express.json({ limit: '2kb' }));
+ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
+ const userIdOf = (req) => req.user.id ?? req.user.sub;
+ r.get('/', requireAuth, async (req, res) => {
+ try {
+ res.json(await menuFeeds.read(userIdOf(req)));
+ } catch (err) {
+ console.error('[MenuFeeds] read:', err);
+ res.status(500).json({ error: 'menu-feeds.error', detail: 'could not read your menu feeds' });
+ }
+ });
+ r.put('/', requireAuth, async (req, res) => {
+ try {
+ res.json(await menuFeeds.write(userIdOf(req), req.body, { ctx: req.ov }));
+ } catch (err) {
+ if (err && err.status) return res.status(err.status).json({ error: err.code || 'menu-feeds.error', detail: err.message, ...(err.errors ? { errors: err.errors } : {}) });
+ console.error('[MenuFeeds] write:', err);
+ res.status(500).json({ error: 'menu-feeds.error', detail: 'could not save your menu feeds' });
+ }
+ });
+ return r;
+}
+
+module.exports = { createMenuFeeds, router, CONTRACT, WRITE_LIMIT, WRITE_WINDOW_MS };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+// The menu's "Choose your feeds" (server/menu-feeds, 2026-11-07): GET and PUT /api/v1/me/menu-feeds answer
+// network.menu-feeds@1. The choice is the sites' menu cards a person picked, in order, and it follows them across
+// every OpenVibe site because localStorage is per site.
+//
+// What this proves:
+// - a person who never chose gets { on: null }; a PUT stores the choice and a GET reads it back, order included;
+// - the body is checked against the contract: five cards, a bad id, a missing on, a non-array, an unknown field
+// and an over-long id are 400s with a clear detail, and nothing is stored;
+// - a session is needed, and one person never reads or writes another's choice;
+// - 60 writes a minute per person;
+// - CORS: https://openvibe.live may ask (preflight included), another origin is refused and its write never runs;
+// - the choice goes into the person's export and is erased with them;
+// - the migration is expand-only.
+// node --import ./test/helpers/pg-preload.mjs test/menu-feeds.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const express = require('express');
+const { ids, validate } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const menuFeedsMod = require('../server/menu-feeds');
+const accountData = require('../server/identity/account-data');
+const publicCors = require('../server/public-cors');
+const authRoutes = require('../server/auth/routes');
+
+(async () => {
+const db = getDb();
+const log = console.log; console.log = () => {};
+
+const mk = async (name) => {
+ const id = (await db.prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?) RETURNING id').run(name, `${name}@example.test`, 'x')).lastInsertRowid;
+ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+};
+const ana = await mk('feedsana');
+const bob = await mk('feedsbob');
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
+const tokenOf = (u) => authRoutes.signToken(u, keys.privateKey, config);
+
+const menuFeeds = menuFeedsMod.createMenuFeeds({ db });
+const app = express();
+Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+// The CORS the whole app runs behind (server/index.js): the first-party allow-list, and a foreign origin refused.
+const allowed = new Set(['https://openvibe.live', 'https://openvibe.network']);
+const corsGuard = publicCors.originGuard((origin) => allowed.has(origin), { log: { warn() {} } });
+app.use(publicCors.gate(publicCors.restrictedCors(corsGuard.origin)));
+app.use(corsGuard.denied);
+app.use('/api/v1/me/menu-feeds', menuFeedsMod.router({ menuFeeds, requireAuth }));
+const server = http.createServer(app);
+await new Promise((r) => server.listen(0, '127.0.0.1', r));
+const api = `http://127.0.0.1:${server.address().port}/api/v1/me/menu-feeds`;
+const call = (method, body, { as = ana, origin = null } = {}) => fetch(api, {
+ method,
+ headers: { authorization: `Bearer ${tokenOf(as)}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(origin ? { origin } : {}) },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})), headers: r.headers }));
+
+try {
+ // Never chosen: { on: null } and nothing else, private and not cached.
+ let r = await call('GET');
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.deepStrictEqual(r.body, { on: null }, 'never chosen reads as no choice');
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+
+ // A choice is stored whole, order included, and the answer is the contract.
+ const two = ['today:today', 'games:new-worlds'];
+ r = await call('PUT', { on: two });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.ok(validate(menuFeedsMod.CONTRACT, r.body).valid, JSON.stringify(validate(menuFeedsMod.CONTRACT, r.body).errors));
+ assert.deepStrictEqual(r.body.on, two);
+ assert.ok(r.body.updated_at, 'the answer carries when it changed');
+ r = await call('GET');
+ assert.deepStrictEqual(r.body.on, two, 'the choice is stored');
+
+ // Replacing and clearing: the order is the person's, and an empty array is a cleared choice.
+ r = await call('PUT', { on: ['news:top', 'today:today'] });
+ assert.deepStrictEqual(r.body.on, ['news:top', 'today:today'], 'the new order is the stored one');
+ r = await call('PUT', { on: [] });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.body.on, [], 'an empty array is a choice, not "never set"');
+ assert.deepStrictEqual((await call('GET')).body.on, [], 'and it reads back');
+
+ // The contract refuses a body, with the field and the fault in detail; nothing is stored.
+ const before = (await call('GET')).body;
+ for (const [body, why] of [
+ [{ on: Array.from({ length: 5 }, (_, i) => `site${i}:card${i}`) }, 'five cards'],
+ [{ on: ['Today:Top'] }, 'an upper-case id'],
+ [{ on: ['today'] }, 'an id without a card'],
+ [{ on: 'today:today' }, 'on is not an array'],
+ [{}, 'no on'],
+ [{ on: ['today:today'], extra: true }, 'a field the contract does not know'],
+ [{ on: [`${'a'.repeat(31)}:card`] }, 'a site over 30 characters'],
+ ]) {
+ r = await call('PUT', body);
+ assert.strictEqual(r.status, 400, `${why}: ${JSON.stringify(r.body)}`);
+ assert.strictEqual(r.body.error, 'menu-feeds.invalid', why);
+ assert.ok(typeof r.body.detail === 'string' && r.body.detail.length, `${why}: the detail says what is wrong`);
+ assert.deepStrictEqual((await call('GET')).body, before, `${why}: nothing was stored`);
+ }
+
+ // A session is needed; another person's token reads and writes only their own choice.
+ assert.strictEqual((await fetch(api, { method: 'GET' })).status, 401, 'a session is needed');
+ assert.strictEqual((await fetch(api, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: '{"on":[]}' })).status, 401, 'a session is needed to write');
+ r = await call('GET', undefined, { as: bob });
+ assert.deepStrictEqual(r.body, { on: null }, "bob has not chosen");
+ r = await call('PUT', { on: ['live:live-now'] }, { as: bob });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.body.on, ['live:live-now'], "bob's own choice");
+ assert.deepStrictEqual((await call('GET')).body.on, [], "bob's write left ana's choice alone");
+
+ // CORS: https://openvibe.live may read, and its preflight is answered; another origin is refused, and its
+ // write never runs the route.
+ let cors = await fetch(api, { headers: { origin: 'https://openvibe.live', authorization: `Bearer ${tokenOf(ana)}` } });
+ assert.strictEqual(cors.status, 200, 'openvibe.live may read the choice');
+ assert.strictEqual(cors.headers.get('access-control-allow-origin'), 'https://openvibe.live', 'the answer names the origin');
+ cors = await fetch(api, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'PUT', 'access-control-request-headers': 'authorization,content-type' } });
+ assert.ok(cors.status === 204 || cors.status === 200, `the PUT preflight is answered: ${cors.status}`);
+ assert.strictEqual(cors.headers.get('access-control-allow-origin'), 'https://openvibe.live', 'the preflight names the origin');
+ cors = await fetch(api, { headers: { origin: 'https://evil.example', authorization: `Bearer ${tokenOf(ana)}` } });
+ assert.strictEqual(cors.status, 403, 'another origin is refused');
+ assert.strictEqual((await cors.json()).code, 'cors.origin_denied');
+ cors = await fetch(api, { method: 'PUT', headers: { origin: 'https://evil.example', authorization: `Bearer ${tokenOf(ana)}`, 'content-type': 'application/json' }, body: JSON.stringify({ on: ['news:top'] }) });
+ assert.strictEqual(cors.status, 403, 'a cross-origin write never runs the route');
+ assert.deepStrictEqual((await call('GET')).body.on, [], 'the refused write changed nothing');
+
+ // 60 writes a minute per person (a small window here so the test is quick).
+ const limitedMod = menuFeedsMod.createMenuFeeds({ db, writeLimit: 2 });
+ const limitApp = express();
+ Object.assign(limitApp.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+ limitApp.use('/api/v1/me/menu-feeds', menuFeedsMod.router({ menuFeeds: limitedMod, requireAuth }));
+ const limitServer = http.createServer(limitApp);
+ await new Promise((r) => limitServer.listen(0, '127.0.0.1', r));
+ const limitApi = `http://127.0.0.1:${limitServer.address().port}/api/v1/me/menu-feeds`;
+ const limited = (body) => fetch(limitApi, { method: 'PUT', headers: { authorization: `Bearer ${tokenOf(bob)}`, 'content-type': 'application/json' }, body: JSON.stringify(body) });
+ assert.strictEqual((await limited({ on: ['today:today'] })).status, 200, 'write 1');
+ assert.strictEqual((await limited({ on: ['today:today'] })).status, 200, 'write 2');
+ const over = await limited({ on: ['today:today'] });
+ assert.strictEqual(over.status, 429, 'the third write in the minute is refused');
+ assert.strictEqual((await over.json()).error, 'menu-feeds.rate_limited');
+ limitServer.close();
+
+ // The choice goes into the person's export, and it is erased with the person.
+ const part = await accountData.networkPart(db, ana.id);
+ const feedsFile = part.files.find((f) => f.name === 'menu-feeds.json');
+ assert.ok(feedsFile, "ana's export carries her menu feeds");
+ const storedFeeds = JSON.parse((await db.prepare('SELECT menu_feeds FROM user_preferences WHERE user_id = ?').get(ana.id)).menu_feeds);
+ assert.deepStrictEqual(feedsFile.content, storedFeeds, 'the exported choice is the stored one');
+ const bobPart = await accountData.networkPart(db, bob.id);
+ assert.deepStrictEqual(bobPart.files.find((f) => f.name === 'menu-feeds.json').content.on, ['today:today'], "bob's export carries only his own choice");
+
+ const delId = `del_${ids.ulid()}`;
+ await db.prepare(`INSERT INTO account_deletions (id, user_id, subject, status, requested_at, delete_after) VALUES (?, ?, ?, 'scheduled', ?, ?)`)
+ .run(delId, ana.id, ana.subject_id, '2026-11-07T12:00:00.000Z', '2026-12-07T12:00:00.000Z');
+ const deletion = await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(delId);
+ await accountData.erase(db, deletion, { now: Date.parse('2026-12-07T12:00:00Z') });
+ assert.strictEqual(await db.prepare('SELECT menu_feeds FROM user_preferences WHERE user_id = ?').get(ana.id), undefined, 'the choice is erased with the person');
+
+ // The migration is expand-only.
+ const migration = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0048_menu_feeds.sql'), 'utf8');
+ assert.match(migration, /^-- phase: expand$/m, 'the migration is expand-only');
+} finally {
+ server.close();
+ console.log = log;
+}
+console.log('menu-feeds: ok');
+process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');