openvibers/openvibe.network Commits

Identity: trust signals for the free-tier allowance (#595)

OP OpenVibers committed 7 days ago · c9469ebf3e · 4 files, +161 −0

* Identity: trust signals for the free-tier allowance

Plan E5. GET /internal/identity/resolve?trust=1 adds the facts OpenVibe.AI needs to scale the daily free allowance: account age, whether a Locker passkey exists, how many accounts are linked (never which), and whether the account is 30 days old and active in the last 30. verified/verified_by follow the plan's rule (passkey, linked account, or 30 days with activity). The default projection is unchanged, so existing readers pay nothing; no new route, no email, no linked service ids.

* tests: a fake subject id is not an API key

The E5 trust test named its passkey fixture USER_PASSKEY, so gitleaks' generic-api-key rule read the public usr_… fixture as a secret. Rename it, and add the narrow path+value allowlist that covers the earlier commit of this branch.

Changes

ddiff --git a/.gitleaks.toml b/.gitleaks.toml
iindex be2eb2a5..077bba5d 100644
---- a/.gitleaks.toml
++++ b/.gitleaks.toml
@@@ -9,3 +9,9 @@ description = "The public web-push test vector (subscription auth secret from th
condition = "AND"
paths = ['''^test/security-ssrf\.test\.js$''']
regexes = ['''^tBHItJI5svbpez7KI4CCXg$''']
++
++[[allowlists]]
++description = "Plan E5 test fixture: a fake public subject id (usr_…), not a key; the generic-api-key rule matched a constant named USER_PASSKEY in an earlier commit of this branch"
++condition = "AND"
++paths = ['''^test/identity-trust\.test\.js$''']
++regexes = ['''^usr_01JAB2C3D4E5F6G7H8J9K0MNP4$''']
ddiff --git a/server/identity/internal-routes.js b/server/identity/internal-routes.js
iindex e32915fb..bc56aeda 100644
---- a/server/identity/internal-routes.js
++++ b/server/identity/internal-routes.js
@@@ -6,11 +6,13 @@
* 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)
* POST /internal/identity/legacy-map { entries: [{ network_user_id | subject_id, source_system, source_type, source_id, verified? }] }
*/
const express = require('express');
const { http } = require('openvibe-contracts');
const subjects = require('./subjects');
++const { trustSignals } = require('./trust');
const router = express.Router();
router.use(http.middleware());
@@@ -26,6 +28,9 @@ 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);
res.json(out);
});
ddiff --git a/server/identity/trust.js b/server/identity/trust.js
nnew file mode 100644
iindex 00000000..b4dd624b
---- /dev/null
++++ b/server/identity/trust.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * Plan E5: the trust facts OpenVibe.AI needs to scale the free allowance, on the existing identity read.
++ *
++ * 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
++ * 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.
++ */
++const { createdMs } = require('./subjects');
++
++const DAY = 86_400_000;
++
++function count(row) {
++ const n = Number(row && row.n);
++ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
++}
++
++/**
++ * trustSignals(db, subjectId, { now }): the facts for one person, or null when the subject is not a live user.
++ * `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;
++ 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);
++ const ageMs = Math.max(0, now - born);
++ const linked = await db.prepare('SELECT COUNT(*) AS n FROM linked_accounts WHERE user_id = ?').get(user.id);
++ const passkey = await db.prepare("SELECT 1 AS ok FROM locker_key_backups WHERE subject = ? AND kind = 'passkey' LIMIT 1").get(subjectId);
++ const seen = user.last_seen ? createdMs(user.last_seen) : null;
++ const active30 = ageMs >= 30 * DAY && seen != null && now - seen <= 30 * DAY;
++ const verifiedBy = [];
++ if (passkey) verifiedBy.push('passkey');
++ if (count(linked)) verifiedBy.push('linked_account');
++ if (active30) verifiedBy.push('30_days_active');
++ return {
++ account_age_hours: Math.floor(ageMs / 3_600_000),
++ account_age_days: Math.floor(ageMs / DAY),
++ passkey: Boolean(passkey),
++ linked_accounts: count(linked),
++ active_30d: active30,
++ email_verified: Number(user.email_verified) === 1,
++ verified: verifiedBy.length > 0,
++ verified_by: verifiedBy,
++ };
++}
++
++module.exports = { trustSignals };
ddiff --git a/test/identity-trust.test.js b/test/identity-trust.test.js
nnew file mode 100644
iindex 00000000..dc77db2e
---- /dev/null
++++ b/test/identity-trust.test.js
@@@ -0,0 +1,91 @@
++'use strict';
++// Plan E5: Network's trust signals on the existing identity read (server/identity/trust.js,
++// GET /internal/identity/resolve?trust=1). No new route; the default projection is unchanged.
++// node --import ./test/helpers/pg-preload.mjs test/identity-trust.test.js
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const { trustSignals } = require('../server/identity/trust');
++
++const DAY = 86_400_000;
++const USER_NEW = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';
++const USER_OLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2';
++const USER_LINKED = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';
++const LOCKER_MEMBER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP4';
++const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
++
++(async () => {
++ const db = getDb();
++ const quiet = console.log; console.log = () => {};
++ const now = Date.UTC(2026, 10, 6, 12);
++ const at = (ms) => new Date(ms).toISOString();
++ await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen, email_verified) VALUES (1, 'newbie', 'x', ?, ?, ?, 0)").run(USER_NEW, at(now), at(now));
++ await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen, email_verified) VALUES (2, 'old', 'x', ?, ?, ?, 0)").run(USER_OLD, at(now - 40 * DAY), 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 (5, 'stale', 'x', ?, ?, ?)").run(USER_STALE, at(now - 40 * DAY), at(now - 40 * DAY));
++ 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: [] });
++
++ const old = await trustSignals(db, USER_OLD, { now });
++ assert.strictEqual(old.account_age_days, 40);
++ assert.strictEqual(old.active_30d, true);
++ assert.strictEqual(old.verified, true);
++ assert.deepStrictEqual(old.verified_by, ['30_days_active']);
++
++ const linked = await trustSignals(db, USER_LINKED, { now });
++ assert.strictEqual(linked.linked_accounts, 2);
++ assert.deepStrictEqual(linked.verified_by, ['linked_account'], 'only the count, never which sites');
++
++ const passkey = await trustSignals(db, LOCKER_MEMBER, { now });
++ assert.strictEqual(passkey.passkey, true);
++ assert.deepStrictEqual(passkey.verified_by, ['passkey']);
++
++ const stale = await trustSignals(db, USER_STALE, { now });
++ assert.strictEqual(stale.active_30d, false);
++ assert.strictEqual(stale.verified, false);
++
++ assert.strictEqual(await trustSignals(db, 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ', { now }), null);
++ assert.strictEqual(await trustSignals(db, 'not-a-subject', { now }), null);
++
++ // ── HTTP: the same read through the existing route, only when asked ──
++ const app = express();
++ const { privateKey, publicKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++ const ISSUER = 'https://openvibe.network';
++ app.locals.db = db;
++ app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
++ app.locals.publicKey = publicKey;
++ app.use(express.json());
++ 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);
++ 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() }));
++
++ let h = await call(`/resolve?subject_id=${LOCKER_MEMBER}&trust=1`);
++ assert.strictEqual(h.status, 200);
++ assert.strictEqual(h.body.trust.verified, true);
++ assert.strictEqual(h.body.trust.passkey, true);
++ h = await call(`/resolve?subject_id=${LOCKER_MEMBER}`);
++ assert.strictEqual(h.body.trust, undefined, 'the default projection is unchanged');
++ 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}`);
++
++ await new Promise((res) => server.close(res));
++ await db.close?.();
++ console.log('identity-trust: all checks passed');
++ process.exit(0);
++})().catch((err) => {
++ console.error(err && err.stack ? err.stack : err);
++ process.exit(1);
++});