openvibers/openvibe.network Commits
Network notifies a new follow itself (roadmap WS-E task 3, D08: Live's pushes that have a domain event move off Live; ADR-020, ADR-030). Since follows moved to Network (FOLLOWS_AUTHORITY=network, 2026-09-26), a follow made on my.openvibe.network, another product or the API told nobody, and one made on Live was pushed by Live over the internal notification route. setFollow now calls a notifier (follows.setNotifier, set at boot to server/notifications/follow-notify.js) when a follow starts, inside its transaction, so the follow, its network.follow.created event, the FOLLOW notification and its network.notification.created event commit together; a notifier that throws is logged and never fails the follow. Flag changes, the same follow again, unfollows and imports (emit: false) notify nobody. The notification matches Live's (New Follower, "<name> followed you", the follower's channel at openvibe.live/@<username>, service live, the follower as sender) and goes through the notification service, so the social preference, blocks (actor_subject) and push apply. FOLLOW now shares the go-live dedupe: one an hour per sender, so unfollowing and following again does not notify twice (and Live's push, which names the same Network sender, is absorbed until Live stops sending it). test/follow-notify.test.js covers all of that; blocks.test.js's "unblocked" check uses a MENTION, since a second FOLLOW from the same person within the hour is now deduped. npm test passes.
Changes
@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follow-notify.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -34,6 +34,12 @@ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const NAME_RE = /^[A-Za-z0-9_]{1,64}$/;const MAX_FOLLOWING = 5000;++// A follow that starts tells the followed person (a FOLLOW notification, server/notifications/follow-notify.js),++// set at boot. It runs inside setFollow's transaction, so the follow, its event and the notification commit++// together; a notifier that throws never fails the follow. Imports (emit: false) and flag changes notify nobody.++let notifier = null;++function setNotifier(fn) { notifier = typeof fn === 'function' ? fn : null; }++class FollowError extends Error {constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }}@@@ -126,6 +132,9 @@ function setFollow(db, follower, type, target, active, { notifyEmail, notifyPushVALUES (?, ?, ?, 1, ?, ?, 1, ?, ?, ?)`).run(follower, type, target, email ? 1 : 0, push ? 1 : 0, source, at, at);}if (emit) eventRelay.writerFor(db).enqueue(buildEnvelope({ follower, type, target, active, notifyEmail: email, notifyPush: push, revision, at, reason, actor }));++ if (emit && active && !(prev && prev.active) && notifier) {++ try { notifier(db, { follower, type, target, at }); } catch (err) { console.warn('[Follows] follow notification not created:', err.message); }++ }return { changed: true, active: !!active, notify_email: email, notify_push: push, revision, created_at: active ? createdAt : null, at };})();}@@@ -374,4 +383,4 @@ function onSubjectMerged(db, { from, into }) {return out;}--module.exports = { onSubjectMerged, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, importFollows, routers, kick };++module.exports = { setNotifier, onSubjectMerged, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, importFollows, routers, kick };@@@ -458,6 +458,8 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.blocapp.use('/api/v1/follows', rateLimit({ windowMs: 60_000, max: 300 }), followRouters.pub);// ADR-030 step 4: products record follows on a person's behalf (network.follows.write, service token only).app.use('/internal/follows', require('./identity/principals').guard('network.follows.write', { legacy: false }), followRouters.internal);++ // A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.++ require('./identity/follows').setNotifier(require('./notifications/follow-notify').followNotifier(notificationService));}// Realtime tickets (WS-E task 3, WS-F task 1; ADR-005 amendment 2): the notification badge on any site opens// OpenVibe.Events' /realtime/stream as the signed-in person with a two-minute, single-use ticketnnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++/**++ * FOLLOW notifications from Network's own follow graph (roadmap WS-E task 3, ADR-020, ADR-030). A follow that++ * starts, made on any site, through the API or by Live's write-through (network.follows.write), tells the++ * followed person once: setFollow calls this inside its transaction (server/identity/follows.js setNotifier).++ * Live stopped pushing its own FOLLOW notification for follows Network took; it still pushes one for a follow++ * that stays Live-only (FOLLOWS_AUTHORITY unset, or a side with no Network subject).++ *++ * The notification service applies the recipient's social preference, their blocks (actor_subject) and the++ * one-an-hour dedupe per sender. The link is the follower's channel, as Live's was.++ */++const { channelUrl } = require('./stream-live');++++function followNotifier(notifications) {++ return (db, { follower, type, target }) => {++ if (type !== 'channel') return null;++ const who = db.prepare('SELECT id, username, display_name, avatar_url, is_anon FROM users WHERE subject_id = ?');++ const f = who.get(follower);++ const t = who.get(target);++ if (!f || !t || t.is_anon) return null;++ const name = f.display_name || f.username || 'Someone';++ return notifications.create({++ user_id: t.id, type: 'FOLLOW', title: 'New Follower', message: `${name} followed you`,++ url: f.username ? channelUrl(f.username) : 'https://openvibe.live/', service: 'live',++ sender_id: f.id, sender_name: name, sender_avatar: f.avatar_url || null, actor_subject: follower,++ });++ };++}++++module.exports = { followNotifier };@@@ -170,9 +170,10 @@ class NotificationService {// Nothing from a person the recipient blocked (platform blocks, WS-E task 5). A block never hides a// staff action: moderation, system and admin notices are always created.if (!BLOCK_EXEMPT_CATEGORIES.has(category) && this.fromBlockedActor(data)) return null;-- // Go-live dedupe (see _recentFromSender).-- if (data.type === 'STREAM_LIVE' && data.sender_id != null) {-- try { if (this._recentFromSender.get(data.user_id, 'STREAM_LIVE', data.sender_id)) return null; } catch { /* */ }++ // Go-live and follow dedupe (see _recentFromSender): one an hour per sender, so following again after an++ // unfollow does not notify again.++ if ((data.type === 'STREAM_LIVE' || data.type === 'FOLLOW') && data.sender_id != null) {++ try { if (this._recentFromSender.get(data.user_id, data.type, data.sender_id)) return null; } catch { /* */ }}const richContent = data.rich_content ? JSON.stringify(data.rich_content) : null;@@@ -175,7 +175,7 @@ const valid = (e) => {assert.ok(notes.create({ user_id: 1, type: 'SERVICE_ANNOUNCEMENT', title: 'hello' }), 'no actor: unchanged');assert.strictEqual(notes.createBulk([1, 3], { type: 'STREAM_LIVE', sender_id: 2, title: 'bob is live' }).length, 1, 'bulk: only cat hears bob went live');await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });-- assert.ok(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2 }), 'unblocked: notifications again');++ assert.ok(notes.create({ user_id: 1, type: 'MENTION', sender_id: 2, message: 'bob mentioned you' }), 'unblocked: notifications again (a MENTION: a second FOLLOW from bob within the hour is deduped)');// ── Import of Chat's exported dm_blocks ──const importer = require('../scripts/import-blocks');nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++// FOLLOW notifications from Network's follow graph (roadmap WS-E task 3, ADR-020, ADR-030): a follow that++// starts tells the followed person once, in the follow's transaction with its network.notification.created++// event; following again within the hour, changing flags, importing and unfollowing notify nobody; the++// person's social preference and blocks apply; a failing notifier never fails the follow.++// node test/follow-notify.test.js++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { initDb } = require('../server/db/database');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follow-notify-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++require('../server/identity/blocks').ensureSchema(db);++++const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';++const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';++const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';++const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';++db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, avatar_url) VALUES++ (1, 'ann', 'Ann', 'x', ?, 'https://openvibe.media/avatar/ann'), (2, 'bob', 'Bob', 'x', ?, NULL),++ (3, 'cat', NULL, 'x', ?, NULL), (4, 'dan', 'Dan', 'x', ?, NULL)`).run(ANN, BOB, CAT, DAN);++++const { NotificationService } = require('../server/notifications/notification-service');++const notifications = new NotificationService(db);++const follows = require('../server/identity/follows');++follows.ensureSchema(db);++follows.setNotifier(require('../server/notifications/follow-notify').followNotifier(notifications));++++const followsOf = (userId) => db.prepare("SELECT * FROM notifications WHERE user_id = ? AND type = 'FOLLOW' ORDER BY created_at").all(userId);++const announced = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))++ .filter((e) => e.event_type === 'network.notification.created');++++// A new follow: one FOLLOW for the followed person, from the follower, linking to the follower's channel.++follows.setFollow(db, ANN, 'channel', BOB, true);++let n = followsOf(2);++assert.strictEqual(n.length, 1);++assert.strictEqual(n[0].message, 'Ann followed you');++assert.strictEqual(n[0].title, 'New Follower');++assert.strictEqual(n[0].url, 'https://openvibe.live/@ann');++assert.strictEqual(n[0].service, 'live');++assert.strictEqual(n[0].category, 'social');++assert.strictEqual(n[0].sender_id, 1);++assert.strictEqual(n[0].sender_avatar, 'https://openvibe.media/avatar/ann');++assert.deepStrictEqual(announced().map((e) => [e.subject.id, e.payload.type]), [[BOB, 'FOLLOW']], 'announced to the followed person');++assert.strictEqual(followsOf(1).length, 0, 'the follower is not notified');++++// Same follow again, a flag change, an unfollow, and following again within the hour: nothing new.++follows.setFollow(db, ANN, 'channel', BOB, true);++follows.setFollow(db, ANN, 'channel', BOB, true, { notifyEmail: false });++follows.setFollow(db, ANN, 'channel', BOB, false);++follows.setFollow(db, ANN, 'channel', BOB, true);++assert.strictEqual(followsOf(2).length, 1, 'one an hour per follower');++assert.strictEqual(announced().length, 1);++++// Another follower is another notification; a name falls back to the username.++follows.setFollow(db, CAT, 'channel', BOB, true);++n = followsOf(2);++assert.strictEqual(n.length, 2);++assert.strictEqual(n[1].message, 'cat followed you');++++// An import (Live's backfill) announces nothing and notifies nobody.++follows.importFollows(db, 'live', [{ follower_ref: 4, target_ref: 1 }], (ref) => ({ 4: DAN, 1: ANN })[ref]);++assert.ok(follows.status(db, 'channel', ANN, DAN).following, 'imported');++assert.strictEqual(followsOf(1).length, 0);++++// The followed person's social preference off: the follow is made, no notification.++notifications.setPreference(4, 'social', { enabled: false });++follows.setFollow(db, ANN, 'channel', DAN, true);++assert.ok(follows.status(db, 'channel', DAN, ANN).following);++assert.strictEqual(followsOf(4).length, 0);++++// A follower the followed person blocked: no notification.++db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject) VALUES (?, ?)').run(ANN, CAT);++follows.setFollow(db, CAT, 'channel', ANN, true);++assert.strictEqual(followsOf(1).length, 0, 'blocked follower');++follows.setFollow(db, BOB, 'channel', ANN, true);++assert.strictEqual(followsOf(1).length, 1, 'anyone else still notifies');++++// A notifier that throws never fails the follow, and the follow and its event still commit.++follows.setNotifier(() => { throw new Error('boom'); });++const warn = console.warn; console.warn = () => {};++const out = follows.setFollow(db, DAN, 'channel', CAT, true);++console.warn = warn;++assert.ok(out.changed && out.active);++assert.ok(follows.status(db, 'channel', CAT, DAN).following);++assert.ok(db.prepare("SELECT 1 FROM network_event_outbox WHERE envelope LIKE '%network.follow.created%' AND envelope LIKE ?").get(`%"follower":"${DAN}","target_type":"channel","target_id":"${CAT}"%`));++follows.setNotifier(null);++++fs.rmSync(dir, { recursive: true, force: true });++console.log('follow notifications: ok');
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follow-notify.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -34,6 +34,12 @@ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -34,6 +34,12 @@ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const NAME_RE = /^[A-Za-z0-9_]{1,64}$/;
const NAME_RE = /^[A-Za-z0-9_]{1,64}$/;
const MAX_FOLLOWING = 5000;
const MAX_FOLLOWING = 5000;
+// A follow that starts tells the followed person (a FOLLOW notification, server/notifications/follow-notify.js),
+// set at boot. It runs inside setFollow's transaction, so the follow, its event and the notification commit
+// together; a notifier that throws never fails the follow. Imports (emit: false) and flag changes notify nobody.
+let notifier = null;
+function setNotifier(fn) { notifier = typeof fn === 'function' ? fn : null; }
+
class FollowError extends Error {
class FollowError extends Error {
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
}
}
@@ -126,6 +132,9 @@ function setFollow(db, follower, type, target, active, { notifyEmail, notifyPush
@@ -126,6 +132,9 @@ function setFollow(db, follower, type, target, active, { notifyEmail, notifyPush
VALUES (?, ?, ?, 1, ?, ?, 1, ?, ?, ?)`).run(follower, type, target, email ? 1 : 0, push ? 1 : 0, source, at, at);
VALUES (?, ?, ?, 1, ?, ?, 1, ?, ?, ?)`).run(follower, type, target, email ? 1 : 0, push ? 1 : 0, source, at, at);
}
}
if (emit) eventRelay.writerFor(db).enqueue(buildEnvelope({ follower, type, target, active, notifyEmail: email, notifyPush: push, revision, at, reason, actor }));
if (emit) eventRelay.writerFor(db).enqueue(buildEnvelope({ follower, type, target, active, notifyEmail: email, notifyPush: push, revision, at, reason, actor }));
+ if (emit && active && !(prev && prev.active) && notifier) {
+ try { notifier(db, { follower, type, target, at }); } catch (err) { console.warn('[Follows] follow notification not created:', err.message); }
+ }
return { changed: true, active: !!active, notify_email: email, notify_push: push, revision, created_at: active ? createdAt : null, at };
return { changed: true, active: !!active, notify_email: email, notify_push: push, revision, created_at: active ? createdAt : null, at };
})();
})();
}
}
@@ -374,4 +383,4 @@ function onSubjectMerged(db, { from, into }) {
@@ -374,4 +383,4 @@ function onSubjectMerged(db, { from, into }) {
return out;
return out;
}
}
-module.exports = { onSubjectMerged, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, importFollows, routers, kick };
+module.exports = { setNotifier, onSubjectMerged, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, importFollows, routers, kick };
@@ -458,6 +458,8 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
@@ -458,6 +458,8 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
app.use('/api/v1/follows', rateLimit({ windowMs: 60_000, max: 300 }), followRouters.pub);
app.use('/api/v1/follows', rateLimit({ windowMs: 60_000, max: 300 }), followRouters.pub);
// ADR-030 step 4: products record follows on a person's behalf (network.follows.write, service token only).
// ADR-030 step 4: products record follows on a person's behalf (network.follows.write, service token only).
app.use('/internal/follows', require('./identity/principals').guard('network.follows.write', { legacy: false }), followRouters.internal);
app.use('/internal/follows', require('./identity/principals').guard('network.follows.write', { legacy: false }), followRouters.internal);
+ // A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.
+ require('./identity/follows').setNotifier(require('./notifications/follow-notify').followNotifier(notificationService));
}
}
// Realtime tickets (WS-E task 3, WS-F task 1; ADR-005 amendment 2): the notification badge on any site opens
// Realtime tickets (WS-E task 3, WS-F task 1; ADR-005 amendment 2): the notification badge on any site opens
// OpenVibe.Events' /realtime/stream as the signed-in person with a two-minute, single-use ticket
// OpenVibe.Events' /realtime/stream as the signed-in person with a two-minute, single-use ticket
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+/**
+ * FOLLOW notifications from Network's own follow graph (roadmap WS-E task 3, ADR-020, ADR-030). A follow that
+ * starts, made on any site, through the API or by Live's write-through (network.follows.write), tells the
+ * followed person once: setFollow calls this inside its transaction (server/identity/follows.js setNotifier).
+ * Live stopped pushing its own FOLLOW notification for follows Network took; it still pushes one for a follow
+ * that stays Live-only (FOLLOWS_AUTHORITY unset, or a side with no Network subject).
+ *
+ * The notification service applies the recipient's social preference, their blocks (actor_subject) and the
+ * one-an-hour dedupe per sender. The link is the follower's channel, as Live's was.
+ */
+const { channelUrl } = require('./stream-live');
+
+function followNotifier(notifications) {
+ return (db, { follower, type, target }) => {
+ if (type !== 'channel') return null;
+ const who = db.prepare('SELECT id, username, display_name, avatar_url, is_anon FROM users WHERE subject_id = ?');
+ const f = who.get(follower);
+ const t = who.get(target);
+ if (!f || !t || t.is_anon) return null;
+ const name = f.display_name || f.username || 'Someone';
+ return notifications.create({
+ user_id: t.id, type: 'FOLLOW', title: 'New Follower', message: `${name} followed you`,
+ url: f.username ? channelUrl(f.username) : 'https://openvibe.live/', service: 'live',
+ sender_id: f.id, sender_name: name, sender_avatar: f.avatar_url || null, actor_subject: follower,
+ });
+ };
+}
+
+module.exports = { followNotifier };
@@ -170,9 +170,10 @@ class NotificationService {
@@ -170,9 +170,10 @@ class NotificationService {
// Nothing from a person the recipient blocked (platform blocks, WS-E task 5). A block never hides a
// Nothing from a person the recipient blocked (platform blocks, WS-E task 5). A block never hides a
// staff action: moderation, system and admin notices are always created.
// staff action: moderation, system and admin notices are always created.
if (!BLOCK_EXEMPT_CATEGORIES.has(category) && this.fromBlockedActor(data)) return null;
if (!BLOCK_EXEMPT_CATEGORIES.has(category) && this.fromBlockedActor(data)) return null;
- // Go-live dedupe (see _recentFromSender).
- if (data.type === 'STREAM_LIVE' && data.sender_id != null) {
- try { if (this._recentFromSender.get(data.user_id, 'STREAM_LIVE', data.sender_id)) return null; } catch { /* */ }
+ // Go-live and follow dedupe (see _recentFromSender): one an hour per sender, so following again after an
+ // unfollow does not notify again.
+ if ((data.type === 'STREAM_LIVE' || data.type === 'FOLLOW') && data.sender_id != null) {
+ try { if (this._recentFromSender.get(data.user_id, data.type, data.sender_id)) return null; } catch { /* */ }
}
}
const richContent = data.rich_content ? JSON.stringify(data.rich_content) : null;
const richContent = data.rich_content ? JSON.stringify(data.rich_content) : null;
@@ -175,7 +175,7 @@ const valid = (e) => {
@@ -175,7 +175,7 @@ const valid = (e) => {
assert.ok(notes.create({ user_id: 1, type: 'SERVICE_ANNOUNCEMENT', title: 'hello' }), 'no actor: unchanged');
assert.ok(notes.create({ user_id: 1, type: 'SERVICE_ANNOUNCEMENT', title: 'hello' }), 'no actor: unchanged');
assert.strictEqual(notes.createBulk([1, 3], { type: 'STREAM_LIVE', sender_id: 2, title: 'bob is live' }).length, 1, 'bulk: only cat hears bob went live');
assert.strictEqual(notes.createBulk([1, 3], { type: 'STREAM_LIVE', sender_id: 2, title: 'bob is live' }).length, 1, 'bulk: only cat hears bob went live');
await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });
await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });
- assert.ok(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2 }), 'unblocked: notifications again');
+ assert.ok(notes.create({ user_id: 1, type: 'MENTION', sender_id: 2, message: 'bob mentioned you' }), 'unblocked: notifications again (a MENTION: a second FOLLOW from bob within the hour is deduped)');
// ── Import of Chat's exported dm_blocks ──
// ── Import of Chat's exported dm_blocks ──
const importer = require('../scripts/import-blocks');
const importer = require('../scripts/import-blocks');
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+// FOLLOW notifications from Network's follow graph (roadmap WS-E task 3, ADR-020, ADR-030): a follow that
+// starts tells the followed person once, in the follow's transaction with its network.notification.created
+// event; following again within the hour, changing flags, importing and unfollowing notify nobody; the
+// person's social preference and blocks apply; a failing notifier never fails the follow.
+// node test/follow-notify.test.js
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { initDb } = require('../server/db/database');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follow-notify-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+require('../server/identity/blocks').ensureSchema(db);
+
+const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
+const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
+const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
+const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
+db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, avatar_url) VALUES
+ (1, 'ann', 'Ann', 'x', ?, 'https://openvibe.media/avatar/ann'), (2, 'bob', 'Bob', 'x', ?, NULL),
+ (3, 'cat', NULL, 'x', ?, NULL), (4, 'dan', 'Dan', 'x', ?, NULL)`).run(ANN, BOB, CAT, DAN);
+
+const { NotificationService } = require('../server/notifications/notification-service');
+const notifications = new NotificationService(db);
+const follows = require('../server/identity/follows');
+follows.ensureSchema(db);
+follows.setNotifier(require('../server/notifications/follow-notify').followNotifier(notifications));
+
+const followsOf = (userId) => db.prepare("SELECT * FROM notifications WHERE user_id = ? AND type = 'FOLLOW' ORDER BY created_at").all(userId);
+const announced = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))
+ .filter((e) => e.event_type === 'network.notification.created');
+
+// A new follow: one FOLLOW for the followed person, from the follower, linking to the follower's channel.
+follows.setFollow(db, ANN, 'channel', BOB, true);
+let n = followsOf(2);
+assert.strictEqual(n.length, 1);
+assert.strictEqual(n[0].message, 'Ann followed you');
+assert.strictEqual(n[0].title, 'New Follower');
+assert.strictEqual(n[0].url, 'https://openvibe.live/@ann');
+assert.strictEqual(n[0].service, 'live');
+assert.strictEqual(n[0].category, 'social');
+assert.strictEqual(n[0].sender_id, 1);
+assert.strictEqual(n[0].sender_avatar, 'https://openvibe.media/avatar/ann');
+assert.deepStrictEqual(announced().map((e) => [e.subject.id, e.payload.type]), [[BOB, 'FOLLOW']], 'announced to the followed person');
+assert.strictEqual(followsOf(1).length, 0, 'the follower is not notified');
+
+// Same follow again, a flag change, an unfollow, and following again within the hour: nothing new.
+follows.setFollow(db, ANN, 'channel', BOB, true);
+follows.setFollow(db, ANN, 'channel', BOB, true, { notifyEmail: false });
+follows.setFollow(db, ANN, 'channel', BOB, false);
+follows.setFollow(db, ANN, 'channel', BOB, true);
+assert.strictEqual(followsOf(2).length, 1, 'one an hour per follower');
+assert.strictEqual(announced().length, 1);
+
+// Another follower is another notification; a name falls back to the username.
+follows.setFollow(db, CAT, 'channel', BOB, true);
+n = followsOf(2);
+assert.strictEqual(n.length, 2);
+assert.strictEqual(n[1].message, 'cat followed you');
+
+// An import (Live's backfill) announces nothing and notifies nobody.
+follows.importFollows(db, 'live', [{ follower_ref: 4, target_ref: 1 }], (ref) => ({ 4: DAN, 1: ANN })[ref]);
+assert.ok(follows.status(db, 'channel', ANN, DAN).following, 'imported');
+assert.strictEqual(followsOf(1).length, 0);
+
+// The followed person's social preference off: the follow is made, no notification.
+notifications.setPreference(4, 'social', { enabled: false });
+follows.setFollow(db, ANN, 'channel', DAN, true);
+assert.ok(follows.status(db, 'channel', DAN, ANN).following);
+assert.strictEqual(followsOf(4).length, 0);
+
+// A follower the followed person blocked: no notification.
+db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject) VALUES (?, ?)').run(ANN, CAT);
+follows.setFollow(db, CAT, 'channel', ANN, true);
+assert.strictEqual(followsOf(1).length, 0, 'blocked follower');
+follows.setFollow(db, BOB, 'channel', ANN, true);
+assert.strictEqual(followsOf(1).length, 1, 'anyone else still notifies');
+
+// A notifier that throws never fails the follow, and the follow and its event still commit.
+follows.setNotifier(() => { throw new Error('boom'); });
+const warn = console.warn; console.warn = () => {};
+const out = follows.setFollow(db, DAN, 'channel', CAT, true);
+console.warn = warn;
+assert.ok(out.changed && out.active);
+assert.ok(follows.status(db, 'channel', CAT, DAN).following);
+assert.ok(db.prepare("SELECT 1 FROM network_event_outbox WHERE envelope LIKE '%network.follow.created%' AND envelope LIKE ?").get(`%"follower":"${DAN}","target_type":"channel","target_id":"${CAT}"%`));
+follows.setNotifier(null);
+
+fs.rmSync(dir, { recursive: true, force: true });
+console.log('follow notifications: ok');