openvibers/openvibe.network Commits
Follows, ADR-030 step 4 on Network (Contracts v0.67.0, pinned from 0.66.0). Products record follows on a person's behalf at PUT/DELETE /internal/follows/:type/:target (network.follows.write, service token only, network.follow-write-request@1; granted to Live; the follower must be a Network account; the writing service is recorded as the source). With FOLLOWS_AUTHORITY=network, go-live notifications read the followers from Network's own graph and no longer ask Live (unset to roll back). test/follows-authority.test.js
Changes
@@@ -422,6 +422,11 @@ A target is a Live channel today (`channel`, named by its owner's subject), andservices with `network.follows.read` (service token only). Lists are never public.- **Events:** every change writes `network.follow.created` or `network.follow.deleted` (subject visibility,growing per-pair revision) to the outbox in the same transaction. Live keeps a projection from them.++- **Products writing for a person** (ADR-030 step 4): `PUT|DELETE /internal/follows/:type/:target` needs++ `network.follows.write`, a service token and `network.follow-write-request@1`. It is granted to Live, whose++ buttons use it.++- **Go-live source:** with `FOLLOWS_AUTHORITY=network`, go-live notifications read the followers here++ instead of asking Live. Unset it to roll back.- **Migration:** `scripts/follows-backfill.js --live-db <live.db>` does a dry run by default. `--apply --backup <file>`imports Live's follows without events: a pair whose side has no subject goes to `follow_import_holds`, andnothing is dropped. `--reconcile` compares per-channel counts and pair sets.@@@ -20,7 +20,7 @@"helmet": "^8.1.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.66.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.67.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","uuid": "^11.1.0",@@@ -1465,9 +1465,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.66.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",-- "integrity": "sha512-iArup+k2iUI4ddMDVgVrf8xTCbEB5LaV0Da/1Bt5k3eH2TJpJ7yIEtBgBee0UZ6taNd2bZsZ6zJOkamqCAa4nA==",++ "version": "0.67.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.67.0",++ "integrity": "sha512-1S1Tq3v2O49VOz86Rf40j6u3bSR9V2Ejqz0SeZCc1DRYCy0WnG/e/8cd5676kPpqSEvnYre+HguQufnTqGCjEA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -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/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",++ "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/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","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -23,7 +23,7 @@"helmet": "^8.1.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.66.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.67.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","uuid": "^11.1.0",@@@ -312,7 +312,39 @@ function routers({ requireAuth, followsGuard }) {return followsGuard(req, res, answer);});-- return { me, pub };++ // ADR-030 step 4: a first-party product (network.follows.write, checked by the mount) records a follow++ // on a person's behalf (network.follow-write-request@1). The follower must be a Network account.++ const internal = express.Router();++ internal.use(http.middleware());++ const followerOrFail = (db, subject) => {++ const u = SUBJECT_RE.test(String(subject || '')) ? personRow(db, 'subject_id = ?', subject) : null;++ if (!u || u.is_anon) throw new FollowError(404, 'follows.unknown_follower', 'the follower is not a Network account');++ return u.subject_id;++ };++ internal.put('/:type/:target', express.json({ limit: '4kb' }), (req, res) => send(req, res, (db) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const v = validate('network.follow-write-request@1', b);++ if (!v.valid) throw new FollowError(400, 'follows.invalid_request', 'the body does not match network.follow-write-request@1');++ const follower = followerOrFail(db, b.follower);++ const t = targetOrFail(db, req.params.type, req.params.target);++ const out = setFollow(db, follower, req.params.type, t.subject_id, true, {++ notifyEmail: typeof b.notify_email === 'boolean' ? b.notify_email : undefined, notifyPush: typeof b.notify_push === 'boolean' ? b.notify_push : undefined,++ source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network',++ });++ if (out.changed) kick(db);++ res.status(out.changed && out.revision === 1 ? 201 : 200).json(status(db, req.params.type, t.subject_id, follower));++ }));++ internal.delete('/:type/:target', (req, res) => send(req, res, (db) => {++ const follower = followerOrFail(db, req.query.follower);++ const key = String(req.params.target || '');++ const t = findTarget(db, req.params.type, key) || (SUBJECT_RE.test(key) ? { subject_id: key } : null);++ if (!t || !t.subject_id) throw new FollowError(404, 'follows.unknown_target', 'no such channel');++ const out = setFollow(db, follower, req.params.type, t.subject_id, false, { source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network' });++ if (out.changed) kick(db);++ res.json(status(db, req.params.type, t.subject_id, follower));++ }));++++ return { me, pub, internal };}module.exports = { TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, importFollows, routers, kick };@@@ -123,6 +123,7 @@ const DEFAULT_GRANTS = [['host', 'events.event.publish', 'openvibe.events', []],// ADR-030 (WS-E task 4): Live rebuilds its follows projection from Network's graph.['live', 'network.follows.read', SELF_AUDIENCE, []],++ ['live', 'network.follows.write', SELF_AUDIENCE, []],// WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).['host', 'network.operator.alert', SELF_AUDIENCE, []],// WS-N task 12: ovhost incident / maintenance post to the status page.@@@ -376,6 +376,7 @@ const projectUsage = require('./developer/usage').createProjectUsage(db);eventsConsumer = require('./notifications/events-consumer').createEventsConsumer({db, notifications: notificationService, secrets: config.eventsWebhookSecrets,liveFollowers, discord: () => app.locals.discordService || null, moderationAudit, projectUsage,++ followsAuthority: process.env.FOLLOWS_AUTHORITY === 'network' ? 'network' : 'live', // ADR-030 step 4});console.log(`[Events consumer] ${eventsConsumer.enabled ? 'on' : 'off (NETWORK_EVENTS_SECRET unset)'}: POST /internal/events`);@@@ -414,6 +415,8 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.blocconst followRouters = require('./identity/follows').routers({ requireAuth, followsGuard: require('./identity/principals').guard('network.follows.read', { legacy: false }) });app.use('/api/v1/me/follows', rateLimit({ windowMs: 60_000, max: 120 }), followRouters.me);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).++ app.use('/internal/follows', require('./identity/principals').guard('network.follows.write', { legacy: false }), followRouters.internal);}// 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@@@ -162,7 +162,7 @@ function liveStarted(event, { now, maxAgeMs }) {* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]* @param {{ record(event: object): string }} [o.projectUsage] ../developer/usage.js createProjectUsage()*/--function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, discord = () => null, moderationAudit = null, projectUsage = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {++function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, followsAuthority = 'live', discord = () => null, moderationAudit = null, projectUsage = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);const inbox = createInbox(db, { table: INBOX_TABLE, now });inbox.ensureSchema();@@@ -179,6 +179,12 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = nullconst v = liveStarted(event, { now: now(), maxAgeMs: liveStartedMaxAgeMs });if (typeof v === 'string') return { skip: v };if (!streamerBySubject.get(v.subject)) return { skip: 'ignored:channel' }; // not a Network account: nobody to announce++ // ADR-030 step 4: with FOLLOWS_AUTHORITY=network the followers come from Network's own graph++ // (server/identity/follows.js), not from Live. Rollback: unset it.++ if (followsAuthority === 'network') {++ const rows = db.prepare("SELECT follower_subject FROM user_follows WHERE target_type = 'channel' AND target_id = ? AND active = 1").all(v.subject);++ return { channelSubject: v.subject, followers: rows.map((r) => ({ subject: r.follower_subject })) };++ }if (!liveFollowers) throw new LiveFollowersError('no Live followers client (RS256 signing key and OV_LIVE_INTERNAL_URL needed)');return liveFollowers.forStream(v.streamId);},nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++// ADR-030 step 4: products write follows through /internal/follows (network.follows.write, service token only),++// and with FOLLOWS_AUTHORITY=network the go-live notifications take the followers from Network's own graph++// (no call to Live).++// node test/follows-authority.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const { ids } = require('openvibe-contracts');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { initDb } = require('../server/db/database');++const { NotificationService } = require('../server/notifications/notification-service');++const { createEventsConsumer } = require('../server/notifications/events-consumer');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follow-auth-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++require('../server/identity/principals').ensureSchema(db);++for (const c of ['live', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);++const CAROL = ids.newId('user'), DAVE = ids.newId('user'), ERIN = ids.newId('user');++db.prepare('INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (20, ?, ?, ?, ?), (21, ?, ?, ?, ?), (22, ?, ?, ?, ?)')++ .run('carol', 'x', CAROL, 'streamer', 'dave', 'x', DAVE, 'user', 'erin', 'x', ERIN, 'user');++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++const { signToken } = require('../server/auth/routes');++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);++const follows = require('../server/identity/follows');++const principals = require('../server/identity/principals');++const SECRET = 's'.repeat(40);++const notifications = new NotificationService(db);++const consumer = createEventsConsumer({ db, notifications, secrets: SECRET, followsAuthority: 'network', log: { log() {}, warn() {}, error() {} } });++const app = express();++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++app.use(express.urlencoded({ extended: true }));++app.use('/oauth', require('../server/auth/oauth-routes'));++const routers = follows.routers({ requireAuth, followsGuard: principals.guard('network.follows.read', { legacy: false }) });++app.use('/internal/follows', principals.guard('network.follows.write', { legacy: false }), routers.internal);++app.use('/internal/events', consumer.router);++const server = http.createServer(app);++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const svc = async (client) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) })).json()).access_token;++ const call = (method, p, { body, token } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++ try {++ const live = await svc('live');++ // ── Writes on a person's behalf ──++ assert.ok([401, 403].includes((await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE } })).status), 'no token');++ assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE }, token: await svc('tools') })).status, 403, 'without network.follows.write');++ let r = await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE, notify_push: false }, token: live });++ assert.deepStrictEqual([r.status, r.body.followers, r.body.following, r.body.notify_push], [201, 1, true, false]);++ r = await call('PUT', `/internal/follows/channel/${CAROL}`, { body: { follower: ERIN }, token: live });++ assert.strictEqual(r.body.followers, 2);++ assert.strictEqual(db.prepare('SELECT source FROM user_follows WHERE follower_subject = ?').get(ERIN).source, 'live', 'the writing service is recorded');++ assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPZ' }, token: live })).status, 404, 'the follower must be a Network account');++ assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE, extra: 1 }, token: live })).status, 400);++ r = await call('DELETE', `/internal/follows/channel/carol?follower=${ERIN}`, { token: live });++ assert.deepStrictEqual([r.status, r.body.following, r.body.followers], [200, false, 1]);++ await call('PUT', '/internal/follows/channel/carol', { body: { follower: ERIN }, token: live });++ const evs = db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((x) => JSON.parse(x.envelope)).filter((e) => e.event_type.startsWith('network.follow.'));++ assert.deepStrictEqual(evs.map((e) => e.event_type), ['network.follow.created', 'network.follow.created', 'network.follow.deleted', 'network.follow.created']);++++ // ── Go-live with FOLLOWS_AUTHORITY=network: Network's followers, no Live call ──++ const started = {++ event_id: ids.newId('event'), event_type: 'live.stream.started', version: 1, source: 'live',++ actor: { type: 'user', id: CAROL }, subject: { type: 'stream', id: '801', revision: 1 }, visibility: 'public', priority: 'important',++ occurred_at: new Date().toISOString(),++ payload: { stream_id: 801, channel: { username: 'carol', display_name: 'Carol', url: 'https://openvibe.live/@carol', subject: { type: 'user', id: CAROL } }, title: 'Go', category: null, protocol: 'whip', is_nsfw: false, started_at: new Date().toISOString() },++ };++ const raw = JSON.stringify({ event: started, seq: 1 });++ const res = await fetch(`${base}/internal/events`, { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) }, body: raw });++ const body = await res.json();++ assert.strictEqual(res.status, 200, JSON.stringify(body));++ assert.strictEqual(body.outcome, 'notified', JSON.stringify(body));++ const told = db.prepare("SELECT user_id FROM notifications WHERE type = 'STREAM_LIVE' AND sender_id = 20 ORDER BY user_id").all().map((x) => x.user_id);++ assert.deepStrictEqual(told, [21, 22], 'both followers, from Network\'s graph');++ } finally {++ server.close();++ fs.rmSync(dir, { recursive: true, force: true });++ }++ console.log('follows authority: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -50,7 +50,7 @@ const server = http.createServer(app);const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });assert.ok(claims.ok, claims.reason);assert.strictEqual(claims.claims.sub, 'svc:live');-- assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.coins.credit', 'network.coins.debit', 'network.follows.read', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);++ assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.coins.credit', 'network.coins.debit', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');const full = t.body.access_token;
@@ -422,6 +422,11 @@ A target is a Live channel today (`channel`, named by its owner's subject), and
@@ -422,6 +422,11 @@ A target is a Live channel today (`channel`, named by its owner's subject), and
services with `network.follows.read` (service token only). Lists are never public.
services with `network.follows.read` (service token only). Lists are never public.
- **Events:** every change writes `network.follow.created` or `network.follow.deleted` (subject visibility,
- **Events:** every change writes `network.follow.created` or `network.follow.deleted` (subject visibility,
growing per-pair revision) to the outbox in the same transaction. Live keeps a projection from them.
growing per-pair revision) to the outbox in the same transaction. Live keeps a projection from them.
+- **Products writing for a person** (ADR-030 step 4): `PUT|DELETE /internal/follows/:type/:target` needs
+ `network.follows.write`, a service token and `network.follow-write-request@1`. It is granted to Live, whose
+ buttons use it.
+- **Go-live source:** with `FOLLOWS_AUTHORITY=network`, go-live notifications read the followers here
+ instead of asking Live. Unset it to roll back.
- **Migration:** `scripts/follows-backfill.js --live-db <live.db>` does a dry run by default. `--apply --backup <file>`
- **Migration:** `scripts/follows-backfill.js --live-db <live.db>` does a dry run by default. `--apply --backup <file>`
imports Live's follows without events: a pair whose side has no subject goes to `follow_import_holds`, and
imports Live's follows without events: a pair whose side has no subject goes to `follow_import_holds`, and
nothing is dropped. `--reconcile` compares per-channel counts and pair sets.
nothing is dropped. `--reconcile` compares per-channel counts and pair sets.
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.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.66.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.66.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",
- "integrity": "sha512-iArup+k2iUI4ddMDVgVrf8xTCbEB5LaV0Da/1Bt5k3eH2TJpJ7yIEtBgBee0UZ6taNd2bZsZ6zJOkamqCAa4nA==",
+ "version": "0.67.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.67.0",
+ "integrity": "sha512-1S1Tq3v2O49VOz86Rf40j6u3bSR9V2Ejqz0SeZCc1DRYCy0WnG/e/8cd5676kPpqSEvnYre+HguQufnTqGCjEA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -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/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",
+ "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/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",
"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"
},
},
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.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.66.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -312,7 +312,39 @@ function routers({ requireAuth, followsGuard }) {
@@ -312,7 +312,39 @@ function routers({ requireAuth, followsGuard }) {
return followsGuard(req, res, answer);
return followsGuard(req, res, answer);
});
});
- return { me, pub };
+ // ADR-030 step 4: a first-party product (network.follows.write, checked by the mount) records a follow
+ // on a person's behalf (network.follow-write-request@1). The follower must be a Network account.
+ const internal = express.Router();
+ internal.use(http.middleware());
+ const followerOrFail = (db, subject) => {
+ const u = SUBJECT_RE.test(String(subject || '')) ? personRow(db, 'subject_id = ?', subject) : null;
+ if (!u || u.is_anon) throw new FollowError(404, 'follows.unknown_follower', 'the follower is not a Network account');
+ return u.subject_id;
+ };
+ internal.put('/:type/:target', express.json({ limit: '4kb' }), (req, res) => send(req, res, (db) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const v = validate('network.follow-write-request@1', b);
+ if (!v.valid) throw new FollowError(400, 'follows.invalid_request', 'the body does not match network.follow-write-request@1');
+ const follower = followerOrFail(db, b.follower);
+ const t = targetOrFail(db, req.params.type, req.params.target);
+ const out = setFollow(db, follower, req.params.type, t.subject_id, true, {
+ notifyEmail: typeof b.notify_email === 'boolean' ? b.notify_email : undefined, notifyPush: typeof b.notify_push === 'boolean' ? b.notify_push : undefined,
+ source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network',
+ });
+ if (out.changed) kick(db);
+ res.status(out.changed && out.revision === 1 ? 201 : 200).json(status(db, req.params.type, t.subject_id, follower));
+ }));
+ internal.delete('/:type/:target', (req, res) => send(req, res, (db) => {
+ const follower = followerOrFail(db, req.query.follower);
+ const key = String(req.params.target || '');
+ const t = findTarget(db, req.params.type, key) || (SUBJECT_RE.test(key) ? { subject_id: key } : null);
+ if (!t || !t.subject_id) throw new FollowError(404, 'follows.unknown_target', 'no such channel');
+ const out = setFollow(db, follower, req.params.type, t.subject_id, false, { source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network' });
+ if (out.changed) kick(db);
+ res.json(status(db, req.params.type, t.subject_id, follower));
+ }));
+
+ return { me, pub, internal };
}
}
module.exports = { TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, importFollows, routers, kick };
module.exports = { TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, importFollows, routers, kick };
@@ -123,6 +123,7 @@ const DEFAULT_GRANTS = [
@@ -123,6 +123,7 @@ const DEFAULT_GRANTS = [
['host', 'events.event.publish', 'openvibe.events', []],
['host', 'events.event.publish', 'openvibe.events', []],
// ADR-030 (WS-E task 4): Live rebuilds its follows projection from Network's graph.
// ADR-030 (WS-E task 4): Live rebuilds its follows projection from Network's graph.
['live', 'network.follows.read', SELF_AUDIENCE, []],
['live', 'network.follows.read', SELF_AUDIENCE, []],
+ ['live', 'network.follows.write', SELF_AUDIENCE, []],
// WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).
// WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).
['host', 'network.operator.alert', SELF_AUDIENCE, []],
['host', 'network.operator.alert', SELF_AUDIENCE, []],
// WS-N task 12: ovhost incident / maintenance post to the status page.
// WS-N task 12: ovhost incident / maintenance post to the status page.
@@ -376,6 +376,7 @@ const projectUsage = require('./developer/usage').createProjectUsage(db);
@@ -376,6 +376,7 @@ const projectUsage = require('./developer/usage').createProjectUsage(db);
eventsConsumer = require('./notifications/events-consumer').createEventsConsumer({
eventsConsumer = require('./notifications/events-consumer').createEventsConsumer({
db, notifications: notificationService, secrets: config.eventsWebhookSecrets,
db, notifications: notificationService, secrets: config.eventsWebhookSecrets,
liveFollowers, discord: () => app.locals.discordService || null, moderationAudit, projectUsage,
liveFollowers, discord: () => app.locals.discordService || null, moderationAudit, projectUsage,
+ followsAuthority: process.env.FOLLOWS_AUTHORITY === 'network' ? 'network' : 'live', // ADR-030 step 4
});
});
console.log(`[Events consumer] ${eventsConsumer.enabled ? 'on' : 'off (NETWORK_EVENTS_SECRET unset)'}: POST /internal/events`);
console.log(`[Events consumer] ${eventsConsumer.enabled ? 'on' : 'off (NETWORK_EVENTS_SECRET unset)'}: POST /internal/events`);
@@ -414,6 +415,8 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
@@ -414,6 +415,8 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
const followRouters = require('./identity/follows').routers({ requireAuth, followsGuard: require('./identity/principals').guard('network.follows.read', { legacy: false }) });
const followRouters = require('./identity/follows').routers({ requireAuth, followsGuard: require('./identity/principals').guard('network.follows.read', { legacy: false }) });
app.use('/api/v1/me/follows', rateLimit({ windowMs: 60_000, max: 120 }), followRouters.me);
app.use('/api/v1/me/follows', rateLimit({ windowMs: 60_000, max: 120 }), followRouters.me);
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).
+ app.use('/internal/follows', require('./identity/principals').guard('network.follows.write', { legacy: false }), followRouters.internal);
}
}
// 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
@@ -162,7 +162,7 @@ function liveStarted(event, { now, maxAgeMs }) {
@@ -162,7 +162,7 @@ function liveStarted(event, { now, maxAgeMs }) {
* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]
* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]
* @param {{ record(event: object): string }} [o.projectUsage] ../developer/usage.js createProjectUsage()
* @param {{ record(event: object): string }} [o.projectUsage] ../developer/usage.js createProjectUsage()
*/
*/
-function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, discord = () => null, moderationAudit = null, projectUsage = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {
+function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, followsAuthority = 'live', discord = () => null, moderationAudit = null, projectUsage = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {
const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);
const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);
const inbox = createInbox(db, { table: INBOX_TABLE, now });
const inbox = createInbox(db, { table: INBOX_TABLE, now });
inbox.ensureSchema();
inbox.ensureSchema();
@@ -179,6 +179,12 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
@@ -179,6 +179,12 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
const v = liveStarted(event, { now: now(), maxAgeMs: liveStartedMaxAgeMs });
const v = liveStarted(event, { now: now(), maxAgeMs: liveStartedMaxAgeMs });
if (typeof v === 'string') return { skip: v };
if (typeof v === 'string') return { skip: v };
if (!streamerBySubject.get(v.subject)) return { skip: 'ignored:channel' }; // not a Network account: nobody to announce
if (!streamerBySubject.get(v.subject)) return { skip: 'ignored:channel' }; // not a Network account: nobody to announce
+ // ADR-030 step 4: with FOLLOWS_AUTHORITY=network the followers come from Network's own graph
+ // (server/identity/follows.js), not from Live. Rollback: unset it.
+ if (followsAuthority === 'network') {
+ const rows = db.prepare("SELECT follower_subject FROM user_follows WHERE target_type = 'channel' AND target_id = ? AND active = 1").all(v.subject);
+ return { channelSubject: v.subject, followers: rows.map((r) => ({ subject: r.follower_subject })) };
+ }
if (!liveFollowers) throw new LiveFollowersError('no Live followers client (RS256 signing key and OV_LIVE_INTERNAL_URL needed)');
if (!liveFollowers) throw new LiveFollowersError('no Live followers client (RS256 signing key and OV_LIVE_INTERNAL_URL needed)');
return liveFollowers.forStream(v.streamId);
return liveFollowers.forStream(v.streamId);
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+// ADR-030 step 4: products write follows through /internal/follows (network.follows.write, service token only),
+// and with FOLLOWS_AUTHORITY=network the go-live notifications take the followers from Network's own graph
+// (no call to Live).
+// node test/follows-authority.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const { ids } = require('openvibe-contracts');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { initDb } = require('../server/db/database');
+const { NotificationService } = require('../server/notifications/notification-service');
+const { createEventsConsumer } = require('../server/notifications/events-consumer');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follow-auth-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+require('../server/identity/principals').ensureSchema(db);
+for (const c of ['live', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
+const CAROL = ids.newId('user'), DAVE = ids.newId('user'), ERIN = ids.newId('user');
+db.prepare('INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (20, ?, ?, ?, ?), (21, ?, ?, ?, ?), (22, ?, ?, ?, ?)')
+ .run('carol', 'x', CAROL, 'streamer', 'dave', 'x', DAVE, 'user', 'erin', 'x', ERIN, 'user');
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+const { signToken } = require('../server/auth/routes');
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
+const follows = require('../server/identity/follows');
+const principals = require('../server/identity/principals');
+const SECRET = 's'.repeat(40);
+const notifications = new NotificationService(db);
+const consumer = createEventsConsumer({ db, notifications, secrets: SECRET, followsAuthority: 'network', log: { log() {}, warn() {}, error() {} } });
+const app = express();
+Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+app.use(express.urlencoded({ extended: true }));
+app.use('/oauth', require('../server/auth/oauth-routes'));
+const routers = follows.routers({ requireAuth, followsGuard: principals.guard('network.follows.read', { legacy: false }) });
+app.use('/internal/follows', principals.guard('network.follows.write', { legacy: false }), routers.internal);
+app.use('/internal/events', consumer.router);
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const svc = async (client) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) })).json()).access_token;
+ const call = (method, p, { body, token } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
+ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+ try {
+ const live = await svc('live');
+ // ── Writes on a person's behalf ──
+ assert.ok([401, 403].includes((await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE } })).status), 'no token');
+ assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE }, token: await svc('tools') })).status, 403, 'without network.follows.write');
+ let r = await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE, notify_push: false }, token: live });
+ assert.deepStrictEqual([r.status, r.body.followers, r.body.following, r.body.notify_push], [201, 1, true, false]);
+ r = await call('PUT', `/internal/follows/channel/${CAROL}`, { body: { follower: ERIN }, token: live });
+ assert.strictEqual(r.body.followers, 2);
+ assert.strictEqual(db.prepare('SELECT source FROM user_follows WHERE follower_subject = ?').get(ERIN).source, 'live', 'the writing service is recorded');
+ assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPZ' }, token: live })).status, 404, 'the follower must be a Network account');
+ assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE, extra: 1 }, token: live })).status, 400);
+ r = await call('DELETE', `/internal/follows/channel/carol?follower=${ERIN}`, { token: live });
+ assert.deepStrictEqual([r.status, r.body.following, r.body.followers], [200, false, 1]);
+ await call('PUT', '/internal/follows/channel/carol', { body: { follower: ERIN }, token: live });
+ const evs = db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((x) => JSON.parse(x.envelope)).filter((e) => e.event_type.startsWith('network.follow.'));
+ assert.deepStrictEqual(evs.map((e) => e.event_type), ['network.follow.created', 'network.follow.created', 'network.follow.deleted', 'network.follow.created']);
+
+ // ── Go-live with FOLLOWS_AUTHORITY=network: Network's followers, no Live call ──
+ const started = {
+ event_id: ids.newId('event'), event_type: 'live.stream.started', version: 1, source: 'live',
+ actor: { type: 'user', id: CAROL }, subject: { type: 'stream', id: '801', revision: 1 }, visibility: 'public', priority: 'important',
+ occurred_at: new Date().toISOString(),
+ payload: { stream_id: 801, channel: { username: 'carol', display_name: 'Carol', url: 'https://openvibe.live/@carol', subject: { type: 'user', id: CAROL } }, title: 'Go', category: null, protocol: 'whip', is_nsfw: false, started_at: new Date().toISOString() },
+ };
+ const raw = JSON.stringify({ event: started, seq: 1 });
+ const res = await fetch(`${base}/internal/events`, { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) }, body: raw });
+ const body = await res.json();
+ assert.strictEqual(res.status, 200, JSON.stringify(body));
+ assert.strictEqual(body.outcome, 'notified', JSON.stringify(body));
+ const told = db.prepare("SELECT user_id FROM notifications WHERE type = 'STREAM_LIVE' AND sender_id = 20 ORDER BY user_id").all().map((x) => x.user_id);
+ assert.deepStrictEqual(told, [21, 22], 'both followers, from Network\'s graph');
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ console.log('follows authority: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -50,7 +50,7 @@ const server = http.createServer(app);
@@ -50,7 +50,7 @@ const server = http.createServer(app);
const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
assert.ok(claims.ok, claims.reason);
assert.ok(claims.ok, claims.reason);
assert.strictEqual(claims.claims.sub, 'svc:live');
assert.strictEqual(claims.claims.sub, 'svc:live');
- assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.coins.credit', 'network.coins.debit', 'network.follows.read', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);
+ assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.coins.credit', 'network.coins.debit', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);
assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
const full = t.body.access_token;
const full = t.body.access_token;