openvibers/openvibe.network Commits

Add M1 home screen, menu feeds v2, and Today frame (#704)

OP OpenVibers committed 4 days ago · fbeb2acb0e · 18 files, +247 −41

* Add menu home storage, feed ordering and day frame

* Merge origin/main for link egress migration

* Record Today analytics call and home migration

* Pin openvibe-contracts v0.352.0 (the M1 contracts)

* Merge main; the home screen migration is 0061

Main applied 0060 (companion_actor_scopes, #703) on production first, so this branch's 0059 would have run out of order:
it is 0061_home_screen now, and the migration lists in the schema tests read 0058, 0060, 0061.

Changes

ddiff --git a/docs/menu-home-feed-m1.md b/docs/menu-home-feed-m1.md
nnew file mode 100644
iindex 00000000..9265a398
---- /dev/null
++++ b/docs/menu-home-feed-m1.md
@@@ -0,0 +1,5 @@
++# Menu Home and Feed backend
++
++- `GET` and `PUT /api/v1/me/home-screen` use a user Bearer token. The Network stores the validated `network.home-screen@1` value in `user_preferences.home_screen`; the account export includes `home-screen.json`, and account deletion removes the preferences row.
++- `GET /api/v1/me/menu-feeds` answers `network.menu-feeds@2` `{ order, off, updated_at }`. Every offered card is on unless its id is in `off`. `PUT` accepts v2 or v1 `{ on }`; old stored `on` lists become the start of `order` on read.
++- `GET /api/v1/frame/today` is public, CORS open, and cached for five minutes. It reads Report's `/api/v1/stats?days=30` and selects the latest fourteen UTC dates. `people` is Report's daily **new accounts** count. Report has no daily stream, post or new-video series yet, so those fields are `null`; a future `activity_daily` series can fill them without changing this API.
ddiff --git a/migrations/0061_home_screen.sql b/migrations/0061_home_screen.sql
nnew file mode 100644
iindex 00000000..49c1842b
---- /dev/null
++++ b/migrations/0061_home_screen.sql
@@@ -0,0 +1,3 @@
++-- phase: expand
++-- A person's ordered menu Home icons and folders; erasure follows user_preferences.
++ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS home_screen text COLLATE "C";
ddiff --git a/package-lock.json b/package-lock.json
iindex aafc90d2..ea3026a4 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.350.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.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.157.0",
"pg": "^8.23.0",
@@@ -2303,9 +2303,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.350.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",
-- "integrity": "sha512-2bGRv8VoFx3kv0jVidLU3KZwTMYJV0HoDTZ+GQtkhsaPS9kpuAKVVh6bioDsHXg9LWm1D2g6JakbrfIsgcYtQQ==",
++ "version": "0.352.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.0",
++ "integrity": "sha512-TN02BrMU4jzXYZ0rjp5dmLbQI06h5tT6miISS6zS7E7/UFI+fGFidTVtQpZXEYG8gppGEo2UEtZ//hk9kkASyQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex c8b4e8e7..721d0a66 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.350.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.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.157.0",
"pg": "^8.23.0",
ddiff --git a/server/developer/tokens.js b/server/developer/tokens.js
iindex d5695901..02c5d06c 100644
---- a/server/developer/tokens.js
++++ b/server/developer/tokens.js
@@@ -115,7 +115,7 @@ async function mint({ app, project, audience, scope, limit, onBehalfOf, privateK
const now = Math.floor(Date.now() / 1000);
const claims = {
iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: projectNamespaces(project.id),
-- project_id: project.id, env: app.environment,
++ project_id: project.id, env: app.environment, app_name: String(app.name || app.id).slice(0, 80),
...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),
...(consent ? { consent } : {}),
iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
ddiff --git a/server/frame/today.js b/server/frame/today.js
nnew file mode 100644
iindex 00000000..cdab529c
---- /dev/null
++++ b/server/frame/today.js
@@@ -0,0 +1,52 @@
++'use strict';
++/** Public menu day counts from Report's /api/v1/stats. Report currently has daily new accounts;
++ * unsupported daily stream, post and video counts stay null until Report publishes them. */
++const express = require('express');
++
++const DAY = 86_400_000;
++const TTL = 5 * 60_000;
++const count = (v) => Number.isSafeInteger(Number(v)) && Number(v) >= 0 && v != null ? Number(v) : null;
++
++function createToday({ reportUrl, fetchImpl = globalThis.fetch, now = Date.now } = {}) {
++ let cached = null, at = 0, inflight = null;
++ async function read() {
++ const base = String(reportUrl || 'http://127.0.0.1:5080').replace(/\/$/, '');
++ const get = async (days) => {
++ const r = await fetchImpl(`${base}/api/v1/stats?days=${days}`, { signal: AbortSignal.timeout(5000) });
++ return r.ok ? await r.json() : null;
++ };
++ // Report normalises all windows except 7, 30 and 90 days to 30; use its 30-day series and select 14 dates.
++ const fourteen = await get(30).catch(() => null);
++ const today = new Date(now()).toISOString().slice(0, 10);
++ const people = new Map((fourteen && fourteen.accounts && fourteen.accounts.daily || []).map((x) => [x.day, count(x.n)]));
++ const activity = new Map((fourteen && fourteen.activity_daily || []).map((x) => [x.day, x]));
++ const series = Array.from({ length: 14 }, (_, i) => {
++ const day = new Date(Date.parse(`${today}T00:00:00Z`) - (13 - i) * DAY).toISOString().slice(0, 10);
++ const a = activity.get(day) || {};
++ return { day, people: people.get(day) ?? null, streams: count(a.streams), posts: count(a.posts), new_videos: count(a.new_videos) };
++ });
++ const a = activity.get(today) || {};
++ return { day: today, people_today: series[13].people,
++ streams_today: count(a.streams),
++ posts_today: count(a.posts), new_videos_today: count(a.new_videos), series, updated_at: new Date(now()).toISOString() };
++ }
++ async function get() {
++ if (cached && now() - at < TTL) return cached;
++ inflight = inflight || read().then((value) => { cached = value; at = now(); return value; }).finally(() => { inflight = null; });
++ return await inflight;
++ }
++ const router = express.Router();
++ router.get('/', async (_req, res) => {
++ try {
++ const value = await get();
++ res.set({ 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*',
++ 'Cross-Origin-Resource-Policy': 'cross-origin' }).json(value);
++ } catch (err) {
++ console.error('[FrameToday] read:', err);
++ res.status(503).json({ error: 'frame.today_unavailable' });
++ }
++ });
++ return { router, get };
++}
++
++module.exports = { createToday };
ddiff --git a/server/home-screen/index.js b/server/home-screen/index.js
nnew file mode 100644
iindex 00000000..05d836e0
---- /dev/null
++++ b/server/home-screen/index.js
@@@ -0,0 +1,58 @@
++'use strict';
++const express = require('express');
++const { validate } = require('openvibe-contracts');
++
++const CONTRACT = 'network.home-screen@1';
++const empty = () => ({ home: [], folders: [], updated_at: null });
++const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
++
++function createHomeScreen({ db, now = () => Date.now() }) {
++ async function read(userId) {
++ const row = await db.prepare('SELECT home_screen FROM user_preferences WHERE user_id = ?').get(userId);
++ if (!row || !row.home_screen) return empty();
++ let value;
++ try { value = typeof row.home_screen === 'string' ? JSON.parse(row.home_screen) : row.home_screen; } catch { return empty(); }
++ return validate(CONTRACT, value).valid ? value : empty();
++ }
++
++ async function write(userId, body) {
++ if (!object(body) || Object.keys(body).some((key) => !['home', 'folders', 'updated_at'].includes(key))) {
++ throw Object.assign(new Error('home and folders are required; no other fields are accepted'), { status: 400 });
++ }
++ const value = { home: body.home, folders: body.folders, updated_at: new Date(now()).toISOString() };
++ const result = validate(CONTRACT, value);
++ if (!result.valid) throw Object.assign(new Error(JSON.stringify(result.errors)), { status: 400 });
++ const ids = new Set(value.folders.map((folder) => folder.id));
++ if (ids.size !== value.folders.length || value.home.some((id) => id.startsWith('folder:') && !ids.has(id.slice(7)))) {
++ throw Object.assign(new Error('folder ids must be unique and every Home folder must exist'), { status: 400 });
++ }
++ await db.prepare(`INSERT INTO user_preferences (user_id, home_screen, updated_at) VALUES (?, ?, ov_now())
++ ON CONFLICT (user_id) DO UPDATE SET home_screen = excluded.home_screen, updated_at = ov_now()`)
++ .run(userId, JSON.stringify(value));
++ return value;
++ }
++ return { read, write };
++}
++
++function router({ homeScreen, requireAuth }) {
++ const r = express.Router();
++ r.use((req, res, next) => {
++ res.set('Cache-Control', 'private, no-store');
++ if (!/^Bearer \S+$/.test(String(req.headers.authorization || ''))) return res.status(401).json({ error: 'home-screen.bearer_required' });
++ next();
++ });
++ r.get('/', requireAuth, async (req, res) => {
++ try { res.json(await homeScreen.read(req.user.id)); }
++ catch (err) { console.error('[HomeScreen] read:', err); res.status(500).json({ error: 'home-screen.error' }); }
++ });
++ r.put('/', requireAuth, express.json({ limit: '8kb' }), async (req, res) => {
++ try { res.json(await homeScreen.write(req.user.id, req.body)); }
++ catch (err) {
++ if (err.status === 400) return res.status(400).json({ error: 'home-screen.invalid', detail: err.message });
++ console.error('[HomeScreen] write:', err); res.status(500).json({ error: 'home-screen.error' });
++ }
++ });
++ return r;
++}
++
++module.exports = { createHomeScreen, router, CONTRACT };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 8ec08dec..0d5dc1a7 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -191,8 +191,13 @@ async function networkPart(db, userId) {
// 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 });
++ add('menu-feeds.json', feeds && typeof feeds === 'object' && Array.isArray(feeds.order) && Array.isArray(feeds.off)
++ ? feeds : feeds && Array.isArray(feeds.on) ? { order: feeds.on, off: [], updated_at: feeds.updated_at || null }
++ : { order: [], off: [], updated_at: null });
++ let home = prefs && prefs.home_screen;
++ if (typeof home === 'string') { try { home = JSON.parse(home); } catch { home = null; } }
++ add('home-screen.json', home && typeof home === 'object' && Array.isArray(home.home) && Array.isArray(home.folders)
++ ? home : { home: [], folders: [], updated_at: 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 28b732e8..15f62edc 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -300,7 +300,7 @@ function isAllowedOrigin(origin) {
// Public discovery (/.well-known/openvibe, /api/v1/registry/*, /contracts/*.json) answers any
// origin, preflight included; every other route keeps this allow-list (server/public-cors.js).
const corsGuard = require('./public-cors').originGuard(isAllowedOrigin);
--app.use(require('./public-cors').gate(require('./public-cors').restrictedCors(corsGuard.origin)));
++app.use(require('./public-cors').gate(require('./public-cors').restrictedCors(corsGuard.origin), require('./public-cors').bearerCors(corsGuard.origin)));
app.use(corsGuard.denied);
// ── Rate Limiting ────────────────────────────────────────────
@@@ -851,9 +851,11 @@ require('./developer/confirmations').start(db);
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.
++ // minute per person, like the Dock's. GET answers the v2 default-on shape for a person who never chose.
const menuFeeds = require('./menu-feeds').createMenuFeeds({ db });
app.use('/api/v1/me/menu-feeds', require('./menu-feeds').router({ menuFeeds, requireAuth }));
++ const homeScreen = require('./home-screen').createHomeScreen({ db });
++ app.use('/api/v1/me/home-screen', require('./home-screen').router({ homeScreen, 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).
@@@ -946,6 +948,8 @@ app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles
// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).
// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.
const frameService = await require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken });
++app.use('/api/v1/frame/today', rateLimit({ windowMs: 60_000, max: 240 }),
++ require('./frame/today').createToday({ reportUrl: config.services.report.internalUrl }).router);
const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
ddiff --git a/server/menu-feeds/index.js b/server/menu-feeds/index.js
iindex 30c38719..aea4ad4b 100644
---- a/server/menu-feeds/index.js
++++ b/server/menu-feeds/index.js
@@@ -4,11 +4,11 @@
* 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
++ * GET /api/v1/me/menu-feeds network.menu-feeds@2: { order, off, updated_at }
++ * PUT /api/v1/me/menu-feeds v2 { order, off } or legacy v1 { on }; answers v2
*
* 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
++ * is checked against the matching contract 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.
*
@@@ -18,12 +18,13 @@
const express = require('express');
const { validate } = require('openvibe-contracts');
--const CONTRACT = 'network.menu-feeds@1';
++const CONTRACT = 'network.menu-feeds@2';
++const LEGACY = '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 BODY_FIELDS = new Set(['on', 'order', 'off', 'updated_at']);
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
@@@ -31,7 +32,7 @@ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
function firstFault(errors) {
const first = (errors || [])[0];
if (!first) return 'not a menu-feeds choice';
-- const field = String(first.instancePath || '').replace(/^\//, '').replace(/\//g, '.');
++ const field = String(first.path || first.instancePath || '').replace(/^\//, '').replace(/\//g, '.');
return field ? `${field}: ${first.message}` : String(first.message || 'not a menu-feeds choice');
}
@@@ -50,7 +51,7 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
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. */
++ /** The stored v2 choice, or an empty default-on choice 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;
@@@ -59,11 +60,12 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
}
// 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) && Array.isArray(value.on)) value = { order: value.on, off: [], updated_at: value.updated_at || null };
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 { order: [], off: [], updated_at: null };
}
-- return { on: value.on, updated_at: value.updated_at };
++ return { order: value.order, off: value.off, updated_at: value.updated_at };
}
/** 60 writes a minute, per person. */
@@@ -76,7 +78,7 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
return true;
}
-- /** Replace the person's choice and store it. → network.menu-feeds@1 */
++ /** Replace the person's choice and store it. → network.menu-feeds@2 */
async function write(userId, body, { ctx = null } = {}) {
const at = now();
if (!mayWrite(userId, at)) {
@@@ -89,7 +91,15 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
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 legacy = Object.hasOwn(body, 'on');
++ if (legacy && (Object.hasOwn(body, 'order') || Object.hasOwn(body, 'off'))) {
++ throw Object.assign(new Error('on cannot be combined with order or off'), { status: 400, code: 'menu-feeds.invalid' });
++ }
++ const value = legacy ? { order: body.on, off: [], updated_at: new Date(at).toISOString() }
++ : { order: body.order, off: body.off, updated_at: new Date(at).toISOString() };
++ if (legacy && !validate(LEGACY, { on: body.on, updated_at: value.updated_at }).valid) {
++ throw Object.assign(new Error('on: invalid v1 choice'), { status: 400, code: 'menu-feeds.invalid' });
++ }
const errors = checked(value);
if (errors) {
throw Object.assign(new Error(firstFault(errors)), { status: 400, code: 'menu-feeds.invalid', errors });
@@@ -103,7 +113,7 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
return { read, write, _writes: writes };
}
--/** The routes, behind Network's session check (the cookie on openvibe.network, a Bearer token elsewhere). */
++/** The routes, behind Network's user-session check. */
function router({ menuFeeds, requireAuth }) {
const r = express.Router();
r.use(express.json({ limit: '2kb' }));
ddiff --git a/server/public-cors.js b/server/public-cors.js
iindex 65b135e0..c1a270cf 100644
---- a/server/public-cors.js
++++ b/server/public-cors.js
@@@ -29,6 +29,7 @@ function isPublicDiscoveryPath(p) {
const path = String(p || '');
if (path === '/.well-known/openvibe') return true;
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
++ if (path === '/api/v1/frame/today' || path === '/api/v1/frame/today/') return true;
if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every site
if (/^\/api\/v1\/people\/@[A-Za-z0-9_]{1,40}\/page$/.test(path)) return true; // public person page JSON
if (CONTRACT_RE.test(path)) return true;
@@@ -72,10 +73,11 @@ function extensionCors(req, res, next) {
}
/** One CORS middleware for the app: extension origins get no grant, discovery is open, the rest restricted. */
--function gate(restricted) {
++function gate(restricted, bearer = restricted) {
return function corsGate(req, res, next) {
if (isExtensionOrigin(req.headers.origin)) return extensionCors(req, res, next);
if (isPublicDiscoveryPath(req.path)) return publicCors(req, res, next);
++ if (req.path === '/api/v1/me/home-screen' || req.path === '/api/v1/me/home-screen/') return bearer(req, res, next);
return restricted(req, res, next);
};
}
@@@ -116,5 +118,8 @@ const RESTRICTED_MAX_AGE_S = 600;
function restrictedCors(origin) {
return require('cors')({ origin, credentials: true, maxAge: RESTRICTED_MAX_AGE_S });
}
++function bearerCors(origin) {
++ return require('cors')({ origin, credentials: false, maxAge: RESTRICTED_MAX_AGE_S });
++}
--module.exports = { gate, publicCors, extensionCors, restrictedCors, isPublicDiscoveryPath, originGuard, ALLOW_HEADERS, EXPOSE_HEADERS, RESTRICTED_MAX_AGE_S };
++module.exports = { gate, publicCors, extensionCors, restrictedCors, bearerCors, isPublicDiscoveryPath, originGuard, ALLOW_HEADERS, EXPOSE_HEADERS, RESTRICTED_MAX_AGE_S };
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 5469ea85..4a33ad80 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'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', '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'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', '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/agent-tokens.test.js b/test/agent-tokens.test.js
iindex d4a4233b..d874f60d 100644
---- a/test/agent-tokens.test.js
++++ b/test/agent-tokens.test.js
@@@ -110,7 +110,7 @@ const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: '
assert.ok(verified.ok, verified.reason);
assert.strictEqual(jwt.decode(res.body.access_token, { complete: true }).header.alg, 'RS256');
// The host's own app token is what it was: no agent claims.
-- assert.deepStrictEqual(Object.keys(ownApp).sort(), ['actor_type', 'aud', 'cap', 'env', 'exp', 'iat', 'iss', 'jti', 'ns', 'project_id', 'sub']);
++ assert.deepStrictEqual(Object.keys(ownApp).sort(), ['actor_type', 'app_name', 'aud', 'cap', 'env', 'exp', 'iat', 'iss', 'jti', 'ns', 'project_id', 'sub']);
assert.deepStrictEqual([ownApp.sub, ownApp.actor_type, ownApp.cap, ownApp.ns], [`app:${PR}`, 'app', ['media.object.delete', 'media.object.read', 'media.object.upload'], [P, `app.${P}.*`]]);
// A scope narrows; a confirm-mode capability alone gives cap [] and cap_confirm, never cap.
assert.deepStrictEqual(await capsOf(PR, A, { scope: 'media.object.delete' }), [[], ['media.object.delete']]);
ddiff --git a/test/developer-projects.test.js b/test/developer-projects.test.js
iindex b235ae54..150f7a6f 100644
---- a/test/developer-projects.test.js
++++ b/test/developer-projects.test.js
@@@ -176,6 +176,7 @@ const secretsSeen = [];
assert.ok(v.ok, v.reason);
assert.strictEqual(v.claims.sub, `app:${A}`);
assert.strictEqual(v.claims.actor_type, 'app');
++ assert.strictEqual(v.claims.app_name, 'Uploader', 'app attribution comes from Network, not a caller-provided label');
assert.deepStrictEqual(v.claims.cap, ['media.object.upload'], 'only the approved grant');
assert.strictEqual(v.claims.project_id, P);
assert.strictEqual(v.claims.env, 'sandbox');
ddiff --git a/test/home-today.test.js b/test/home-today.test.js
nnew file mode 100644
iindex 00000000..91e72769
---- /dev/null
++++ b/test/home-today.test.js
@@@ -0,0 +1,39 @@
++'use strict';
++const assert = require('assert');
++const { createHomeScreen } = require('../server/home-screen');
++const { createToday } = require('../server/frame/today');
++
++(async () => {
++ const rows = new Map();
++ const db = { prepare(sql) { return {
++ get: async (id) => ({ home_screen: rows.get(id) || null }),
++ run: async (id, json) => { rows.set(id, json); },
++ }; } };
++ const home = createHomeScreen({ db, now: () => Date.parse('2026-11-09T12:00:00Z') });
++ assert.deepStrictEqual(await home.read(1), { home: [], folders: [], updated_at: null });
++ const layout = { home: ['live', 'folder:tools'], folders: [{ id: 'tools', name: 'Tools', apps: ['tools:qr'] }] };
++ assert.deepStrictEqual((await home.write(1, layout)).home, layout.home);
++ assert.deepStrictEqual((await home.read(1)).folders, layout.folders);
++ assert.deepStrictEqual(await home.read(2), { home: [], folders: [], updated_at: null });
++ for (const invalid of [
++ { home: ['folder:missing'], folders: [] },
++ { home: Array(41).fill('live'), folders: [] },
++ { home: ['live'], folders: [{ id: 'tools', name: 'Tools', apps: [] }, { id: 'tools', name: 'Tools', apps: [] }] },
++ ]) await assert.rejects(home.write(1, invalid), (err) => err.status === 400);
++ assert.deepStrictEqual((await home.read(1)).home, layout.home);
++
++ let requests = 0;
++ const today = createToday({ reportUrl: 'https://report.test', now: () => Date.parse('2026-11-09T12:00:00Z'), fetchImpl: async (url) => {
++ requests++;
++ return { ok: true, json: async () => ({ accounts: { daily: [{ day: '2026-11-09', n: 3 }] } }) };
++ } });
++ const result = await today.get();
++ assert.strictEqual(result.series.length, 14);
++ assert.strictEqual(result.people_today, 3);
++ assert.strictEqual(result.streams_today, null);
++ assert.strictEqual(result.posts_today, null);
++ assert.strictEqual(result.new_videos_today, null);
++ await today.get();
++ assert.strictEqual(requests, 1, 'cached for five minutes');
++ console.log('home and today: passed');
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/menu-feeds.test.js b/test/menu-feeds.test.js
iindex 7ab5f890..4e55392b 100644
---- a/test/menu-feeds.test.js
++++ b/test/menu-feeds.test.js
@@@ -4,7 +4,7 @@
// 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;
++// - a person who never chose gets { order: [], off: [], updated_at: 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;
@@@ -23,6 +23,7 @@ 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 homeScreenMod = require('../server/home-screen');
const accountData = require('../server/identity/account-data');
const publicCors = require('../server/public-cors');
const authRoutes = require('../server/auth/routes');
@@@ -50,12 +51,14 @@ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, 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(publicCors.gate(publicCors.restrictedCors(corsGuard.origin), publicCors.bearerCors(corsGuard.origin)));
app.use(corsGuard.denied);
app.use('/api/v1/me/menu-feeds', menuFeedsMod.router({ menuFeeds, requireAuth }));
++app.use('/api/v1/me/home-screen', homeScreenMod.router({ homeScreen: homeScreenMod.createHomeScreen({ db }), 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 homeApi = `http://127.0.0.1:${server.address().port}/api/v1/me/home-screen`;
const call = (method, body, { as = ana, origin = null } = {}) => fetch(api, {
method,
headers: { authorization: `Bearer ${tokenOf(as)}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(origin ? { origin } : {}) },
@@@ -63,10 +66,10 @@ const call = (method, body, { as = ana, origin = null } = {}) => fetch(api, {
}).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.
++ // Never chosen: { order: [], off: [], updated_at: 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.deepStrictEqual(r.body, { order: [], off: [], updated_at: 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.
@@@ -74,18 +77,24 @@ try {
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.deepStrictEqual(r.body.order, two);
++ assert.deepStrictEqual(r.body.off, [], 'a v1 on list becomes the start of the v2 order');
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');
++ assert.deepStrictEqual(r.body.order, 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');
++ assert.deepStrictEqual(r.body.order, ['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');
++ assert.deepStrictEqual(r.body.order, [], 'an empty array is a choice, not "never set"');
++ assert.deepStrictEqual((await call('GET')).body.order, [], 'and it reads back');
++ r = await call('PUT', { order: ['today:today', 'news:top'], off: ['news:top'] });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.body.off, ['news:top'], 'v2 can turn a card off without dropping its order');
++ r = await call('PUT', { order: [], off: [] });
++ assert.strictEqual(r.status, 200);
// The contract refuses a body, with the field and the fault in detail; nothing is stored.
const before = (await call('GET')).body;
@@@ -109,11 +118,11 @@ try {
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");
++ assert.deepStrictEqual(r.body, { order: [], off: [], updated_at: 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");
++ assert.deepStrictEqual(r.body.order, ['live:live-now'], "bob's own choice");
++ assert.deepStrictEqual((await call('GET')).body.order, [], "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.
@@@ -128,7 +137,7 @@ try {
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');
++ assert.deepStrictEqual((await call('GET')).body.order, [], '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 });
@@@ -146,14 +155,28 @@ try {
assert.strictEqual((await over.json()).error, 'menu-feeds.rate_limited');
limitServer.close();
++ // Home requires Bearer, validates against its contract and follows account export and erasure.
++ assert.strictEqual((await fetch(homeApi)).status, 401);
++ const homePreflight = await fetch(homeApi, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'PUT', 'access-control-request-headers': 'Authorization, Content-Type' } });
++ assert.strictEqual(homePreflight.status, 204);
++ assert.strictEqual(homePreflight.headers.get('access-control-allow-origin'), 'https://openvibe.live');
++ assert.strictEqual(homePreflight.headers.get('access-control-allow-credentials'), null, 'Bearer CORS does not grant cookies');
++ const layout = { home: ['live', 'folder:tools'], folders: [{ id: 'tools', name: 'Tools', apps: ['tools:qr'] }] };
++ const putHome = await fetch(homeApi, { method: 'PUT', headers: { authorization: `Bearer ${tokenOf(ana)}`, 'content-type': 'application/json' }, body: JSON.stringify(layout) });
++ assert.strictEqual(putHome.status, 200);
++ assert.ok(validate(homeScreenMod.CONTRACT, await putHome.json()).valid);
++ const badHome = await fetch(homeApi, { method: 'PUT', headers: { authorization: `Bearer ${tokenOf(ana)}`, 'content-type': 'application/json' }, body: JSON.stringify({ home: ['folder:missing'], folders: [] }) });
++ assert.strictEqual(badHome.status, 400);
++
// The choice goes into the person's export, and it is erased with the person.
const part = await accountData.networkPart(db, ana.id);
++ assert.deepStrictEqual(part.files.find((f) => f.name === 'home-screen.json').content.home, layout.home);
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");
++ assert.deepStrictEqual(bobPart.files.find((f) => f.name === 'menu-feeds.json').content.order, ['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', ?, ?)`)
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 1b29b5e4..5bd6cb4c 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -113,6 +113,7 @@ const REVIEWED = {
'server/node/egress-settings.js fetch': [2, 'browser-only GET and PUT to the fixed same-origin /api/v1/me/nodes/egress settings route; no caller-chosen host or URL'],
'server/connect/egress.js http.request': [1, 'Connect gateway (E7): only the app definition\'s fixed API host, through openvibe-shared/egress (IP literals refused before the socket, public DNS answers only, redirects followed by hand with every hop re-checked); never a URL from the input (Opus review 11-06)'],
'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
++ 'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],
'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],
'server/admin/routes.js fetch': [1, 'registry refresh: POSTs to the service refresh targets resolved from the registry (admin-set service URLs, staff only)'],
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 1abde30e..499560b1 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'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', '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'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', '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');