openvibers/openvibe.network Commits
Add M1 home screen, menu feeds v2, and Today frame (#704)
Changes
nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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";@@@ -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",@@@ -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",@@@ -115,7 +115,7 @@ async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKconst 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')}`,nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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')@@@ -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);@@@ -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' }));@@@ -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 siteif (/^\/api\/v1\/people\/@[A-Za-z0-9_]{1,40}\/page$/.test(path)) return true; // public person page JSONif (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 };@@@ -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.@@@ -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']]);@@@ -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');nnew file mode 100644@@@ -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; });@@@ -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', ?, ?)`)@@@ -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)'],@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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";
@@ -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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.157.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.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",
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.157.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -115,7 +115,7 @@ async function mint({ app, project, audience, scope, limit, onBehalfOf, privateK
@@ -115,7 +115,7 @@ async function mint({ app, project, audience, scope, limit, onBehalfOf, privateK
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
const claims = {
const claims = {
iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: projectNamespaces(project.id),
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 } : {}),
...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),
...(consent ? { consent } : {}),
...(consent ? { consent } : {}),
iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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 };
@@ -191,8 +191,13 @@ async function networkPart(db, userId) {
@@ -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.
// it readable without opening preferences.json. Never chosen reads as { on: null }, exactly as the route answers.
let feeds = prefs && prefs.menu_feeds;
let feeds = prefs && prefs.menu_feeds;
if (typeof feeds === 'string') { try { feeds = JSON.parse(feeds); } catch { feeds = null; } }
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');
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')
@@ -300,7 +300,7 @@ function isAllowedOrigin(origin) {
@@ -300,7 +300,7 @@ function isAllowedOrigin(origin) {
// Public discovery (/.well-known/openvibe, /api/v1/registry/*, /contracts/*.json) answers any
// 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).
// origin, preflight included; every other route keeps this allow-list (server/public-cors.js).
const corsGuard = require('./public-cors').originGuard(isAllowedOrigin);
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);
app.use(corsGuard.denied);
// ── Rate Limiting ────────────────────────────────────────────
// ── Rate Limiting ────────────────────────────────────────────
@@ -851,9 +851,11 @@ require('./developer/confirmations').start(db);
@@ -851,9 +851,11 @@ require('./developer/confirmations').start(db);
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
// 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
// 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 });
const menuFeeds = require('./menu-feeds').createMenuFeeds({ db });
app.use('/api/v1/me/menu-feeds', require('./menu-feeds').router({ menuFeeds, requireAuth }));
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 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).
@@ -946,6 +948,8 @@ app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles
@@ -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).
// 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.
// /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 });
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 });
const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
@@ -4,11 +4,11 @@
@@ -4,11 +4,11 @@
* order, so the menu shows the same feeds on every OpenVibe site. localStorage is per site, so the choice follows
* 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.
* 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
* 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.
* 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.
* 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 @@
@@ -18,12 +18,13 @@
const express = require('express');
const express = require('express');
const { validate } = require('openvibe-contracts');
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_LIMIT = 60;
const WRITE_WINDOW_MS = 60_000;
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
// 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.
// 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);
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)
@@ -31,7 +32,7 @@ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
function firstFault(errors) {
function firstFault(errors) {
const first = (errors || [])[0];
const first = (errors || [])[0];
if (!first) return 'not a menu-feeds choice';
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');
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,
@@ -50,7 +51,7 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
if (!db) throw new TypeError('createMenuFeeds: db is required');
if (!db) throw new TypeError('createMenuFeeds: db is required');
const writes = new Map();
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) {
async function read(userId) {
const row = await db.prepare('SELECT menu_feeds FROM user_preferences WHERE user_id = ?').get(userId);
const row = await db.prepare('SELECT menu_feeds FROM user_preferences WHERE user_id = ?').get(userId);
let value = row && row.menu_feeds;
let value = row && row.menu_feeds;
@@ -59,11 +60,12 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
@@ -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
// 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.
// 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 (!isObject(value) || checked(value)) {
if (row && row.menu_feeds != null) log.warn(`[MenuFeeds] user ${userId} has a choice the contract refuses; showing none`);
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. */
/** 60 writes a minute, per person. */
@@ -76,7 +78,7 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
@@ -76,7 +78,7 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
return true;
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 } = {}) {
async function write(userId, body, { ctx = null } = {}) {
const at = now();
const at = now();
if (!mayWrite(userId, at)) {
if (!mayWrite(userId, at)) {
@@ -89,7 +91,15 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
@@ -89,7 +91,15 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
if (unknown) {
if (unknown) {
throw Object.assign(new Error(`${unknown}: is not part of a menu-feeds choice`), { status: 400, code: 'menu-feeds.invalid' });
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);
const errors = checked(value);
if (errors) {
if (errors) {
throw Object.assign(new Error(firstFault(errors)), { status: 400, code: 'menu-feeds.invalid', 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,
@@ -103,7 +113,7 @@ function createMenuFeeds({ db, now = () => Date.now(), writeLimit = WRITE_LIMIT,
return { read, write, _writes: writes };
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 }) {
function router({ menuFeeds, requireAuth }) {
const r = express.Router();
const r = express.Router();
r.use(express.json({ limit: '2kb' }));
r.use(express.json({ limit: '2kb' }));
@@ -29,6 +29,7 @@ function isPublicDiscoveryPath(p) {
@@ -29,6 +29,7 @@ function isPublicDiscoveryPath(p) {
const path = String(p || '');
const path = String(p || '');
if (path === '/.well-known/openvibe') return true;
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/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\/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 (/^\/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;
if (CONTRACT_RE.test(path)) return true;
@@ -72,10 +73,11 @@ function extensionCors(req, res, next) {
@@ -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. */
/** 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) {
return function corsGate(req, res, next) {
if (isExtensionOrigin(req.headers.origin)) return extensionCors(req, res, next);
if (isExtensionOrigin(req.headers.origin)) return extensionCors(req, res, next);
if (isPublicDiscoveryPath(req.path)) return publicCors(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);
return restricted(req, res, next);
};
};
}
}
@@ -116,5 +118,8 @@ const RESTRICTED_MAX_AGE_S = 600;
@@ -116,5 +118,8 @@ const RESTRICTED_MAX_AGE_S = 600;
function restrictedCors(origin) {
function restrictedCors(origin) {
return require('cors')({ origin, credentials: true, maxAge: RESTRICTED_MAX_AGE_S });
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 };
@@ -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'], ['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.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.
@@ -110,7 +110,7 @@ const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: '
@@ -110,7 +110,7 @@ const U = { owner: jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: '
assert.ok(verified.ok, verified.reason);
assert.ok(verified.ok, verified.reason);
assert.strictEqual(jwt.decode(res.body.access_token, { complete: true }).header.alg, 'RS256');
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.
// 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}.*`]]);
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.
// 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']]);
assert.deepStrictEqual(await capsOf(PR, A, { scope: 'media.object.delete' }), [[], ['media.object.delete']]);
@@ -176,6 +176,7 @@ const secretsSeen = [];
@@ -176,6 +176,7 @@ const secretsSeen = [];
assert.ok(v.ok, v.reason);
assert.ok(v.ok, v.reason);
assert.strictEqual(v.claims.sub, `app:${A}`);
assert.strictEqual(v.claims.sub, `app:${A}`);
assert.strictEqual(v.claims.actor_type, 'app');
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.deepStrictEqual(v.claims.cap, ['media.object.upload'], 'only the approved grant');
assert.strictEqual(v.claims.project_id, P);
assert.strictEqual(v.claims.project_id, P);
assert.strictEqual(v.claims.env, 'sandbox');
assert.strictEqual(v.claims.env, 'sandbox');
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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; });
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
// every OpenVibe site because localStorage is per site.
// every OpenVibe site because localStorage is per site.
//
//
// What this proves:
// 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
// - 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;
// 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;
// - a session is needed, and one person never reads or writes another's choice;
@@ -23,6 +23,7 @@ const { ids, validate } = require('openvibe-contracts');
@@ -23,6 +23,7 @@ const { ids, validate } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const menuFeedsMod = require('../server/menu-feeds');
const menuFeedsMod = require('../server/menu-feeds');
+const homeScreenMod = require('../server/home-screen');
const accountData = require('../server/identity/account-data');
const accountData = require('../server/identity/account-data');
const publicCors = require('../server/public-cors');
const publicCors = require('../server/public-cors');
const authRoutes = require('../server/auth/routes');
const authRoutes = require('../server/auth/routes');
@@ -50,12 +51,14 @@ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey:
@@ -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.
// 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 allowed = new Set(['https://openvibe.live', 'https://openvibe.network']);
const corsGuard = publicCors.originGuard((origin) => allowed.has(origin), { log: { warn() {} } });
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(corsGuard.denied);
app.use('/api/v1/me/menu-feeds', menuFeedsMod.router({ menuFeeds, requireAuth }));
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);
const server = http.createServer(app);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
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 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, {
const call = (method, body, { as = ana, origin = null } = {}) => fetch(api, {
method,
method,
headers: { authorization: `Bearer ${tokenOf(as)}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(origin ? { origin } : {}) },
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, {
@@ -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 }));
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})), headers: r.headers }));
try {
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');
let r = await call('GET');
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
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');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
// A choice is stored whole, order included, and the answer is the contract.
// A choice is stored whole, order included, and the answer is the contract.
@@ -74,18 +77,24 @@ try {
@@ -74,18 +77,24 @@ try {
r = await call('PUT', { on: two });
r = await call('PUT', { on: two });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
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.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');
assert.ok(r.body.updated_at, 'the answer carries when it changed');
r = await call('GET');
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.
// 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'] });
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: [] });
r = await call('PUT', { on: [] });
assert.strictEqual(r.status, 200);
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.
// The contract refuses a body, with the field and the fault in detail; nothing is stored.
const before = (await call('GET')).body;
const before = (await call('GET')).body;
@@ -109,11 +118,11 @@ try {
@@ -109,11 +118,11 @@ try {
assert.strictEqual((await fetch(api, { method: 'GET' })).status, 401, 'a session is needed');
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');
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 });
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 });
r = await call('PUT', { on: ['live:live-now'] }, { as: bob });
assert.strictEqual(r.status, 200);
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
// CORS: https://openvibe.live may read, and its preflight is answered; another origin is refused, and its
// write never runs the route.
// write never runs the route.
@@ -128,7 +137,7 @@ try {
@@ -128,7 +137,7 @@ try {
assert.strictEqual((await cors.json()).code, 'cors.origin_denied');
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'] }) });
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.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).
// 60 writes a minute per person (a small window here so the test is quick).
const limitedMod = menuFeedsMod.createMenuFeeds({ db, writeLimit: 2 });
const limitedMod = menuFeedsMod.createMenuFeeds({ db, writeLimit: 2 });
@@ -146,14 +155,28 @@ try {
@@ -146,14 +155,28 @@ try {
assert.strictEqual((await over.json()).error, 'menu-feeds.rate_limited');
assert.strictEqual((await over.json()).error, 'menu-feeds.rate_limited');
limitServer.close();
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.
// The choice goes into the person's export, and it is erased with the person.
const part = await accountData.networkPart(db, ana.id);
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');
const feedsFile = part.files.find((f) => f.name === 'menu-feeds.json');
assert.ok(feedsFile, "ana's export carries her menu feeds");
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);
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');
assert.deepStrictEqual(feedsFile.content, storedFeeds, 'the exported choice is the stored one');
const bobPart = await accountData.networkPart(db, bob.id);
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()}`;
const delId = `del_${ids.ulid()}`;
await db.prepare(`INSERT INTO account_deletions (id, user_id, subject, status, requested_at, delete_after) VALUES (?, ?, ?, 'scheduled', ?, ?)`)
await db.prepare(`INSERT INTO account_deletions (id, user_id, subject, status, requested_at, delete_after) VALUES (?, ?, ?, 'scheduled', ?, ?)`)
@@ -113,6 +113,7 @@ const REVIEWED = {
@@ -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/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/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/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/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/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)'],
'server/admin/routes.js fetch': [1, 'registry refresh: POSTs to the service refresh targets resolved from the registry (admin-set service URLs, staff only)'],
@@ -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'], ['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');
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');