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

OP OpenVibers committed 26 Sept 2026 · 3928b03dd3 · 9 files, +146 −9


Changes

ddiff --git a/README.md b/README.md
iindex 2f3c4c88..794c6fd4 100644
---- a/README.md
++++ b/README.md
@@@ -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.
- **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`, and
nothing is dropped. `--reconcile` compares per-channel counts and pair sets.
ddiff --git a/package-lock.json b/package-lock.json
iindex 06359a9b..698d4f1f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex f8f8a7f6..0e160e2d 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/identity/follows.js b/server/identity/follows.js
iindex c72b0335..6b4a66f8 100644
---- a/server/identity/follows.js
++++ b/server/identity/follows.js
@@@ -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 };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex d583cbb9..1bbdd909 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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.
ddiff --git a/server/index.js b/server/index.js
iindex 872e02eb..2aab60b8 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.bloc
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/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
ddiff --git a/server/notifications/events-consumer.js b/server/notifications/events-consumer.js
iindex c2506864..054ed1ce 100644
---- a/server/notifications/events-consumer.js
++++ b/server/notifications/events-consumer.js
@@@ -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 = null
const 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);
},
ddiff --git a/test/follows-authority.test.js b/test/follows-authority.test.js
nnew file mode 100644
iindex 00000000..bcad4aa3
---- /dev/null
++++ b/test/follows-authority.test.js
@@@ -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); });
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 814fd248..4cb34250 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -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;