openvibers/openvibe.network Commits
Identity: trust signals for the free-tier allowance (#595)
Changes
@@@ -9,3 +9,9 @@ description = "The public web-push test vector (subscription auth secret from thcondition = "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$''']@@@ -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);});nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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);++});
@@ -9,3 +9,9 @@ description = "The public web-push test vector (subscription auth secret from th
@@ -9,3 +9,9 @@ description = "The public web-push test vector (subscription auth secret from th
condition = "AND"
condition = "AND"
paths = ['''^test/security-ssrf\.test\.js$''']
paths = ['''^test/security-ssrf\.test\.js$''']
regexes = ['''^tBHItJI5svbpez7KI4CCXg$''']
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$''']
@@ -6,11 +6,13 @@
@@ -6,11 +6,13 @@
* 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)
* 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');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const subjects = require('./subjects');
const subjects = require('./subjects');
+const { trustSignals } = require('./trust');
const router = express.Router();
const router = express.Router();
router.use(http.middleware());
router.use(http.middleware());
@@ -26,6 +28,9 @@ router.get('/resolve', async (req, res) => {
@@ -26,6 +28,9 @@ 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);
res.json(out);
res.json(out);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -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);
+});