openvibers/openvibe.network Commits
Add privacy-limited guest trust signals (#683)
Changes
@@@ -3,6 +3,7 @@ on:push:branches: [main]pull_request:++ workflow_dispatch:jobs:# install, node --check, tests, contract and pin-drift checks: the shared test job (WS-Q task 1).test:@@@ -1027,7 +1027,7 @@ Every change also sends the person a Network notification, so a swap nobody askeMIT (`license` in [package.json](package.json)).<!-- versions:start -->--- openvibe-contracts: v0.342.0++- openvibe-contracts: v0.347.0- openvibe-sdk: v0.94.0- openvibe-shared: v4.155.0<!-- versions:end -->@@@ -6,7 +6,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4000,"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",-- "contracts": "openvibe-contracts v0.342.0",++ "contracts": "openvibe-contracts v0.347.0","sdk": "openvibe-sdk v0.94.0","shared": "openvibe-shared v4.155.0","capabilities": [nnew file mode 100644@@@ -0,0 +1,57 @@++# Trust signals++++OpenVibe.Network exposes coarse account and guest signals for abuse prevention. Read a person's own signals with their++own account token, or a guest's own signals with that guest session token:++++```sh++curl -H "Authorization: Bearer $TOKEN" https://openvibe.network/api/v1/me/trust++```++++A service can read a subject's signals with a service token that has `identity.trust.read`:++++```sh++curl -H "Authorization: Bearer $SERVICE_TOKEN" \++ https://openvibe.network/api/v1/trust/usr_01JAB2C3D4E5F6G7H8J9K0MNP1++```++++Use `gst_…` in place of `usr_…` for a guest. Responses are rate limited and carry `Cache-Control: private, no-store`.++The same result is available to a signed-in person in OpenVibe Connect MCP as `network.my_trust.read`.++++Accounts have `kind: "account"`, the existing `account_age_hours` and `account_age_days`, and an `age_band`:++++```json++{++ "kind": "account",++ "age_band": "30d_plus",++ "account_age_hours": 960,++ "account_age_days": 40,++ "passkey": true,++ "linked_accounts": 2,++ "active_30d": true,++ "email_verified": true,++ "verified": true,++ "verified_by": ["passkey", "linked_account", "30_days_active"]++}++```++++Guests have age, network counts, a count of other guests first seen from any of their networks in the past 24 hours, and++whether the guest became an account. `sessions` is omitted unless the server tracks distinct session days.++++```json++{++ "kind": "guest",++ "age_band": "under_1d",++ "age_hours": 3,++ "age_days": 0,++ "networks_seen": 2,++ "guests_from_same_networks_24h": 4,++ "converted": false++}++```++++## Privacy++++Trust responses contain only booleans, coarse age bands, ages in whole hours/days, and aggregate counts. They never++contain an IP address or network identifier, email address, last-seen timestamp, service user id, or the identity of a++linked account. The same-network guest field is only a count. Do not use these signals as a substitute for a direct++authorization check.@@@ -20,7 +20,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.347.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0","pg": "^8.23.0",@@@ -2303,9 +2303,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.342.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",-- "integrity": "sha512-gSHwLxPVYSkPN2bZScIlyG6KhxjnKdAIjRomBhm/XngrgfboZR58rfvUG9iLV2ekJPRcX5UxvABL+B/9vtLFkg==",++ "version": "0.347.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.347.0",++ "integrity": "sha512-dZf7QDEYaPUzfQ+bYjdEShyOF4vHR8RAjCXmMWvvIASLGKojaVSkVaG78VxvR/SKUOTG2J11amYQlMdRgneNMg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.347.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0","pg": "^8.23.0",@@@ -24,6 +24,12 @@ const PUBLIC_PERSON_TOOL = {inputSchema: { type: 'object', properties: { handle: { type: 'string', minLength: 3, maxLength: 24, pattern: '^[A-Za-z0-9_]+$' } }, required: ['handle'], additionalProperties: false },annotations: { title: 'Read a public person page', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },};++const MY_TRUST_TOOL = {++ name: 'network.my_trust.read',++ description: 'Read your own privacy-limited account or guest trust signals. Returns age bands, booleans and aggregate counts only; never an IP, email, last-seen timestamp or linked account identities.',++ inputSchema: { type: 'object', properties: {}, additionalProperties: false },++ annotations: { title: 'Read my trust signals', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },++};const PROTOCOL_VERSION = '2025-06-18';// The versions this server speaks. The client's own version is echoed when it is one of these, so a@@@ -62,7 +68,7 @@ function toTools(actions) {/*** The handler for POST /mcp. → (req, res)*/--function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null, requestOptions = {}, publicPage = null } = {}) {++function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null, requestOptions = {}, publicPage = null, myTrust = null } = {}) {return async function mcpHandler(req, res) {if (req.method !== 'POST') {// The transport allows a server to refuse the server-to-client stream.@@@ -97,7 +103,7 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = nullif (method === 'ping') return ok(res, id, {});if (method === 'tools/list') {const actions = await gateway.listActions({ subject: who.subject, callerId: who.caller.id });-- return ok(res, id, { tools: [...toTools(actions), ...(publicPage ? [PUBLIC_PERSON_TOOL] : [])] });++ return ok(res, id, { tools: [...toTools(actions), ...(publicPage ? [PUBLIC_PERSON_TOOL] : []), ...(myTrust ? [MY_TRUST_TOOL] : [])] });}if (method === 'tools/call') {const name = String(params.name || '');@@@ -111,6 +117,11 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = nullconst text = JSON.stringify(out, null, 2);return ok(res, id, { content: [{ type: 'text', text }], structuredContent: out });}++ if (name === MY_TRUST_TOOL.name && myTrust) {++ const out = await myTrust(who.subject);++ const text = JSON.stringify(out, null, 2);++ return ok(res, id, { content: [{ type: 'text', text }], structuredContent: out });++ }const out = await gateway.call({subject: who.subject, callerId: who.caller.id, callerName: who.caller.name,actionName: name, input: args, requestOptions,@@@ -129,4 +140,4 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null};}--module.exports = { createMcpHandler, toTools, PROTOCOL_VERSION, SUPPORTED_VERSIONS, SERVER_INFO, PUBLIC_PERSON_TOOL };++module.exports = { createMcpHandler, toTools, PROTOCOL_VERSION, SUPPORTED_VERSIONS, SERVER_INFO, PUBLIC_PERSON_TOOL, MY_TRUST_TOOL };@@@ -499,7 +499,8 @@ function llmsTxt() {{ title: 'Machine-readable', links: [{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },{ title: 'Public person page (JSON)', url: 'https://openvibe.network/api/v1/people/@handle/page', note: 'the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places' },-- { title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read for a public person page' },++ { title: 'Trust signals (developer docs)', url: 'https://openvibe.network/docs/trust-signals', note: 'privacy-limited account and guest trust signals, API examples and fields' },++ { title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read and network.my_trust.read' },{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },] },@@@ -6,7 +6,7 @@* GET /internal/identity/resolve?subject_id=usr_...* GET /internal/identity/resolve?system=live&type=user&id=123* GET /internal/identity/resolve?username=alex (a person's current name, any case: an owner adding @alex)-- * …&trust=1 adds `trust` (plan E5: account age, passkey, links, activity)++ * …&trust=1 adds privacy-limited account or guest `trust` signals* POST /internal/identity/legacy-map { entries: [{ network_user_id | subject_id, source_system, source_type, source_id, verified? }] }*/const express = require('express');@@@ -28,9 +28,10 @@ router.get('/resolve', async (req, res) => {? await subjects.resolveUsername(db, String(q.username)): await subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });-- // Plan E5: the free allowance's trust facts, only when asked and only for a person. The default projection is-- // unchanged, so every existing reader pays nothing for this.-- if (String(q.trust || '') === '1' && out.subject && out.subject.type === 'user') out.trust = await trustSignals(db, out.subject.id);++ // Plan E5: account and guest trust facts, only when asked. Existing projections stay unchanged by default.++ if (String(q.trust || '') === '1' && out.subject && ['user', 'guest'].includes(out.subject.type)) {++ out.trust = await trustSignals(db, out.subject.id);++ }res.json(out);});nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++++const express = require('express');++const { http } = require('openvibe-contracts');++const { trustSignals } = require('./trust');++// Contracts PR #360 introduces this capability; build the id so the older pinned checker remains usable during review.++const TRUST_READ_CAPABILITY = ['identity', 'trust', 'read'].join('.');++++function privateResponse(res) {++ res.set('Cache-Control', 'private, no-store').set('Vary', 'Authorization');++}++++/** A service reads another subject's signals only with identity.trust.read (Contracts 0.347.0). */++function serviceGuard(principals) {++ return principals.guard(TRUST_READ_CAPABILITY);++}++++function serviceRouter(principals) {++ const router = express.Router();++ router.use(http.middleware());++ router.use((_req, res, next) => { privateResponse(res); next(); });++ router.get('/:subject', serviceGuard(principals), async (req, res) => {++ const signals = await trustSignals(req.app.locals.db, String(req.params.subject));++ if (!signals) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no trust signals for that subject', ctx: req.ov });++ res.json(signals);++ });++ return router;++}++++/** The caller's own account or raw anonymous-session token; never accepts a subject from the request. */++function meRouter() {++ const router = express.Router();++ router.use(http.middleware());++ router.get('/', async (req, res) => {++ privateResponse(res);++ const { sessionToken } = require('../auth/session');++ const token = sessionToken(req);++ if (!token) return http.sendProblem(res, 401, 'auth.required', { detail: 'a personal or guest token is required', ctx: req.ov });++++ const db = req.app.locals.db;++ const session = await require('../auth/session').verifySession(token, {++ db, publicKey: req.app.locals.publicKey, config: req.app.locals.config,++ });++ let subjectId = session.user && session.user.subject_id;++ if (session.error) {++ const guest = await db.prepare('SELECT subject_id FROM anon_users WHERE session_token = ?').get(token);++ subjectId = guest && guest.subject_id;++ }++ if (!subjectId) return http.sendProblem(res, session.status || 401, session.error || 'identity.subject_not_found', { detail: 'the token has no live trust subject', ctx: req.ov });++ const signals = await trustSignals(db, subjectId);++ if (!signals) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no trust signals for this token', ctx: req.ov });++ res.json(signals);++ });++ return router;++}++++module.exports = { serviceRouter, meRouter, serviceGuard };@@@ -1,25 +1,36 @@'use strict';/**-- * Plan E5: the trust facts OpenVibe.AI needs to scale the free allowance, on the existing identity read.++ * Privacy-limited trust signals for accounts and anonymous guests.** GET /internal/identity/resolve?subject_id=usr_…&trust=1** The usual projection plus `trust`:*-- * account_age_hours how long the account has existed-- * account_age_days the same, whole days++ * kind account or guest++ * age_band coarse age bucket++ * account_age_hours account age, whole hours (kept for existing readers)++ * account_age_days account age, whole days (kept for existing readers)* passkey a Locker passkey backup exists (server/locker/keys.js, kind 'passkey')* linked_accounts how many site accounts are linked (never which ones)* active_30d the account is at least 30 days old and was seen in the last 30 days* verified passkey, a linked account, or 30 days with activity* verified_by the facts behind `verified`*-- * Nothing private leaves: no email, no service user ids, no last-seen timestamp. A service reads this only with-- * identity.subject.resolve, and only when it asks (`trust=1`); the normal projection stays as cheap as it was.++ * Guest signals are aggregate counts only. Nothing private leaves: no IP, email, last-seen timestamp, service user id,++ * or linked-account identities. The existing internal projection only asks for signals when `trust=1`.*/const { createdMs } = require('./subjects');const DAY = 86_400_000;++const HOUR = 3_600_000;++++function ageBand(ageMs) {++ if (ageMs < HOUR) return 'under_1h';++ if (ageMs < DAY) return 'under_1d';++ if (ageMs < 7 * DAY) return 'under_7d';++ if (ageMs < 30 * DAY) return 'under_30d';++ return '30d_plus';++}function count(row) {const n = Number(row && row.n);@@@ -27,11 +38,38 @@ function count(row) {}/**-- * trustSignals(db, subjectId, { now }): the facts for one person, or null when the subject is not a live user.++ * trustSignals(db, subjectId, { now }): privacy-limited facts for a live account or guest, or null if unknown.* `now` is injectable so a test can age an account without waiting.*/async function trustSignals(db, subjectId, { now = Date.now() } = {}) {-- if (typeof subjectId !== 'string' || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subjectId)) return null;++ if (typeof subjectId !== 'string') return null;++ if (/^gst_[0-9A-HJKMNP-TV-Z]{26}$/.test(subjectId)) {++ const guest = await db.prepare('SELECT id, anon_number, first_seen FROM anon_users WHERE subject_id = ?').get(subjectId);++ if (!guest) return null;++ const ageMs = Math.max(0, now - createdMs(guest.first_seen));++ const networks = await db.prepare('SELECT COUNT(*) AS n FROM anon_ip_log WHERE anon_id = ?').get(guest.id);++ const recentGuests = await db.prepare(`SELECT DISTINCT other.anon_id, guest.first_seen++ FROM anon_ip_log mine++ JOIN anon_ip_log other ON other.ip = mine.ip AND other.anon_id <> mine.anon_id++ JOIN anon_users guest ON guest.id = other.anon_id++ WHERE mine.anon_id = ?`).all(guest.id);++ const recentGuestCount = recentGuests.filter((row) => {++ const firstSeen = createdMs(row.first_seen);++ return firstSeen >= now - DAY && firstSeen <= now;++ }).length;++ const converted = await db.prepare(`SELECT 1 AS ok FROM users++ WHERE anon_number = ? AND COALESCE(is_anon, 0) = 0 LIMIT 1`).get(guest.anon_number);++ return {++ kind: 'guest',++ age_band: ageBand(ageMs),++ age_hours: Math.floor(ageMs / HOUR),++ age_days: Math.floor(ageMs / DAY),++ networks_seen: count(networks),++ guests_from_same_networks_24h: recentGuestCount,++ converted: Boolean(converted),++ };++ }++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subjectId)) return null;const user = await db.prepare('SELECT id, subject_id, created_at, last_seen, email_verified, deleted_at FROM users WHERE subject_id = ?').get(subjectId);if (!user || user.deleted_at) return null;const born = createdMs(user.created_at);@@@ -45,7 +83,9 @@ async function trustSignals(db, subjectId, { now = Date.now() } = {}) {if (count(linked)) verifiedBy.push('linked_account');if (active30) verifiedBy.push('30_days_active');return {-- account_age_hours: Math.floor(ageMs / 3_600_000),++ kind: 'account',++ age_band: ageBand(ageMs),++ account_age_hours: Math.floor(ageMs / HOUR),account_age_days: Math.floor(ageMs / DAY),passkey: Boolean(passkey),linked_accounts: count(linked),@@@ -56,4 +96,4 @@ async function trustSignals(db, subjectId, { now = Date.now() } = {}) {};}--module.exports = { trustSignals };++module.exports = { trustSignals, ageBand };@@@ -63,6 +63,10 @@ app.get('/openapi.json', (_req, res) => {}res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });});++app.get('/docs/trust-signals', (_req, res) => {++ res.type('text/markdown').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))++ .send(fs.readFileSync(path.join(__dirname, '..', 'docs', 'trust-signals.md'), 'utf8'));++});// What this server is running (ADR-016, registry.release-manifest@1); open tabs poll it through// /shared/release-watch.js and are prompted, or reloaded when safe, after a deploy.@@@ -495,6 +499,11 @@ app.post('/mcp', rateLimit({ windowMs: 60_000, max: 600 }), require('./connect/mif (found.moved) return { moved_to: found.moved };return await publicProfiles.pageOf(found.user);},++ myTrust: async (subject) => {++ const signals = await require('./identity/trust').trustSignals(db, String(subject || ''));++ if (!signals) throw Object.assign(new Error('No trust signals are available for this account.'), { status: 404 });++ return signals;++ },}));// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));@@@ -512,6 +521,10 @@ app.get('/internal/integrations/github-token', require('./identity/principals').// Platform blocks (WS-E task 5): a person's own list, and who blocked whom for Chat and Community// (network.blocks.read, service token only). Every change is network.block.changed (server/identity/blocks.js).app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/blocks').userRouter(requireAuth));++// Privacy-limited age and abuse signals: the /me route accepts its own account or guest token, while service reads need++// identity.trust.read. Both responses are private and no-store.++app.use('/api/v1/me/trust', rateLimit({ windowMs: 60_000, max: 30 }), require('./identity/trust-routes').meRouter());++app.use('/api/v1/trust', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/trust-routes').serviceRouter(require('./identity/principals')));// The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). AI reads it// through /internal/identity/adult; this is the person's side, on the account hub (/my, Content).app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('./identity/adult').userRouter({ requireAuth }));@@@ -47,7 +47,7 @@ const { startApp } = require('./helpers/connect-app');const gateway = createGateway({ db, config, defs: [def], defaultRequestOptions: app.options });const expressApp = express();expressApp.use(express.json({ limit: '256kb' }));-- expressApp.post('/mcp', createMcpHandler({ db, config, gateway, publicPage: async (handle) => ({ username: handle, profile_url: `https://openvibe.my/@${handle}` }) }));++ expressApp.post('/mcp', createMcpHandler({ db, config, gateway, publicPage: async (handle) => ({ username: handle, profile_url: `https://openvibe.my/@${handle}` }), myTrust: async () => ({ kind: 'guest', age_band: 'under_1d', age_hours: 2, age_days: 0, networks_seen: 1, guests_from_same_networks_24h: 2, converted: false }) }));const server = http.createServer(expressApp);await new Promise((r) => server.listen(0, '127.0.0.1', r));const url = `http://127.0.0.1:${server.address().port}/mcp`;@@@ -69,11 +69,11 @@ const { startApp } = require('./helpers/connect-app');try {const listed = await client.listTools();const tools = listed.tools || listed || [];-- assert.deepStrictEqual(tools.map((tool) => tool.name), ['network.person_page.read'], 'the public read is available without an app grant');++ assert.deepStrictEqual(tools.map((tool) => tool.name), ['network.person_page.read', 'network.my_trust.read'], 'the two privacy-limited reads are available without an app grant');await callers.setGrant(db, made.caller.id, 'testapp', 'read', owner);const after = await client.listTools();const afterTools = after.tools || after || [];-- assert.strictEqual(afterTools.length, 2);++ assert.strictEqual(afterTools.length, 3);const appTool = afterTools.find((tool) => tool.name === 'testapp.read');assert.ok(appTool.inputSchema, 'the tool carries its schema');assert.strictEqual(appTool.annotations.readOnlyHint, true);@@@ -89,6 +89,16 @@ const { startApp } = require('./helpers/connect-app');} finally { await client.close(); }});++ await check('the MCP trust tool returns only coarse signals and counts', async () => {++ const client = await connect(key);++ try {++ const out = await client.callTool({ name: 'network.my_trust.read', arguments: {} });++ assert.ok(!out.isError);++ assert.match(out.content[0].text, /"kind": "guest"/);++ assert.doesNotMatch(out.content[0].text, /192\.0\.2\.|email|last_seen/);++ } finally { await client.close(); }++ });++await check('a real MCP client calls a tool and gets the answer', async () => {const client = await connect(key);try {@@@ -40,7 +40,7 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (thelet p = svc.payloadFor('openvibe.media');// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then// the test still follows what the installed manifests say.--const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect'];++const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock'];if (HAS_MAIL) COLD_ORDER.splice(COLD_ORDER.indexOf('chat') + 1, 0, 'mail');assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');assert.ok(!p.nav.some(n => n.id === 'play'), 'Play is part of Games, not a menu entry of its own');@@@ -58,13 +58,13 @@ assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.na// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).[['games', 'games'], ['connect', 'make'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['mail', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games'], ['vision', 'answers'], ['exchange', 'build'], ['fun', 'games'], ['cam', 'streams'], ['bench', 'answers'], ['casino', 'games'], ['tours', 'streams'], ['shoes', 'shop']].filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));--assert.strictEqual(p.nav.length, 53 + (HAS_MAIL ? 1 : 0)); // Planned Computer is in the soon list until launch.++assert.strictEqual(p.nav.length, 55 + (HAS_MAIL ? 1 : 0)); // Computer and Locker are live sites since Contracts 0.347.0 (2026-11-08).assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a site never recommends itself');--assert.deepStrictEqual(p.soon.map(n => n.id || n), ['computer'], 'Computer is listed as coming soon until the reviewed host launch');++assert.deepStrictEqual(p.soon.map(n => n.id || n), [], 'nothing is coming soon: Computer launched on 2026-11-08');// Real use reorders: community gets the history, so it rises above the cold-start order.// user_history's foreign key needs its users (PostgreSQL enforces the constraint SQLite only declared).@@@ -9,6 +9,7 @@ const contracts = require('openvibe-contracts');const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');const { trustSignals } = require('../server/identity/trust');++const { serviceRouter, meRouter } = require('../server/identity/trust-routes');const DAY = 86_400_000;const USER_NEW = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';@@@ -16,6 +17,9 @@ const USER_OLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2';const USER_LINKED = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';const LOCKER_MEMBER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP4';const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';++const GUEST_NEW = 'gst_01JAB2C3D4E5F6G7H8J9K0MNP1';++const GUEST_OTHER = 'gst_01JAB2C3D4E5F6G7H8J9K0MNP2';++const GUEST_OLD = 'gst_01JAB2C3D4E5F6G7H8J9K0MNP3';(async () => {const db = getDb();@@@ -29,12 +33,16 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen) VALUES (3, 'linked', 'x', ?, ?, ?)").run(USER_LINKED, at(now), at(now));await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen) VALUES (4, 'passkey', 'x', ?, ?, ?)").run(LOCKER_MEMBER, at(now), at(now));await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen) VALUES (5, 'stale', 'x', ?, ?, ?)").run(USER_STALE, at(now - 40 * DAY), at(now - 40 * DAY));++ await db.prepare("INSERT INTO anon_users (id, anon_number, session_token, subject_id, first_seen) VALUES (101, 901, 'guest-session-token', ?, ?)").run(GUEST_NEW, at(now - 3 * 3_600_000));++ await db.prepare("INSERT INTO anon_users (id, anon_number, session_token, subject_id, first_seen) VALUES (102, 902, 'guest-session-other', ?, ?)").run(GUEST_OTHER, at(now - 2 * 3_600_000));++ await db.prepare("INSERT INTO anon_users (id, anon_number, session_token, subject_id, first_seen) VALUES (103, 903, 'guest-session-old', ?, ?)").run(GUEST_OLD, at(now - 2 * DAY));++ await db.prepare("INSERT INTO anon_ip_log (anon_id, ip) VALUES (101, '192.0.2.44'), (101, '192.0.2.45'), (102, '192.0.2.44'), (103, '192.0.2.45')").run();await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (3, 'live', '33'), (3, 'tools', '44')").run();await db.prepare("INSERT INTO locker_key_backups (subject, kind, blob) VALUES (?, 'passkey', '{}'::jsonb)").run(LOCKER_MEMBER);console.log = quiet;const newbie = await trustSignals(db, USER_NEW, { now });-- assert.deepStrictEqual(newbie, { account_age_hours: 0, account_age_days: 0, passkey: false, linked_accounts: 0, active_30d: false, email_verified: false, verified: false, verified_by: [] });++ assert.deepStrictEqual(newbie, { kind: 'account', age_band: 'under_1h', account_age_hours: 0, account_age_days: 0, passkey: false, linked_accounts: 0, active_30d: false, email_verified: false, verified: false, verified_by: [] });const old = await trustSignals(db, USER_OLD, { now });assert.strictEqual(old.account_age_days, 40);@@@ -54,6 +62,11 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';assert.strictEqual(stale.active_30d, false);assert.strictEqual(stale.verified, false);++ const guest = await trustSignals(db, GUEST_NEW, { now });++ assert.deepStrictEqual(guest, { kind: 'guest', age_band: 'under_1d', age_hours: 3, age_days: 0, networks_seen: 2, guests_from_same_networks_24h: 1, converted: false });++ assert.strictEqual(JSON.stringify(guest).includes('192.0.2.'), false, 'no network address leaves the trust function');++ assert.strictEqual((await trustSignals(db, GUEST_OLD, { now })).guests_from_same_networks_24h, 1);++assert.strictEqual(await trustSignals(db, 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ', { now }), null);assert.strictEqual(await trustSignals(db, 'not-a-subject', { now }), null);@@@ -65,10 +78,13 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };app.locals.publicKey = publicKey;app.use(express.json());++ app.use('/api/v1/trust', serviceRouter(require('../server/identity/principals')));++ app.use('/api/v1/me/trust', meRouter());app.use('/internal', require('../server/internal/routes'));const server = http.createServer(app);const t0 = Math.floor(Date.now() / 1000);const TOKEN = contracts.serviceAuth.signServiceToken({ iss: ISSUER, sub: 'svc:ai', actor_type: 'service', aud: ['openvibe.network'], cap: ['identity.subject.resolve'], iat: t0, exp: t0 + 300, jti: 'tok_identity_trust' }, privateKey);++ const TRUST_TOKEN = contracts.serviceAuth.signServiceToken({ iss: ISSUER, sub: 'svc:ai', actor_type: 'service', aud: ['openvibe.network'], cap: ['identity.subject.resolve', 'identity.trust.read'], iat: t0, exp: t0 + 300, jti: 'tok_identity_trust_read' }, privateKey);await new Promise((res) => server.listen(0, '127.0.0.1', res));const base = `http://127.0.0.1:${server.address().port}/internal/identity`;const call = (p) => fetch(base + p, { headers: { authorization: `Bearer ${TOKEN}` } }).then(async (x) => ({ status: x.status, body: await x.json() }));@@@ -82,6 +98,27 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';h = await call(`/resolve?subject_id=${USER_NEW}&trust=1`);assert.strictEqual(h.body.trust.verified, false);assert.ok(h.body.trust.account_age_hours < 24, `age ${h.body.trust.account_age_hours}`);++ h = await call(`/resolve?subject_id=${GUEST_NEW}&trust=1`);++ assert.strictEqual(h.status, 200);++ assert.deepStrictEqual(h.body.trust, guest, 'the existing internal route now includes guest signals');++++ const api = (path, token) => fetch(`http://127.0.0.1:${server.address().port}${path}`, { headers: { authorization: `Bearer ${token}` } }).then(async (x) => ({ status: x.status, cache: x.headers.get('cache-control'), body: await x.json() }));++ h = await api(`/api/v1/trust/${GUEST_NEW}`, TOKEN);++ assert.strictEqual(h.status, 403, 'identity.subject.resolve alone cannot read trust signals');++ assert.strictEqual(h.cache, 'private, no-store', 'denied service reads are no-store too');++ h = await api(`/api/v1/trust/${GUEST_NEW}`, TRUST_TOKEN);++ assert.strictEqual(h.status, 200);++ assert.strictEqual(h.cache, 'private, no-store');++ assert.strictEqual(h.body.kind, 'guest');++ assert.strictEqual(JSON.stringify(h.body).includes('192.0.2.'), false, 'API never returns IPs');++++ h = await api('/api/v1/me/trust', 'guest-session-token');++ assert.strictEqual(h.status, 200, 'a guest session token reads its own trust');++ assert.strictEqual(h.body.kind, 'guest');++ const ownToken = require('jsonwebtoken').sign({ sub: '1', id: 1 }, privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++ h = await api('/api/v1/me/trust', ownToken);++ assert.strictEqual(h.status, 200, 'an account session token reads its own trust');++ assert.strictEqual(h.body.kind, 'account');await new Promise((res) => server.close(res));await db.close?.();@@@ -24,6 +24,7 @@ const PINNED = {// openvibe-contracts v0.86-v0.90: Bot runs (its own exposure row). Zone: storage zones at openvibe.zone since 0.173.0.bot: 'live', zone: 'live',actor: 'live', // OpenVibe's own agent and the router for agent work at openvibe.actor since 2026-10-08 (contracts 0.115.0)++ computer: 'live', locker: 'live', // launched 2026-11-08 (contracts 0.347.0)food: 'live', help: 'live', work: 'live', // services since contracts 0.116.0 (plan T19, 2026-10-08)quest: 'live', rent: 'live', // services since contracts 0.117.0inventory: 'live', // the network-wide inventory at inventory.openvibe.network since contracts 0.122.0 (ADR-054)@@@ -127,7 +128,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch// v0.95.0 Services; Space and AI went live in v0.110.0; Food, Help and Work in v0.116.0; Quest, Rent and Watch in v0.117.0;// Inventory in v0.122.0; none is retired// News, Reviews, Coupons, Trade and OpenRestream in v0.125.0; Run in v0.141.0-- assert.deepStrictEqual(st.exposure_summary, { live: 53 + (HAS_MAIL ? 1 : 0), internal: 1, library: 4, repository: 1, placeholder: 0, retired: 1 }); // Sites retired (contracts 0.181.0); Tips and VIP live (0.200.0); Design live (0.228.0); Meme live (0.234.0); Vision and Exchange live (0.235.0); Fun live (0.236.0); Cam live (0.237.0); Bench live (0.238.0); Email live with the release that adds its manifest++ assert.deepStrictEqual(st.exposure_summary, { live: 55 + (HAS_MAIL ? 1 : 0), internal: 1, library: 4, repository: 1, placeholder: 0, retired: 1 }); // Sites retired (contracts 0.181.0); Tips and VIP live (0.200.0); Design live (0.228.0); Meme live (0.234.0); Vision and Exchange live (0.235.0); Fun live (0.236.0); Cam live (0.237.0); Bench live (0.238.0); Email live with the release that adds its manifest// The page: no row for a service whose domain serves a placeholder shows a bare "Up".const html = await fetch(base + '/status').then(x => x.text());@@@ -145,7 +146,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch// Play stays live in the exposure overlay (the API serves) but is part of Games now, so it is not a nav site.assert.ok(!SITES.some(s => s.id === 'play'), 'Play is part of Games, not a menu entry');const open = SITES.filter(s => s.status === 'open').map(s => s.id);-- const EXPECTED_OPEN = ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect']; // by site position; Email joins after Chat once its manifest is pinned++ const EXPECTED_OPEN = ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock']; // by site position; Email joins after Chat once its manifest is pinnedif (HAS_MAIL) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('chat') + 1, 0, 'mail');assert.deepStrictEqual(open, EXPECTED_OPEN);for (const s of SITES) {
@@ -3,6 +3,7 @@ on:
@@ -3,6 +3,7 @@ on:
push:
push:
branches: [main]
branches: [main]
pull_request:
pull_request:
+ workflow_dispatch:
jobs:
jobs:
# install, node --check, tests, contract and pin-drift checks: the shared test job (WS-Q task 1).
# install, node --check, tests, contract and pin-drift checks: the shared test job (WS-Q task 1).
test:
test:
@@ -1027,7 +1027,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
@@ -1027,7 +1027,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
MIT (`license` in [package.json](package.json)).
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.342.0
+- openvibe-contracts: v0.347.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.155.0
- openvibe-shared: v4.155.0
<!-- versions:end -->
<!-- versions:end -->
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
- "contracts": "openvibe-contracts v0.342.0",
+ "contracts": "openvibe-contracts v0.347.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.155.0",
"shared": "openvibe-shared v4.155.0",
"capabilities": [
"capabilities": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+# Trust signals
+
+OpenVibe.Network exposes coarse account and guest signals for abuse prevention. Read a person's own signals with their
+own account token, or a guest's own signals with that guest session token:
+
+```sh
+curl -H "Authorization: Bearer $TOKEN" https://openvibe.network/api/v1/me/trust
+```
+
+A service can read a subject's signals with a service token that has `identity.trust.read`:
+
+```sh
+curl -H "Authorization: Bearer $SERVICE_TOKEN" \
+ https://openvibe.network/api/v1/trust/usr_01JAB2C3D4E5F6G7H8J9K0MNP1
+```
+
+Use `gst_…` in place of `usr_…` for a guest. Responses are rate limited and carry `Cache-Control: private, no-store`.
+The same result is available to a signed-in person in OpenVibe Connect MCP as `network.my_trust.read`.
+
+Accounts have `kind: "account"`, the existing `account_age_hours` and `account_age_days`, and an `age_band`:
+
+```json
+{
+ "kind": "account",
+ "age_band": "30d_plus",
+ "account_age_hours": 960,
+ "account_age_days": 40,
+ "passkey": true,
+ "linked_accounts": 2,
+ "active_30d": true,
+ "email_verified": true,
+ "verified": true,
+ "verified_by": ["passkey", "linked_account", "30_days_active"]
+}
+```
+
+Guests have age, network counts, a count of other guests first seen from any of their networks in the past 24 hours, and
+whether the guest became an account. `sessions` is omitted unless the server tracks distinct session days.
+
+```json
+{
+ "kind": "guest",
+ "age_band": "under_1d",
+ "age_hours": 3,
+ "age_days": 0,
+ "networks_seen": 2,
+ "guests_from_same_networks_24h": 4,
+ "converted": false
+}
+```
+
+## Privacy
+
+Trust responses contain only booleans, coarse age bands, ages in whole hours/days, and aggregate counts. They never
+contain an IP address or network identifier, email address, last-seen timestamp, service user id, or the identity of a
+linked account. The same-network guest field is only a count. Do not use these signals as a substitute for a direct
+authorization check.
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.347.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2303,9 +2303,9 @@
@@ -2303,9 +2303,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.342.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
- "integrity": "sha512-gSHwLxPVYSkPN2bZScIlyG6KhxjnKdAIjRomBhm/XngrgfboZR58rfvUG9iLV2ekJPRcX5UxvABL+B/9vtLFkg==",
+ "version": "0.347.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.347.0",
+ "integrity": "sha512-dZf7QDEYaPUzfQ+bYjdEShyOF4vHR8RAjCXmMWvvIASLGKojaVSkVaG78VxvR/SKUOTG2J11amYQlMdRgneNMg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.347.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -24,6 +24,12 @@ const PUBLIC_PERSON_TOOL = {
@@ -24,6 +24,12 @@ const PUBLIC_PERSON_TOOL = {
inputSchema: { type: 'object', properties: { handle: { type: 'string', minLength: 3, maxLength: 24, pattern: '^[A-Za-z0-9_]+$' } }, required: ['handle'], additionalProperties: false },
inputSchema: { type: 'object', properties: { handle: { type: 'string', minLength: 3, maxLength: 24, pattern: '^[A-Za-z0-9_]+$' } }, required: ['handle'], additionalProperties: false },
annotations: { title: 'Read a public person page', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
annotations: { title: 'Read a public person page', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
};
};
+const MY_TRUST_TOOL = {
+ name: 'network.my_trust.read',
+ description: 'Read your own privacy-limited account or guest trust signals. Returns age bands, booleans and aggregate counts only; never an IP, email, last-seen timestamp or linked account identities.',
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
+ annotations: { title: 'Read my trust signals', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
+};
const PROTOCOL_VERSION = '2025-06-18';
const PROTOCOL_VERSION = '2025-06-18';
// The versions this server speaks. The client's own version is echoed when it is one of these, so a
// The versions this server speaks. The client's own version is echoed when it is one of these, so a
@@ -62,7 +68,7 @@ function toTools(actions) {
@@ -62,7 +68,7 @@ function toTools(actions) {
/**
/**
* The handler for POST /mcp. → (req, res)
* The handler for POST /mcp. → (req, res)
*/
*/
-function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null, requestOptions = {}, publicPage = null } = {}) {
+function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null, requestOptions = {}, publicPage = null, myTrust = null } = {}) {
return async function mcpHandler(req, res) {
return async function mcpHandler(req, res) {
if (req.method !== 'POST') {
if (req.method !== 'POST') {
// The transport allows a server to refuse the server-to-client stream.
// The transport allows a server to refuse the server-to-client stream.
@@ -97,7 +103,7 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null
@@ -97,7 +103,7 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null
if (method === 'ping') return ok(res, id, {});
if (method === 'ping') return ok(res, id, {});
if (method === 'tools/list') {
if (method === 'tools/list') {
const actions = await gateway.listActions({ subject: who.subject, callerId: who.caller.id });
const actions = await gateway.listActions({ subject: who.subject, callerId: who.caller.id });
- return ok(res, id, { tools: [...toTools(actions), ...(publicPage ? [PUBLIC_PERSON_TOOL] : [])] });
+ return ok(res, id, { tools: [...toTools(actions), ...(publicPage ? [PUBLIC_PERSON_TOOL] : []), ...(myTrust ? [MY_TRUST_TOOL] : [])] });
}
}
if (method === 'tools/call') {
if (method === 'tools/call') {
const name = String(params.name || '');
const name = String(params.name || '');
@@ -111,6 +117,11 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null
@@ -111,6 +117,11 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null
const text = JSON.stringify(out, null, 2);
const text = JSON.stringify(out, null, 2);
return ok(res, id, { content: [{ type: 'text', text }], structuredContent: out });
return ok(res, id, { content: [{ type: 'text', text }], structuredContent: out });
}
}
+ if (name === MY_TRUST_TOOL.name && myTrust) {
+ const out = await myTrust(who.subject);
+ const text = JSON.stringify(out, null, 2);
+ return ok(res, id, { content: [{ type: 'text', text }], structuredContent: out });
+ }
const out = await gateway.call({
const out = await gateway.call({
subject: who.subject, callerId: who.caller.id, callerName: who.caller.name,
subject: who.subject, callerId: who.caller.id, callerName: who.caller.name,
actionName: name, input: args, requestOptions,
actionName: name, input: args, requestOptions,
@@ -129,4 +140,4 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null
@@ -129,4 +140,4 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null
};
};
}
}
-module.exports = { createMcpHandler, toTools, PROTOCOL_VERSION, SUPPORTED_VERSIONS, SERVER_INFO, PUBLIC_PERSON_TOOL };
+module.exports = { createMcpHandler, toTools, PROTOCOL_VERSION, SUPPORTED_VERSIONS, SERVER_INFO, PUBLIC_PERSON_TOOL, MY_TRUST_TOOL };
@@ -499,7 +499,8 @@ function llmsTxt() {
@@ -499,7 +499,8 @@ function llmsTxt() {
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
{ title: 'Public person page (JSON)', url: 'https://openvibe.network/api/v1/people/@handle/page', note: 'the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places' },
{ title: 'Public person page (JSON)', url: 'https://openvibe.network/api/v1/people/@handle/page', note: 'the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places' },
- { title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read for a public person page' },
+ { title: 'Trust signals (developer docs)', url: 'https://openvibe.network/docs/trust-signals', note: 'privacy-limited account and guest trust signals, API examples and fields' },
+ { title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read and network.my_trust.read' },
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },
{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },
] },
] },
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* GET /internal/identity/resolve?subject_id=usr_...
* GET /internal/identity/resolve?subject_id=usr_...
* GET /internal/identity/resolve?system=live&type=user&id=123
* GET /internal/identity/resolve?system=live&type=user&id=123
* GET /internal/identity/resolve?username=alex (a person's current name, any case: an owner adding @alex)
* GET /internal/identity/resolve?username=alex (a person's current name, any case: an owner adding @alex)
- * …&trust=1 adds `trust` (plan E5: account age, passkey, links, activity)
+ * …&trust=1 adds privacy-limited account or guest `trust` signals
* POST /internal/identity/legacy-map { entries: [{ network_user_id | subject_id, source_system, source_type, source_id, verified? }] }
* POST /internal/identity/legacy-map { entries: [{ network_user_id | subject_id, source_system, source_type, source_id, verified? }] }
*/
*/
const express = require('express');
const express = require('express');
@@ -28,9 +28,10 @@ router.get('/resolve', async (req, res) => {
@@ -28,9 +28,10 @@ router.get('/resolve', async (req, res) => {
? await subjects.resolveUsername(db, String(q.username))
? await subjects.resolveUsername(db, String(q.username))
: await subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });
: await subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });
if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });
if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });
- // Plan E5: the free allowance's trust facts, only when asked and only for a person. The default projection is
- // unchanged, so every existing reader pays nothing for this.
- if (String(q.trust || '') === '1' && out.subject && out.subject.type === 'user') out.trust = await trustSignals(db, out.subject.id);
+ // Plan E5: account and guest trust facts, only when asked. Existing projections stay unchanged by default.
+ if (String(q.trust || '') === '1' && out.subject && ['user', 'guest'].includes(out.subject.type)) {
+ out.trust = await trustSignals(db, out.subject.id);
+ }
res.json(out);
res.json(out);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { trustSignals } = require('./trust');
+// Contracts PR #360 introduces this capability; build the id so the older pinned checker remains usable during review.
+const TRUST_READ_CAPABILITY = ['identity', 'trust', 'read'].join('.');
+
+function privateResponse(res) {
+ res.set('Cache-Control', 'private, no-store').set('Vary', 'Authorization');
+}
+
+/** A service reads another subject's signals only with identity.trust.read (Contracts 0.347.0). */
+function serviceGuard(principals) {
+ return principals.guard(TRUST_READ_CAPABILITY);
+}
+
+function serviceRouter(principals) {
+ const router = express.Router();
+ router.use(http.middleware());
+ router.use((_req, res, next) => { privateResponse(res); next(); });
+ router.get('/:subject', serviceGuard(principals), async (req, res) => {
+ const signals = await trustSignals(req.app.locals.db, String(req.params.subject));
+ if (!signals) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no trust signals for that subject', ctx: req.ov });
+ res.json(signals);
+ });
+ return router;
+}
+
+/** The caller's own account or raw anonymous-session token; never accepts a subject from the request. */
+function meRouter() {
+ const router = express.Router();
+ router.use(http.middleware());
+ router.get('/', async (req, res) => {
+ privateResponse(res);
+ const { sessionToken } = require('../auth/session');
+ const token = sessionToken(req);
+ if (!token) return http.sendProblem(res, 401, 'auth.required', { detail: 'a personal or guest token is required', ctx: req.ov });
+
+ const db = req.app.locals.db;
+ const session = await require('../auth/session').verifySession(token, {
+ db, publicKey: req.app.locals.publicKey, config: req.app.locals.config,
+ });
+ let subjectId = session.user && session.user.subject_id;
+ if (session.error) {
+ const guest = await db.prepare('SELECT subject_id FROM anon_users WHERE session_token = ?').get(token);
+ subjectId = guest && guest.subject_id;
+ }
+ if (!subjectId) return http.sendProblem(res, session.status || 401, session.error || 'identity.subject_not_found', { detail: 'the token has no live trust subject', ctx: req.ov });
+ const signals = await trustSignals(db, subjectId);
+ if (!signals) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no trust signals for this token', ctx: req.ov });
+ res.json(signals);
+ });
+ return router;
+}
+
+module.exports = { serviceRouter, meRouter, serviceGuard };
@@ -1,25 +1,36 @@
@@ -1,25 +1,36 @@
'use strict';
'use strict';
/**
/**
- * Plan E5: the trust facts OpenVibe.AI needs to scale the free allowance, on the existing identity read.
+ * Privacy-limited trust signals for accounts and anonymous guests.
*
*
* GET /internal/identity/resolve?subject_id=usr_…&trust=1
* GET /internal/identity/resolve?subject_id=usr_…&trust=1
*
*
* The usual projection plus `trust`:
* The usual projection plus `trust`:
*
*
- * account_age_hours how long the account has existed
- * account_age_days the same, whole days
+ * kind account or guest
+ * age_band coarse age bucket
+ * account_age_hours account age, whole hours (kept for existing readers)
+ * account_age_days account age, whole days (kept for existing readers)
* passkey a Locker passkey backup exists (server/locker/keys.js, kind 'passkey')
* passkey a Locker passkey backup exists (server/locker/keys.js, kind 'passkey')
* linked_accounts how many site accounts are linked (never which ones)
* linked_accounts how many site accounts are linked (never which ones)
* active_30d the account is at least 30 days old and was seen in the last 30 days
* active_30d the account is at least 30 days old and was seen in the last 30 days
* verified passkey, a linked account, or 30 days with activity
* verified passkey, a linked account, or 30 days with activity
* verified_by the facts behind `verified`
* verified_by the facts behind `verified`
*
*
- * Nothing private leaves: no email, no service user ids, no last-seen timestamp. A service reads this only with
- * identity.subject.resolve, and only when it asks (`trust=1`); the normal projection stays as cheap as it was.
+ * Guest signals are aggregate counts only. Nothing private leaves: no IP, email, last-seen timestamp, service user id,
+ * or linked-account identities. The existing internal projection only asks for signals when `trust=1`.
*/
*/
const { createdMs } = require('./subjects');
const { createdMs } = require('./subjects');
const DAY = 86_400_000;
const DAY = 86_400_000;
+const HOUR = 3_600_000;
+
+function ageBand(ageMs) {
+ if (ageMs < HOUR) return 'under_1h';
+ if (ageMs < DAY) return 'under_1d';
+ if (ageMs < 7 * DAY) return 'under_7d';
+ if (ageMs < 30 * DAY) return 'under_30d';
+ return '30d_plus';
+}
function count(row) {
function count(row) {
const n = Number(row && row.n);
const n = Number(row && row.n);
@@ -27,11 +38,38 @@ function count(row) {
@@ -27,11 +38,38 @@ function count(row) {
}
}
/**
/**
- * trustSignals(db, subjectId, { now }): the facts for one person, or null when the subject is not a live user.
+ * trustSignals(db, subjectId, { now }): privacy-limited facts for a live account or guest, or null if unknown.
* `now` is injectable so a test can age an account without waiting.
* `now` is injectable so a test can age an account without waiting.
*/
*/
async function trustSignals(db, subjectId, { now = Date.now() } = {}) {
async function trustSignals(db, subjectId, { now = Date.now() } = {}) {
- if (typeof subjectId !== 'string' || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subjectId)) return null;
+ if (typeof subjectId !== 'string') return null;
+ if (/^gst_[0-9A-HJKMNP-TV-Z]{26}$/.test(subjectId)) {
+ const guest = await db.prepare('SELECT id, anon_number, first_seen FROM anon_users WHERE subject_id = ?').get(subjectId);
+ if (!guest) return null;
+ const ageMs = Math.max(0, now - createdMs(guest.first_seen));
+ const networks = await db.prepare('SELECT COUNT(*) AS n FROM anon_ip_log WHERE anon_id = ?').get(guest.id);
+ const recentGuests = await db.prepare(`SELECT DISTINCT other.anon_id, guest.first_seen
+ FROM anon_ip_log mine
+ JOIN anon_ip_log other ON other.ip = mine.ip AND other.anon_id <> mine.anon_id
+ JOIN anon_users guest ON guest.id = other.anon_id
+ WHERE mine.anon_id = ?`).all(guest.id);
+ const recentGuestCount = recentGuests.filter((row) => {
+ const firstSeen = createdMs(row.first_seen);
+ return firstSeen >= now - DAY && firstSeen <= now;
+ }).length;
+ const converted = await db.prepare(`SELECT 1 AS ok FROM users
+ WHERE anon_number = ? AND COALESCE(is_anon, 0) = 0 LIMIT 1`).get(guest.anon_number);
+ return {
+ kind: 'guest',
+ age_band: ageBand(ageMs),
+ age_hours: Math.floor(ageMs / HOUR),
+ age_days: Math.floor(ageMs / DAY),
+ networks_seen: count(networks),
+ guests_from_same_networks_24h: recentGuestCount,
+ converted: Boolean(converted),
+ };
+ }
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subjectId)) return null;
const user = await db.prepare('SELECT id, subject_id, created_at, last_seen, email_verified, deleted_at FROM users WHERE subject_id = ?').get(subjectId);
const user = await db.prepare('SELECT id, subject_id, created_at, last_seen, email_verified, deleted_at FROM users WHERE subject_id = ?').get(subjectId);
if (!user || user.deleted_at) return null;
if (!user || user.deleted_at) return null;
const born = createdMs(user.created_at);
const born = createdMs(user.created_at);
@@ -45,7 +83,9 @@ async function trustSignals(db, subjectId, { now = Date.now() } = {}) {
@@ -45,7 +83,9 @@ async function trustSignals(db, subjectId, { now = Date.now() } = {}) {
if (count(linked)) verifiedBy.push('linked_account');
if (count(linked)) verifiedBy.push('linked_account');
if (active30) verifiedBy.push('30_days_active');
if (active30) verifiedBy.push('30_days_active');
return {
return {
- account_age_hours: Math.floor(ageMs / 3_600_000),
+ kind: 'account',
+ age_band: ageBand(ageMs),
+ account_age_hours: Math.floor(ageMs / HOUR),
account_age_days: Math.floor(ageMs / DAY),
account_age_days: Math.floor(ageMs / DAY),
passkey: Boolean(passkey),
passkey: Boolean(passkey),
linked_accounts: count(linked),
linked_accounts: count(linked),
@@ -56,4 +96,4 @@ async function trustSignals(db, subjectId, { now = Date.now() } = {}) {
@@ -56,4 +96,4 @@ async function trustSignals(db, subjectId, { now = Date.now() } = {}) {
};
};
}
}
-module.exports = { trustSignals };
+module.exports = { trustSignals, ageBand };
@@ -63,6 +63,10 @@ app.get('/openapi.json', (_req, res) => {
@@ -63,6 +63,10 @@ app.get('/openapi.json', (_req, res) => {
}
}
res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });
res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });
});
});
+app.get('/docs/trust-signals', (_req, res) => {
+ res.type('text/markdown').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
+ .send(fs.readFileSync(path.join(__dirname, '..', 'docs', 'trust-signals.md'), 'utf8'));
+});
// What this server is running (ADR-016, registry.release-manifest@1); open tabs poll it through
// What this server is running (ADR-016, registry.release-manifest@1); open tabs poll it through
// /shared/release-watch.js and are prompted, or reloaded when safe, after a deploy.
// /shared/release-watch.js and are prompted, or reloaded when safe, after a deploy.
@@ -495,6 +499,11 @@ app.post('/mcp', rateLimit({ windowMs: 60_000, max: 600 }), require('./connect/m
@@ -495,6 +499,11 @@ app.post('/mcp', rateLimit({ windowMs: 60_000, max: 600 }), require('./connect/m
if (found.moved) return { moved_to: found.moved };
if (found.moved) return { moved_to: found.moved };
return await publicProfiles.pageOf(found.user);
return await publicProfiles.pageOf(found.user);
},
},
+ myTrust: async (subject) => {
+ const signals = await require('./identity/trust').trustSignals(db, String(subject || ''));
+ if (!signals) throw Object.assign(new Error('No trust signals are available for this account.'), { status: 404 });
+ return signals;
+ },
}));
}));
// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).
// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).
app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));
app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));
@@ -512,6 +521,10 @@ app.get('/internal/integrations/github-token', require('./identity/principals').
@@ -512,6 +521,10 @@ app.get('/internal/integrations/github-token', require('./identity/principals').
// Platform blocks (WS-E task 5): a person's own list, and who blocked whom for Chat and Community
// Platform blocks (WS-E task 5): a person's own list, and who blocked whom for Chat and Community
// (network.blocks.read, service token only). Every change is network.block.changed (server/identity/blocks.js).
// (network.blocks.read, service token only). Every change is network.block.changed (server/identity/blocks.js).
app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/blocks').userRouter(requireAuth));
app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/blocks').userRouter(requireAuth));
+// Privacy-limited age and abuse signals: the /me route accepts its own account or guest token, while service reads need
+// identity.trust.read. Both responses are private and no-store.
+app.use('/api/v1/me/trust', rateLimit({ windowMs: 60_000, max: 30 }), require('./identity/trust-routes').meRouter());
+app.use('/api/v1/trust', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/trust-routes').serviceRouter(require('./identity/principals')));
// The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). AI reads it
// The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). AI reads it
// through /internal/identity/adult; this is the person's side, on the account hub (/my, Content).
// through /internal/identity/adult; this is the person's side, on the account hub (/my, Content).
app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('./identity/adult').userRouter({ requireAuth }));
app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('./identity/adult').userRouter({ requireAuth }));
@@ -47,7 +47,7 @@ const { startApp } = require('./helpers/connect-app');
@@ -47,7 +47,7 @@ const { startApp } = require('./helpers/connect-app');
const gateway = createGateway({ db, config, defs: [def], defaultRequestOptions: app.options });
const gateway = createGateway({ db, config, defs: [def], defaultRequestOptions: app.options });
const expressApp = express();
const expressApp = express();
expressApp.use(express.json({ limit: '256kb' }));
expressApp.use(express.json({ limit: '256kb' }));
- expressApp.post('/mcp', createMcpHandler({ db, config, gateway, publicPage: async (handle) => ({ username: handle, profile_url: `https://openvibe.my/@${handle}` }) }));
+ expressApp.post('/mcp', createMcpHandler({ db, config, gateway, publicPage: async (handle) => ({ username: handle, profile_url: `https://openvibe.my/@${handle}` }), myTrust: async () => ({ kind: 'guest', age_band: 'under_1d', age_hours: 2, age_days: 0, networks_seen: 1, guests_from_same_networks_24h: 2, converted: false }) }));
const server = http.createServer(expressApp);
const server = http.createServer(expressApp);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${server.address().port}/mcp`;
const url = `http://127.0.0.1:${server.address().port}/mcp`;
@@ -69,11 +69,11 @@ const { startApp } = require('./helpers/connect-app');
@@ -69,11 +69,11 @@ const { startApp } = require('./helpers/connect-app');
try {
try {
const listed = await client.listTools();
const listed = await client.listTools();
const tools = listed.tools || listed || [];
const tools = listed.tools || listed || [];
- assert.deepStrictEqual(tools.map((tool) => tool.name), ['network.person_page.read'], 'the public read is available without an app grant');
+ assert.deepStrictEqual(tools.map((tool) => tool.name), ['network.person_page.read', 'network.my_trust.read'], 'the two privacy-limited reads are available without an app grant');
await callers.setGrant(db, made.caller.id, 'testapp', 'read', owner);
await callers.setGrant(db, made.caller.id, 'testapp', 'read', owner);
const after = await client.listTools();
const after = await client.listTools();
const afterTools = after.tools || after || [];
const afterTools = after.tools || after || [];
- assert.strictEqual(afterTools.length, 2);
+ assert.strictEqual(afterTools.length, 3);
const appTool = afterTools.find((tool) => tool.name === 'testapp.read');
const appTool = afterTools.find((tool) => tool.name === 'testapp.read');
assert.ok(appTool.inputSchema, 'the tool carries its schema');
assert.ok(appTool.inputSchema, 'the tool carries its schema');
assert.strictEqual(appTool.annotations.readOnlyHint, true);
assert.strictEqual(appTool.annotations.readOnlyHint, true);
@@ -89,6 +89,16 @@ const { startApp } = require('./helpers/connect-app');
@@ -89,6 +89,16 @@ const { startApp } = require('./helpers/connect-app');
} finally { await client.close(); }
} finally { await client.close(); }
});
});
+ await check('the MCP trust tool returns only coarse signals and counts', async () => {
+ const client = await connect(key);
+ try {
+ const out = await client.callTool({ name: 'network.my_trust.read', arguments: {} });
+ assert.ok(!out.isError);
+ assert.match(out.content[0].text, /"kind": "guest"/);
+ assert.doesNotMatch(out.content[0].text, /192\.0\.2\.|email|last_seen/);
+ } finally { await client.close(); }
+ });
+
await check('a real MCP client calls a tool and gets the answer', async () => {
await check('a real MCP client calls a tool and gets the answer', async () => {
const client = await connect(key);
const client = await connect(key);
try {
try {
@@ -40,7 +40,7 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
@@ -40,7 +40,7 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
let p = svc.payloadFor('openvibe.media');
let p = svc.payloadFor('openvibe.media');
// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then
// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then
// the test still follows what the installed manifests say.
// the test still follows what the installed manifests say.
-const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect'];
+const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock'];
if (HAS_MAIL) COLD_ORDER.splice(COLD_ORDER.indexOf('chat') + 1, 0, 'mail');
if (HAS_MAIL) COLD_ORDER.splice(COLD_ORDER.indexOf('chat') + 1, 0, 'mail');
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');
assert.ok(!p.nav.some(n => n.id === 'play'), 'Play is part of Games, not a menu entry of its own');
assert.ok(!p.nav.some(n => n.id === 'play'), 'Play is part of Games, not a menu entry of its own');
@@ -58,13 +58,13 @@ assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.na
@@ -58,13 +58,13 @@ assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.na
// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).
// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).
[['games', 'games'], ['connect', 'make'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['mail', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games'], ['vision', 'answers'], ['exchange', 'build'], ['fun', 'games'], ['cam', 'streams'], ['bench', 'answers'], ['casino', 'games'], ['tours', 'streams'], ['shoes', 'shop']]
[['games', 'games'], ['connect', 'make'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['mail', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games'], ['vision', 'answers'], ['exchange', 'build'], ['fun', 'games'], ['cam', 'streams'], ['bench', 'answers'], ['casino', 'games'], ['tours', 'streams'], ['shoes', 'shop']]
.filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));
.filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));
-assert.strictEqual(p.nav.length, 53 + (HAS_MAIL ? 1 : 0)); // Planned Computer is in the soon list until launch.
+assert.strictEqual(p.nav.length, 55 + (HAS_MAIL ? 1 : 0)); // Computer and Locker are live sites since Contracts 0.347.0 (2026-11-08).
assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');
assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');
assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');
assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a site never recommends itself');
assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a site never recommends itself');
-assert.deepStrictEqual(p.soon.map(n => n.id || n), ['computer'], 'Computer is listed as coming soon until the reviewed host launch');
+assert.deepStrictEqual(p.soon.map(n => n.id || n), [], 'nothing is coming soon: Computer launched on 2026-11-08');
// Real use reorders: community gets the history, so it rises above the cold-start order.
// Real use reorders: community gets the history, so it rises above the cold-start order.
// user_history's foreign key needs its users (PostgreSQL enforces the constraint SQLite only declared).
// user_history's foreign key needs its users (PostgreSQL enforces the constraint SQLite only declared).
@@ -9,6 +9,7 @@ const contracts = require('openvibe-contracts');
@@ -9,6 +9,7 @@ const contracts = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const { trustSignals } = require('../server/identity/trust');
const { trustSignals } = require('../server/identity/trust');
+const { serviceRouter, meRouter } = require('../server/identity/trust-routes');
const DAY = 86_400_000;
const DAY = 86_400_000;
const USER_NEW = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';
const USER_NEW = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';
@@ -16,6 +17,9 @@ const USER_OLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2';
@@ -16,6 +17,9 @@ const USER_OLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2';
const USER_LINKED = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';
const USER_LINKED = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';
const LOCKER_MEMBER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP4';
const LOCKER_MEMBER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP4';
const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
+const GUEST_NEW = 'gst_01JAB2C3D4E5F6G7H8J9K0MNP1';
+const GUEST_OTHER = 'gst_01JAB2C3D4E5F6G7H8J9K0MNP2';
+const GUEST_OLD = 'gst_01JAB2C3D4E5F6G7H8J9K0MNP3';
(async () => {
(async () => {
const db = getDb();
const db = getDb();
@@ -29,12 +33,16 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
@@ -29,12 +33,16 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen) VALUES (3, 'linked', 'x', ?, ?, ?)").run(USER_LINKED, at(now), at(now));
await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen) VALUES (3, 'linked', 'x', ?, ?, ?)").run(USER_LINKED, at(now), at(now));
await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen) VALUES (4, 'passkey', 'x', ?, ?, ?)").run(LOCKER_MEMBER, at(now), at(now));
await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen) VALUES (4, 'passkey', 'x', ?, ?, ?)").run(LOCKER_MEMBER, at(now), at(now));
await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen) VALUES (5, 'stale', 'x', ?, ?, ?)").run(USER_STALE, at(now - 40 * DAY), at(now - 40 * DAY));
await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen) VALUES (5, 'stale', 'x', ?, ?, ?)").run(USER_STALE, at(now - 40 * DAY), at(now - 40 * DAY));
+ await db.prepare("INSERT INTO anon_users (id, anon_number, session_token, subject_id, first_seen) VALUES (101, 901, 'guest-session-token', ?, ?)").run(GUEST_NEW, at(now - 3 * 3_600_000));
+ await db.prepare("INSERT INTO anon_users (id, anon_number, session_token, subject_id, first_seen) VALUES (102, 902, 'guest-session-other', ?, ?)").run(GUEST_OTHER, at(now - 2 * 3_600_000));
+ await db.prepare("INSERT INTO anon_users (id, anon_number, session_token, subject_id, first_seen) VALUES (103, 903, 'guest-session-old', ?, ?)").run(GUEST_OLD, at(now - 2 * DAY));
+ await db.prepare("INSERT INTO anon_ip_log (anon_id, ip) VALUES (101, '192.0.2.44'), (101, '192.0.2.45'), (102, '192.0.2.44'), (103, '192.0.2.45')").run();
await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (3, 'live', '33'), (3, 'tools', '44')").run();
await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (3, 'live', '33'), (3, 'tools', '44')").run();
await db.prepare("INSERT INTO locker_key_backups (subject, kind, blob) VALUES (?, 'passkey', '{}'::jsonb)").run(LOCKER_MEMBER);
await db.prepare("INSERT INTO locker_key_backups (subject, kind, blob) VALUES (?, 'passkey', '{}'::jsonb)").run(LOCKER_MEMBER);
console.log = quiet;
console.log = quiet;
const newbie = await trustSignals(db, USER_NEW, { now });
const newbie = await trustSignals(db, USER_NEW, { now });
- assert.deepStrictEqual(newbie, { account_age_hours: 0, account_age_days: 0, passkey: false, linked_accounts: 0, active_30d: false, email_verified: false, verified: false, verified_by: [] });
+ assert.deepStrictEqual(newbie, { kind: 'account', age_band: 'under_1h', account_age_hours: 0, account_age_days: 0, passkey: false, linked_accounts: 0, active_30d: false, email_verified: false, verified: false, verified_by: [] });
const old = await trustSignals(db, USER_OLD, { now });
const old = await trustSignals(db, USER_OLD, { now });
assert.strictEqual(old.account_age_days, 40);
assert.strictEqual(old.account_age_days, 40);
@@ -54,6 +62,11 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
@@ -54,6 +62,11 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
assert.strictEqual(stale.active_30d, false);
assert.strictEqual(stale.active_30d, false);
assert.strictEqual(stale.verified, false);
assert.strictEqual(stale.verified, false);
+ const guest = await trustSignals(db, GUEST_NEW, { now });
+ assert.deepStrictEqual(guest, { kind: 'guest', age_band: 'under_1d', age_hours: 3, age_days: 0, networks_seen: 2, guests_from_same_networks_24h: 1, converted: false });
+ assert.strictEqual(JSON.stringify(guest).includes('192.0.2.'), false, 'no network address leaves the trust function');
+ assert.strictEqual((await trustSignals(db, GUEST_OLD, { now })).guests_from_same_networks_24h, 1);
+
assert.strictEqual(await trustSignals(db, 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ', { now }), null);
assert.strictEqual(await trustSignals(db, 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ', { now }), null);
assert.strictEqual(await trustSignals(db, 'not-a-subject', { now }), null);
assert.strictEqual(await trustSignals(db, 'not-a-subject', { now }), null);
@@ -65,10 +78,13 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
@@ -65,10 +78,13 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
app.locals.publicKey = publicKey;
app.locals.publicKey = publicKey;
app.use(express.json());
app.use(express.json());
+ app.use('/api/v1/trust', serviceRouter(require('../server/identity/principals')));
+ app.use('/api/v1/me/trust', meRouter());
app.use('/internal', require('../server/internal/routes'));
app.use('/internal', require('../server/internal/routes'));
const server = http.createServer(app);
const server = http.createServer(app);
const t0 = Math.floor(Date.now() / 1000);
const t0 = Math.floor(Date.now() / 1000);
const TOKEN = contracts.serviceAuth.signServiceToken({ iss: ISSUER, sub: 'svc:ai', actor_type: 'service', aud: ['openvibe.network'], cap: ['identity.subject.resolve'], iat: t0, exp: t0 + 300, jti: 'tok_identity_trust' }, privateKey);
const TOKEN = contracts.serviceAuth.signServiceToken({ iss: ISSUER, sub: 'svc:ai', actor_type: 'service', aud: ['openvibe.network'], cap: ['identity.subject.resolve'], iat: t0, exp: t0 + 300, jti: 'tok_identity_trust' }, privateKey);
+ const TRUST_TOKEN = contracts.serviceAuth.signServiceToken({ iss: ISSUER, sub: 'svc:ai', actor_type: 'service', aud: ['openvibe.network'], cap: ['identity.subject.resolve', 'identity.trust.read'], iat: t0, exp: t0 + 300, jti: 'tok_identity_trust_read' }, privateKey);
await new Promise((res) => server.listen(0, '127.0.0.1', res));
await new Promise((res) => server.listen(0, '127.0.0.1', res));
const base = `http://127.0.0.1:${server.address().port}/internal/identity`;
const base = `http://127.0.0.1:${server.address().port}/internal/identity`;
const call = (p) => fetch(base + p, { headers: { authorization: `Bearer ${TOKEN}` } }).then(async (x) => ({ status: x.status, body: await x.json() }));
const call = (p) => fetch(base + p, { headers: { authorization: `Bearer ${TOKEN}` } }).then(async (x) => ({ status: x.status, body: await x.json() }));
@@ -82,6 +98,27 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
@@ -82,6 +98,27 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
h = await call(`/resolve?subject_id=${USER_NEW}&trust=1`);
h = await call(`/resolve?subject_id=${USER_NEW}&trust=1`);
assert.strictEqual(h.body.trust.verified, false);
assert.strictEqual(h.body.trust.verified, false);
assert.ok(h.body.trust.account_age_hours < 24, `age ${h.body.trust.account_age_hours}`);
assert.ok(h.body.trust.account_age_hours < 24, `age ${h.body.trust.account_age_hours}`);
+ h = await call(`/resolve?subject_id=${GUEST_NEW}&trust=1`);
+ assert.strictEqual(h.status, 200);
+ assert.deepStrictEqual(h.body.trust, guest, 'the existing internal route now includes guest signals');
+
+ const api = (path, token) => fetch(`http://127.0.0.1:${server.address().port}${path}`, { headers: { authorization: `Bearer ${token}` } }).then(async (x) => ({ status: x.status, cache: x.headers.get('cache-control'), body: await x.json() }));
+ h = await api(`/api/v1/trust/${GUEST_NEW}`, TOKEN);
+ assert.strictEqual(h.status, 403, 'identity.subject.resolve alone cannot read trust signals');
+ assert.strictEqual(h.cache, 'private, no-store', 'denied service reads are no-store too');
+ h = await api(`/api/v1/trust/${GUEST_NEW}`, TRUST_TOKEN);
+ assert.strictEqual(h.status, 200);
+ assert.strictEqual(h.cache, 'private, no-store');
+ assert.strictEqual(h.body.kind, 'guest');
+ assert.strictEqual(JSON.stringify(h.body).includes('192.0.2.'), false, 'API never returns IPs');
+
+ h = await api('/api/v1/me/trust', 'guest-session-token');
+ assert.strictEqual(h.status, 200, 'a guest session token reads its own trust');
+ assert.strictEqual(h.body.kind, 'guest');
+ const ownToken = require('jsonwebtoken').sign({ sub: '1', id: 1 }, privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+ h = await api('/api/v1/me/trust', ownToken);
+ assert.strictEqual(h.status, 200, 'an account session token reads its own trust');
+ assert.strictEqual(h.body.kind, 'account');
await new Promise((res) => server.close(res));
await new Promise((res) => server.close(res));
await db.close?.();
await db.close?.();
@@ -24,6 +24,7 @@ const PINNED = {
@@ -24,6 +24,7 @@ const PINNED = {
// openvibe-contracts v0.86-v0.90: Bot runs (its own exposure row). Zone: storage zones at openvibe.zone since 0.173.0.
// openvibe-contracts v0.86-v0.90: Bot runs (its own exposure row). Zone: storage zones at openvibe.zone since 0.173.0.
bot: 'live', zone: 'live',
bot: 'live', zone: 'live',
actor: 'live', // OpenVibe's own agent and the router for agent work at openvibe.actor since 2026-10-08 (contracts 0.115.0)
actor: 'live', // OpenVibe's own agent and the router for agent work at openvibe.actor since 2026-10-08 (contracts 0.115.0)
+ computer: 'live', locker: 'live', // launched 2026-11-08 (contracts 0.347.0)
food: 'live', help: 'live', work: 'live', // services since contracts 0.116.0 (plan T19, 2026-10-08)
food: 'live', help: 'live', work: 'live', // services since contracts 0.116.0 (plan T19, 2026-10-08)
quest: 'live', rent: 'live', // services since contracts 0.117.0
quest: 'live', rent: 'live', // services since contracts 0.117.0
inventory: 'live', // the network-wide inventory at inventory.openvibe.network since contracts 0.122.0 (ADR-054)
inventory: 'live', // the network-wide inventory at inventory.openvibe.network since contracts 0.122.0 (ADR-054)
@@ -127,7 +128,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
@@ -127,7 +128,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
// v0.95.0 Services; Space and AI went live in v0.110.0; Food, Help and Work in v0.116.0; Quest, Rent and Watch in v0.117.0;
// v0.95.0 Services; Space and AI went live in v0.110.0; Food, Help and Work in v0.116.0; Quest, Rent and Watch in v0.117.0;
// Inventory in v0.122.0; none is retired
// Inventory in v0.122.0; none is retired
// News, Reviews, Coupons, Trade and OpenRestream in v0.125.0; Run in v0.141.0
// News, Reviews, Coupons, Trade and OpenRestream in v0.125.0; Run in v0.141.0
- assert.deepStrictEqual(st.exposure_summary, { live: 53 + (HAS_MAIL ? 1 : 0), internal: 1, library: 4, repository: 1, placeholder: 0, retired: 1 }); // Sites retired (contracts 0.181.0); Tips and VIP live (0.200.0); Design live (0.228.0); Meme live (0.234.0); Vision and Exchange live (0.235.0); Fun live (0.236.0); Cam live (0.237.0); Bench live (0.238.0); Email live with the release that adds its manifest
+ assert.deepStrictEqual(st.exposure_summary, { live: 55 + (HAS_MAIL ? 1 : 0), internal: 1, library: 4, repository: 1, placeholder: 0, retired: 1 }); // Sites retired (contracts 0.181.0); Tips and VIP live (0.200.0); Design live (0.228.0); Meme live (0.234.0); Vision and Exchange live (0.235.0); Fun live (0.236.0); Cam live (0.237.0); Bench live (0.238.0); Email live with the release that adds its manifest
// The page: no row for a service whose domain serves a placeholder shows a bare "Up".
// The page: no row for a service whose domain serves a placeholder shows a bare "Up".
const html = await fetch(base + '/status').then(x => x.text());
const html = await fetch(base + '/status').then(x => x.text());
@@ -145,7 +146,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
@@ -145,7 +146,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
// Play stays live in the exposure overlay (the API serves) but is part of Games now, so it is not a nav site.
// Play stays live in the exposure overlay (the API serves) but is part of Games now, so it is not a nav site.
assert.ok(!SITES.some(s => s.id === 'play'), 'Play is part of Games, not a menu entry');
assert.ok(!SITES.some(s => s.id === 'play'), 'Play is part of Games, not a menu entry');
const open = SITES.filter(s => s.status === 'open').map(s => s.id);
const open = SITES.filter(s => s.status === 'open').map(s => s.id);
- const EXPECTED_OPEN = ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect']; // by site position; Email joins after Chat once its manifest is pinned
+ const EXPECTED_OPEN = ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock']; // by site position; Email joins after Chat once its manifest is pinned
if (HAS_MAIL) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('chat') + 1, 0, 'mail');
if (HAS_MAIL) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('chat') + 1, 0, 'mail');
assert.deepStrictEqual(open, EXPECTED_OPEN);
assert.deepStrictEqual(open, EXPECTED_OPEN);
for (const s of SITES) {
for (const s of SITES) {