openvibers/openvibe.network Commits

Persist and publish Feed seen time (#790)

OP OpenVibers committed 1 days ago · 56143f8d0b · 6 files, +96 −2

* Persist and publish Feed seen time

* Use validated request trace context

* Merge remote-tracking branch 'origin/main' into fix/feed-seen-790

* Feed seen: migration 0079 (0069 was taken on main); the migration lists name it

* Merge main; Feed seen takes migration 0078, the next free number (0077 is machine access)

Changes

ddiff --git a/migrations/0078_feed_seen.sql b/migrations/0078_feed_seen.sql
nnew file mode 100644
iindex 00000000..59bd2b67
---- /dev/null
++++ b/migrations/0078_feed_seen.sql
@@@ -0,0 +1,3 @@
++-- phase: expand
++-- The menu Feed's seen time follows a signed-in person across OpenVibe sites.
++ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS feed_seen_at timestamptz;
ddiff --git a/server/feed-seen.js b/server/feed-seen.js
nnew file mode 100644
iindex 00000000..bd2ba874
---- /dev/null
++++ b/server/feed-seen.js
@@@ -0,0 +1,47 @@
++'use strict';
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const { ids, validate } = require('openvibe-contracts');
++const eventRelay = require('./developer/event-relay');
++const subjects = require('./identity/subjects');
++
++const EVENT_TYPE = 'network.feed.seen';
++
++function envelope(subject, at) {
++ const ms = Date.parse(at);
++ const env = {
++ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
++ actor: { type: 'user', id: subject }, timestamp: new Date(ms).toISOString(), visibility: 'subject',
++ subject: { type: 'user', id: subject }, payload: { seen_at: new Date(ms).toISOString() },
++ };
++ const v = validate('events.event-envelope@1', env);
++ if (!v.valid) throw new Error(`feed-seen: invalid event envelope: ${(v.errors || []).map(e => e.message).join('; ')}`);
++ return env;
++}
++
++function router({ requireAuth }) {
++ const r = express.Router();
++ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
++ r.use(requireAuth);
++ r.use(rateLimit({ windowMs: 60_000, max: 60, keyGenerator: (req) => String(req.user?.id || req.ip) }));
++ r.get('/', async (req, res) => {
++ const row = await req.app.locals.db.prepare('SELECT feed_seen_at FROM user_preferences WHERE user_id = ?').get(req.user.id);
++ res.json({ seen_at: row?.feed_seen_at ? new Date(row.feed_seen_at).toISOString() : null });
++ });
++ r.put('/', async (req, res) => {
++ const db = req.app.locals.db;
++ const at = new Date().toISOString();
++ const subject = await subjects.ensureUserSubject(db, req.user);
++ await db.tx(async () => {
++ await db.prepare(`INSERT INTO user_preferences (user_id, feed_seen_at, updated_at) VALUES (?, ?, ov_now())
++ ON CONFLICT (user_id) DO UPDATE SET feed_seen_at = EXCLUDED.feed_seen_at, updated_at = ov_now()`)
++ .run(req.user.id, at);
++ await eventRelay.writerFor(db).enqueue(envelope(subject, at), { traceparent: req.ov && req.ov.traceparent || null });
++ });
++ const live = eventRelay.outboxFor(db); if (live) live.kick();
++ res.json({ seen_at: at });
++ });
++ return r;
++}
++
++module.exports = { router, envelope, EVENT_TYPE };
ddiff --git a/server/index.js b/server/index.js
iindex 58f9beff..5f5301f1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -913,6 +913,7 @@ require('./developer/confirmations').start(db);
// 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 }));
++ app.use('/api/v1/me/feed-seen', require('./feed-seen').router({ 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
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 3f005177..716cc195 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', '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'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand'], ['0078', 'feed_seen', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/feed-seen.test.js b/test/feed-seen.test.js
nnew file mode 100644
iindex 00000000..a428f766
---- /dev/null
++++ b/test/feed-seen.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const http = require('node:http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const authRoutes = require('../server/auth/routes');
++const relay = require('../server/developer/event-relay');
++
++(async () => {
++ const db = getDb();
++ const anaId = (await db.prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?) RETURNING id').run(`feedseen${Date.now()}`, `feedseen${Date.now()}@example.test`, 'x')).lastInsertRowid;
++ const bobId = (await db.prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?) RETURNING id').run(`feedseenb${Date.now()}`, `feedseenb${Date.now()}@example.test`, 'x')).lastInsertRowid;
++ const ana = await db.prepare('SELECT * FROM users WHERE id = ?').get(anaId);
++ const bob = await db.prepare('SELECT * FROM users WHERE id = ?').get(bobId);
++ await subjects.ensureUserSubject(db, ana); await subjects.ensureUserSubject(db, bob);
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++ const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
++ const tokenOf = (u) => authRoutes.signToken(u, keys.privateKey, config);
++ const app = express(); app.use(express.json()); Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++ app.use('/api/v1/me/feed-seen', require('../server/feed-seen').router({ 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/feed-seen`;
++ const events = [], writerFor = relay.writerFor;
++ relay.writerFor = () => ({ enqueue: async (env) => events.push(env) });
++ const call = (method, user = ana) => fetch(api, { method, headers: { authorization: `Bearer ${tokenOf(user)}` } }).then(async (r) => ({ status: r.status, body: await r.json(), headers: r.headers }));
++ try {
++ assert.equal((await call('GET')).body.seen_at, null, 'unseen defaults to null');
++ const put = await call('PUT');
++ assert.equal(put.status, 200); assert.ok(put.body.seen_at); assert.equal(put.headers.get('cache-control'), 'private, no-store');
++ assert.equal((await call('GET')).body.seen_at, put.body.seen_at, "the person's own time persists");
++ assert.equal((await call('GET', bob)).body.seen_at, null, 'another person cannot read it');
++ assert.equal((await fetch(api)).status, 401, 'signed out is refused');
++ assert.equal(events.length, 1); assert.equal(events[0].event_type, 'network.feed.seen');
++ assert.deepEqual(events[0].subject, { type: 'user', id: ana.subject_id });
++ assert.equal(events[0].visibility, 'subject'); assert.equal(events[0].payload.seen_at, put.body.seen_at);
++ const migration = require('node:fs').readFileSync(require('node:path').join(__dirname, '..', 'migrations/0078_feed_seen.sql'), 'utf8');
++ assert.match(migration, /^-- phase: expand$/m);
++ } finally { relay.writerFor = writerFor; server.close(); }
++ console.log('feed-seen: ok'); process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 0d28be74..50fe6cbb 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -56,7 +56,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'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', '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'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand'], ['0078', 'feed_seen', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.