openvibers/openvibe.network Commits

Add privacy-limited guest trust signals (#683)

OP OpenVibers committed 5 days ago · d17029a088 · 16 files, +262 −33

* Add privacy-limited guest trust signals

* Keep trust capability check compatible with review pin

* Assert denied trust reads are not cached

* Allow manual network CI runs

* Contracts v0.347.0: identity.trust.read is a real capability; Computer and Locker are live sites

Merge main; pin openvibe-contracts v0.347.0, which knows identity.trust.read (the PR-window fallback guard is gone:
the service route uses the normal grant-aware guard) and carries the locker and computer manifests, so the frame
lists both and Network's CORS allows openvibe.locker. The frame test's cold order and count follow.

* registry-exposure: Computer and Locker are live sites (contracts 0.347.0)

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex a6b839c1..3c04d717 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -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:
ddiff --git a/README.md b/README.md
iindex fbfe59f2..a3ea062b 100644
---- a/README.md
++++ b/README.md
@@@ -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)).
<!-- versions:start -->
--- openvibe-contracts: v0.342.0
++- openvibe-contracts: v0.347.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.155.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 6988b40a..ac360f19 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/docs/trust-signals.md b/docs/trust-signals.md
nnew file mode 100644
iindex 00000000..f4632b5f
---- /dev/null
++++ b/docs/trust-signals.md
@@@ -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.
ddiff --git a/package-lock.json b/package-lock.json
iindex c0a947d5..2f81e9bd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 38a7e1ed..cb9dc9a6 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/connect/mcp.js b/server/connect/mcp.js
iindex 38d3712b..2ef1c095 100644
---- a/server/connect/mcp.js
++++ b/server/connect/mcp.js
@@@ -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 = null
if (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 = null
const 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 };
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 8408b29b..95ba138c 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -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' },
] },
ddiff --git a/server/identity/internal-routes.js b/server/identity/internal-routes.js
iindex bc56aeda..dbcb49f1 100644
---- a/server/identity/internal-routes.js
++++ b/server/identity/internal-routes.js
@@@ -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);
});
ddiff --git a/server/identity/trust-routes.js b/server/identity/trust-routes.js
nnew file mode 100644
iindex 00000000..42261573
---- /dev/null
++++ b/server/identity/trust-routes.js
@@@ -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 };
ddiff --git a/server/identity/trust.js b/server/identity/trust.js
iindex b4dd624b..11fe4d1d 100644
---- a/server/identity/trust.js
++++ b/server/identity/trust.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex f8084c48..b9afe816 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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/m
if (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 }));
ddiff --git a/test/connect-mcp.test.js b/test/connect-mcp.test.js
iindex 1946d5e4..5a315dc1 100644
---- a/test/connect-mcp.test.js
++++ b/test/connect-mcp.test.js
@@@ -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 {
ddiff --git a/test/frame.test.js b/test/frame.test.js
iindex 185f3257..9e2a255f 100644
---- a/test/frame.test.js
++++ b/test/frame.test.js
@@@ -40,7 +40,7 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
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
// 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).
ddiff --git a/test/identity-trust.test.js b/test/identity-trust.test.js
iindex c6a82357..baf6a820 100644
---- a/test/identity-trust.test.js
++++ b/test/identity-trust.test.js
@@@ -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?.();
ddiff --git a/test/registry-exposure.test.js b/test/registry-exposure.test.js
iindex 65ccf7f3..56b9aa22 100644
---- a/test/registry-exposure.test.js
++++ b/test/registry-exposure.test.js
@@@ -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.0
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
// 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 pinned
if (HAS_MAIL) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('chat') + 1, 0, 'mail');
assert.deepStrictEqual(open, EXPECTED_OPEN);
for (const s of SITES) {