openvibers/openvibe.network Commits

Menu feeds follow the person across sites (#636)

OP OpenVibers committed 6 days ago · 8b57ddace9 · 10 files, +334 −10

* Menu feeds follow the person across sites

The OpenVibe menu's Choose your feeds: GET and PUT /api/v1/me/menu-feeds store up to four <site>:<cardId> choices per person in their preferences row (migration 0048, expand-only), following them across every site. The contract network.menu-feeds@1 (Contract 0.326.0) validates the body; a never-chosen person gets { on: null }. 60 writes a minute; the choice is in the account export and is erased with the person.

* Merge origin/main into menu-feeds

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex e8c4dc83..0dc3e359 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/migrations/0048_menu_feeds.sql b/migrations/0048_menu_feeds.sql
nnew file mode 100644
iindex 00000000..2bdb1d63
---- /dev/null
++++ b/migrations/0048_menu_feeds.sql
@@@ -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";
ddiff --git a/package-lock.json b/package-lock.json
iindex 0d550dce..44a24e5d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 59122e14..5c9f073d 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 02130d03..8ec08dec 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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')
ddiff --git a/server/index.js b/server/index.js
iindex 45f7eb81..2d1e19c7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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).
ddiff --git a/server/menu-feeds/index.js b/server/menu-feeds/index.js
nnew file mode 100644
iindex 00000000..30c38719
---- /dev/null
++++ b/server/menu-feeds/index.js
@@@ -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 };
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex ec953271..5036327d 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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.
ddiff --git a/test/menu-feeds.test.js b/test/menu-feeds.test.js
nnew file mode 100644
iindex 00000000..7ab5f890
---- /dev/null
++++ b/test/menu-feeds.test.js
@@@ -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); });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex defca1bd..b5cae27e 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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 twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');