openvibers/openvibe.network Commits

One earned Network level, ledger and public leaderboards (#762)

OP OpenVibers committed 3 days ago · 11697c56d1 · 24 files, +348 −13

* Own shared experience, levels and leaderboards on Network

* Merge current Network history and pin updates

* Include the experience migration in schema upgrade expectations

Changes

ddiff --git a/migrations/0065_levels.sql b/migrations/0065_levels.sql
nnew file mode 100644
iindex 00000000..5a6c401a
---- /dev/null
++++ b/migrations/0065_levels.sql
@@@ -0,0 +1,27 @@
++-- phase: expand
++-- Network owns earned experience; no amount is spendable or convertible.
++CREATE TABLE IF NOT EXISTS person_levels (
++ user_id bigint PRIMARY KEY REFERENCES users(id),
++ xp bigint NOT NULL DEFAULT 0 CHECK (xp BETWEEN 0 AND 1000000000000),
++ level integer NOT NULL DEFAULT 1 CHECK (level >= 1),
++ leaderboard boolean NOT NULL DEFAULT true
++);
++CREATE TABLE IF NOT EXISTS experience_events (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL REFERENCES users(id),
++ service text NOT NULL,
++ reason text NOT NULL,
++ amount bigint NOT NULL CHECK (amount > 0),
++ idempotency_key text NOT NULL,
++ earned_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(service, idempotency_key)
++);
++CREATE INDEX IF NOT EXISTS experience_person_recent ON experience_events(user_id, earned_at DESC, id DESC);
++CREATE INDEX IF NOT EXISTS experience_week ON experience_events(earned_at, user_id);
++CREATE INDEX IF NOT EXISTS person_levels_board ON person_levels(xp DESC, user_id) WHERE leaderboard;
++CREATE TABLE IF NOT EXISTS experience_service_days (
++ service text NOT NULL,
++ day date NOT NULL,
++ amount bigint NOT NULL,
++ PRIMARY KEY(service, day)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 523b4608..ef6bf188 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.390.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.391.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.187.0",
"pg": "^8.23.0",
@@@ -2303,9 +2303,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.390.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.390.0",
-- "integrity": "sha512-Do5oU1PQiuYI6bTDifs3IdRn/JJ43P3FXyXKPTKbTvErVj0ZuliXxzXmab5zQHx3694Welal2UMN2TSVhMLBwQ==",
++ "version": "0.391.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.391.0",
++ "integrity": "sha512-YZZlJcszGWyy1TpEoVFj+mprnedvDWflOtkXWsLV406jVrCuStJXgK82ooJPr6llmC2KbAS55sGIxiYAieyqbg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 17f00537..bf990b91 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.390.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.391.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.187.0",
"pg": "^8.23.0",
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex b1fea5d2..bfb94524 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -615,3 +615,18 @@ function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccounts(); }
function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
function signOutAll() { OpenVibeAccountSwitcher.logoutAll(); }
++
++// Network owns this choice; leaving the board does not hide the profile's level.
++(() => {
++ const input = document.getElementById('levels-board');
++ if (!input) return;
++ input.disabled = true;
++ apiFetch('/api/v1/me/levels').then(data => { input.checked = data.leaderboard; input.disabled = false; }).catch(() => {});
++ input.addEventListener('change', async () => {
++ input.disabled = true;
++ try { await apiFetch('/api/v1/me/levels', { method: 'PUT', body: JSON.stringify({ leaderboard: input.checked }) });
++ document.getElementById('levels-setting-status').textContent = 'Saved';
++ } catch { input.checked = !input.checked; document.getElementById('levels-setting-status').textContent = 'Could not save. Try again.'; }
++ finally { input.disabled = false; }
++ });
++})();
ddiff --git a/public/my.html b/public/my.html
iindex 4083b729..4a74f1d7 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -215,6 +215,8 @@
<div class="form-field">
<label>Public Profile</label>
<label class="data-check"><input type="checkbox" id="pf-public" checked> <span>Show my profile at <a id="pf-public-link" href="/my" target="_blank" rel="noopener">openvibe.network/@you</a>: your picture, bio, when you joined, and the items you own and wear. When this is off, it shows only your name and picture.</span></label>
++ <label><input type="checkbox" id="levels-board" checked> Show me on the experience leaderboard</label>
++ <p id="levels-setting-status" role="status"></p>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-primary">Save Changes</button>
ddiff --git a/scripts/backfill-levels.js b/scripts/backfill-levels.js
nnew file mode 100644
iindex 00000000..b44305f9
---- /dev/null
++++ b/scripts/backfill-levels.js
@@@ -0,0 +1,44 @@
++#!/usr/bin/env node
++'use strict';
++// Run with the service environment. Default dry run; --apply explicitly writes through the same ledger.
++const { earned } = require('../server/levels/coins');
++async function backfill(db, { apply = false } = {}) {
++ const counts = { scanned: 0, eligible: 0, existing: 0, awarded: 0, xp: 0 };
++ let after = 0;
++ while (true) {
++ const rows = await db.prepare(`SELECT c.id, c.user_id, c.app_id, c.delta, c.reason, c.idempotency_key, c.created_at
++ FROM coin_transactions c JOIN users u ON u.id = c.user_id
++ WHERE c.id > ? AND c.delta > 0 AND u.deleted_at IS NULL ORDER BY c.id LIMIT 500`).all(after);
++ if (!rows.length) break;
++ for (const row of rows) {
++ after = Number(row.id); counts.scanned++;
++ if (!earned(row)) continue;
++ // Earn history follows an existing account merge, but never resurrects a deleted person.
++ let owner = await db.prepare('SELECT id, merged_into, deleted_at, is_banned, is_anon FROM users WHERE id = ?').get(row.user_id);
++ const visited = new Set();
++ while (owner?.merged_into && !visited.has(owner.id)) {
++ visited.add(owner.id);
++ owner = await db.prepare('SELECT id, merged_into, deleted_at, is_banned, is_anon FROM users WHERE id = ?').get(owner.merged_into);
++ }
++ if (!owner || owner.merged_into || owner.deleted_at || Number(owner.is_banned) || Number(owner.is_anon)) continue;
++ counts.eligible++;
++ const key = `coin:${row.idempotency_key}`;
++ if (await db.prepare("SELECT 1 FROM experience_events WHERE service = 'opencoins' AND idempotency_key = ?").get(key)) { counts.existing++; continue; }
++ counts.xp += Number(row.delta);
++ if (apply) {
++ try { await db.tx(() => require('../server/levels/store').append(db, { user_id: owner.id, service: 'opencoins', reason: 'quest.completed', amount: Number(row.delta), idempotency_key: key, at: new Date(row.created_at).toISOString(), backfill: true })); counts.awarded++; }
++ catch (e) { if (e.code === 'levels.duplicate') counts.existing++; else throw e; }
++ }
++ }
++ }
++ return { mode: apply ? 'apply' : 'dry-run', ...counts };
++}
++if (require.main === module) {
++ // No migrations or seed writes: production schema changes happen only at deploy.
++ const { createDb } = require('openvibe-sdk/db');
++ if (!process.env.DATABASE_URL) throw new Error('run with the service environment');
++ const db = createDb({ url: process.env.DATABASE_URL, service: 'network-levels-backfill', max: 1 });
++ backfill(db, { apply: process.argv.includes('--apply') }).then(result => console.log(JSON.stringify(result)))
++ .catch(() => { console.error('experience backfill failed'); process.exitCode = 1; }).finally(() => db.close());
++}
++module.exports = { backfill };
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 67398287..c900f78e 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -399,7 +399,7 @@ router.get('/me', requireAuth, async (req, res) => {
const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);
await subjects.ensureUserSubject(db, req.user);
res.json({
-- user: sanitizeUser(req.user),
++ user: { ...sanitizeUser(req.user), level: await require('../levels/store').total(db, req.user.id) },
preferences: prefs || { theme_id: 'vibe' },
features: { qr_signin: require('./qr-signin').enabled(getConfig(req)) },
});
ddiff --git a/server/coins/wallet.js b/server/coins/wallet.js
iindex a18548b1..db7ce8b6 100644
---- a/server/coins/wallet.js
++++ b/server/coins/wallet.js
@@@ -102,6 +102,7 @@ async function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, i
ref,
idempotency_key,
});
++ if (sign > 0) await require('../levels/coins').onCredit(db, { user_id: uid, app_id, delta: amount, reason, idempotency_key });
return { balance, deduped: false };
});
return run();
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 2eb31325..a02d4bed 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -208,6 +208,8 @@ async function networkPart(db, userId) {
await byUser('profile_changes.json', 'user_profile_changes');
await byUser('sign_in_log.json', 'ip_log');
await byUser('emails_sent.json', 'email_delivery_log');
++ const experience = await rowsOf(db, 'experience_events', 'user_id = ?', [u.id]);
++ add('experience.json', { totals: await rowsOf(db, 'person_levels', 'user_id = ?', [u.id]), events: experience.rows }, experience.truncated);
const coins = await rowsOf(db, 'coin_transactions', 'user_id = ?', [u.id]);
add('opencoins.json', { balance: (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(u.id) || { balance: 0 }).balance, ledger: coins.rows }, coins.truncated);
add('modules.json', await tableExists(db, 'user_modules') ? (await db.prepare('SELECT namespace, version, revision, data, updated_at FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(u.subject_id))
@@@ -360,6 +362,8 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
'notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
'ip_log', 'email_delivery_log', 'user_theme_history', 'user_theme_site_overrides']) await del(t, `user_id IN ${inU}`, userIds);
++ await del('experience_events', `user_id IN ${inU}`, userIds);
++ await del('person_levels', `user_id IN ${inU}`, userIds);
await del('chat_app_pending_links', `confirmed_by IN ${inU}`, userIds);
if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);
// Notifications this person sent others stay, without their name or picture.
ddiff --git a/server/identity/account-merge.js b/server/identity/account-merge.js
iindex bf4c8de2..bf10bfac 100644
---- a/server/identity/account-merge.js
++++ b/server/identity/account-merge.js
@@@ -167,6 +167,7 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
await wallet.credit(db, { user_id: intoUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:in` });
moved.coins = pre.balance;
}
++ await require('../levels/store').merge(db, fromUser.id, intoUser.id);
const mods = await modules.onSubjectMerged(db, { from, into });
moved.modules = mods.moved + (mods.filled || 0);
// Consent is the person's own and is never assumed: the folded-in account's active choices are revoked
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 8d77aa61..433108fc 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -46,6 +46,7 @@ const DEFAULT_GRANTS = [
// OpenVibe.Quest pays a completed quest's OpenCoins (app_id quest, idempotency_key = the completion id), resolving
// the person's subject to the wallet first, and subscribes to the events it counts (plan T19).
['quest', 'network.coins.credit', SELF_AUDIENCE, ['quest']],
++ ['quest', 'network.levels.award', SELF_AUDIENCE, []],
['quest', 'identity.subject.resolve', SELF_AUDIENCE, []],
['quest', 'events.subscription.manage', 'openvibe.events', []],
// They subscribe to network.account.export_requested and network.account.deleted at boot (ADR-033).
ddiff --git a/server/index.js b/server/index.js
iindex 000be855..5a09ad1a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -838,6 +838,8 @@ app.use('/api/themes', require('./themes/routes'));
// OpenCoins wallet API (user-facing, Bearer JWT)
app.use('/api/coins', createCoinsRoutes(db, requireAuth));
++app.use('/api/v1/levels', require('./levels/routes').publicRouter({ db, requireAuth }));
++app.use('/api/v1/me/levels', require('./levels/routes').settingsRouter({ db, requireAuth }));
// The wallet's own API (MONEY.md D12): the person's Balance and Vibes through Billing, their activity, the projects
// they administer, and the Vibes Pay charge the pay sheet shows. Bearer person tokens; see server/wallet/routes.js.
app.use('/api/wallet', rateLimit({ windowMs: 60_000, max: 120 }), createWalletRoutes(db, requireAuth, { selfToken }));
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex eb15a7cc..bd8d5760 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -47,6 +47,7 @@ function ownSourceSystem(req, res, next) {
}
router.use(requireServiceToken);
++require('../levels/routes').internalRoutes(router, principals);
router.use('/chat-app-links', principals.guard('identity.subject.resolve'), require('../chat-apps/links').internalRouter());
// Identity lookups accept a service token with identity.subject.resolve (or the key, as before).
router.get('/identity/resolve', principals.guard('identity.subject.resolve'));
ddiff --git a/server/levels/coins.js b/server/levels/coins.js
nnew file mode 100644
iindex 00000000..2a37e4e5
---- /dev/null
++++ b/server/levels/coins.js
@@@ -0,0 +1,11 @@
++'use strict';
++/** Only proven earn sources; a positive delta also includes refunds, transfers and grants. */
++function earned(row) {
++ return Number(row.delta) > 0 && row.app_id === 'quest' && /^quest:[a-z0-9][a-z0-9-]*$/.test(row.reason || '')
++ && typeof row.idempotency_key === 'string' && !row.idempotency_key.endsWith(':in');
++}
++async function onCredit(db, row) {
++ if (!earned(row)) return;
++ await require('./store').append(db, { user_id: row.user_id, service: 'opencoins', reason: 'quest.completed', amount: Number(row.delta), idempotency_key: `coin:${row.idempotency_key}` });
++}
++module.exports = { earned, onCredit };
ddiff --git a/server/levels/routes.js b/server/levels/routes.js
nnew file mode 100644
iindex 00000000..8a954b76
---- /dev/null
++++ b/server/levels/routes.js
@@@ -0,0 +1,41 @@
++'use strict';
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const store = require('./store');
++const handle = fn => async (req, res, next) => { try { await fn(req, res); } catch (e) { if (e.status) return res.status(e.status).json({ error: e.code, code: e.code }); next(e); } };
++function publicRouter({ db, requireAuth }) {
++ const router = express.Router();
++ router.use(rateLimit({ windowMs: 60000, max: 240 }));
++ router.use((req, res, next) => { res.set('Cache-Control', 'public, max-age=30'); next(); });
++ router.get('/batch', handle(async (req, res) => res.json(await store.batch(db, String(req.query.subjects || '').split(',').filter(Boolean)))));
++ router.get('/people/:username', handle(async (req, res) => res.json(await store.byUsername(db, req.params.username))));
++ router.get('/board', (req, res, next) => {
++ res.vary('Authorization'); res.vary('Cookie');
++ if (req.headers.authorization || req.cookies?.ov_token) {
++ res.set('Cache-Control', 'private, max-age=30');
++ return requireAuth(req, res, next);
++ }
++ next();
++ }, handle(async (req, res) => res.json(await store.board(db, req.user?.id))));
++ return router;
++}
++function settingsRouter({ db, requireAuth }) {
++ const router = express.Router();
++ router.use(requireAuth, (req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
++ router.get('/', handle(async (req, res) => {
++ const row = await db.prepare('SELECT leaderboard FROM person_levels WHERE user_id = ?').get(req.user.id);
++ res.json({ leaderboard: row?.leaderboard !== false });
++ }));
++ router.put('/', handle(async (req, res) => {
++ if (typeof req.body?.leaderboard !== 'boolean') return res.status(400).json({ error: 'levels.invalid_setting' });
++ await db.prepare('INSERT INTO person_levels(user_id, leaderboard) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET leaderboard = EXCLUDED.leaderboard').run(req.user.id, req.body.leaderboard);
++ res.json({ leaderboard: req.body.leaderboard });
++ }));
++ return router;
++}
++function internalRoutes(router, principals) {
++ router.post('/levels/award', principals.guard('network.levels.award'),
++ rateLimit({ windowMs: 60000, max: 120, keyGenerator: req => req.principal.sub }),
++ handle(async (req, res) => res.status(201).json(await store.award(req.app.locals.db, req.principal.sub.replace(/^svc:/, ''), req.body || {}))));
++}
++module.exports = { publicRouter, settingsRouter, internalRoutes };
ddiff --git a/server/levels/store.js b/server/levels/store.js
nnew file mode 100644
iindex 00000000..18bef537
---- /dev/null
++++ b/server/levels/store.js
@@@ -0,0 +1,94 @@
++'use strict';
++const { levelOf, reasons, reasonName } = require('openvibe-contracts').levels;
++const ACTIVE = 'COALESCE(u.is_anon, 0) = 0 AND COALESCE(u.is_banned, 0) = 0 AND u.deleted_at IS NULL AND u.merged_into IS NULL';
++const PERSON_DAILY = 10000;
++const SERVICE_DAILY = 1000000;
++function fail(status, code) { const e = new Error(code); e.status = status; e.code = code; throw e; }
++async function total(db, userId) {
++ const row = await db.prepare('SELECT xp, level, leaderboard FROM person_levels WHERE user_id = ?').get(userId);
++ const xp = Number(row?.xp || 0);
++ return { xp, ...levelOf(xp) };
++}
++/** Caller owns the transaction. The service quota and person rows serialize concurrent awards. */
++async function append(db, { user_id, service, reason, amount, idempotency_key, at, backfill = false }) {
++ if (!reasons[reason] || !Number.isSafeInteger(amount) || amount < 1 || amount > (backfill ? 1000000000000 : PERSON_DAILY)) fail(400, 'levels.invalid_award');
++ if (typeof idempotency_key !== 'string' || !idempotency_key.length || idempotency_key.length > 200) fail(400, 'levels.invalid_key');
++ const user = await db.prepare(`SELECT u.id FROM users u WHERE u.id = ? AND ${ACTIVE}`).get(user_id);
++ if (!user) fail(404, 'levels.person_not_found');
++ const duplicate = await db.prepare('SELECT 1 FROM experience_events WHERE service = ? AND idempotency_key = ?').get(service, idempotency_key);
++ if (duplicate) fail(409, 'levels.duplicate');
++ if (!backfill) {
++ const quota = await db.prepare(`INSERT INTO experience_service_days(service, day, amount) VALUES (?, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::date, ?)
++ ON CONFLICT(service, day) DO UPDATE SET amount = experience_service_days.amount + EXCLUDED.amount
++ WHERE experience_service_days.amount + EXCLUDED.amount <= ? RETURNING amount`).get(service, amount, SERVICE_DAILY);
++ if (!quota) fail(429, 'levels.service_daily_limit');
++ }
++ await db.prepare('INSERT INTO person_levels(user_id) VALUES (?) ON CONFLICT DO NOTHING').run(user_id);
++ const current = await db.prepare('SELECT xp FROM person_levels WHERE user_id = ? FOR UPDATE').get(user_id);
++ if (!backfill) {
++ const today = await db.prepare("SELECT COALESCE(SUM(amount), 0) AS amount FROM experience_events WHERE user_id = ? AND earned_at >= date_trunc('day', CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'").get(user_id);
++ if (Number(today.amount) + amount > PERSON_DAILY) fail(429, 'levels.person_daily_limit');
++ }
++ const xp = Number(current.xp) + amount;
++ if (xp > 1000000000000) fail(429, 'levels.total_limit');
++ const added = await db.prepare(`INSERT INTO experience_events(user_id, service, reason, amount, idempotency_key, earned_at)
++ VALUES (?, ?, ?, ?, ?, COALESCE(?::timestamptz, CURRENT_TIMESTAMP)) ON CONFLICT DO NOTHING RETURNING id`).get(user_id, service, reason, amount, idempotency_key, at || null);
++ if (!added) fail(409, 'levels.duplicate');
++ await db.prepare('UPDATE person_levels SET xp = ?, level = ? WHERE user_id = ?').run(xp, levelOf(xp).level, user_id);
++ return { xp, ...levelOf(xp) };
++}
++async function award(db, service, body) {
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(body.subject || '')) fail(400, 'levels.invalid_subject');
++ if (typeof body.idempotency_key !== 'string' || body.idempotency_key.length > 160) fail(400, 'levels.invalid_key');
++ const user = await db.prepare('SELECT id FROM users WHERE subject_id = ?').get(body.subject);
++ if (!user) fail(404, 'levels.person_not_found');
++ await db.tx(() => append(db, { user_id: user.id, service, reason: body.reason, amount: body.amount, idempotency_key: body.idempotency_key }));
++ return person(db, user.id);
++}
++async function person(db, userId) {
++ const progress = await total(db, userId);
++ const rank = await db.prepare(`WITH ranked AS (SELECT u.id, ROW_NUMBER() OVER (ORDER BY COALESCE(l.xp, 0) DESC, u.id) AS rank
++ FROM users u LEFT JOIN person_levels l ON l.user_id = u.id WHERE ${ACTIVE} AND COALESCE(l.leaderboard, true)) SELECT rank FROM ranked WHERE id = ?`).get(userId);
++ const recent = await db.prepare('SELECT reason, amount, earned_at FROM experience_events WHERE user_id = ? ORDER BY earned_at DESC, id DESC LIMIT 5').all(userId);
++ return { ...progress, rank: rank ? Number(rank.rank) : null, recent: recent.map(e => ({ name: reasonName(e.reason), amount: Number(e.amount), at: new Date(e.earned_at).toISOString() })) };
++}
++async function byUsername(db, username) {
++ const user = await db.prepare(`SELECT u.id FROM users u WHERE LOWER(u.username) = LOWER(?) AND ${ACTIVE}`).get(username);
++ if (!user) fail(404, 'levels.person_not_found');
++ return person(db, user.id);
++}
++async function batch(db, subjects) {
++ if (!Array.isArray(subjects) || subjects.length > 100 || subjects.some(s => !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(s))) fail(400, 'levels.invalid_batch');
++ if (!subjects.length) return { levels: {} };
++ const rows = await db.prepare(`SELECT u.subject_id, COALESCE(l.level, 1) AS level FROM users u LEFT JOIN person_levels l ON l.user_id = u.id WHERE ${ACTIVE} AND u.subject_id IN (${subjects.map(() => '?').join(',')})`).all(...subjects);
++ return { levels: Object.fromEntries(rows.map(r => [r.subject_id, r.level])) };
++}
++async function board(db, userId) {
++ async function period(weekly) {
++ const score = weekly ? 'COALESCE(w.xp, 0)' : 'COALESCE(l.xp, 0)';
++ const rows = await db.prepare(`WITH ranked AS (
++ SELECT u.id, u.subject_id AS subject, u.username, COALESCE(l.level, 1) AS level, ${score} AS xp,
++ ROW_NUMBER() OVER (ORDER BY ${score} DESC, u.id) AS rank
++ FROM users u LEFT JOIN person_levels l ON l.user_id = u.id
++ ${weekly ? "LEFT JOIN (SELECT user_id, SUM(amount) AS xp FROM experience_events WHERE earned_at >= date_trunc('week', CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' GROUP BY user_id) w ON w.user_id = u.id" : ''}
++ WHERE ${ACTIVE} AND u.subject_id IS NOT NULL AND COALESCE(l.leaderboard, true)
++ ) SELECT * FROM ranked WHERE rank <= 50 OR ABS(rank - (SELECT rank FROM ranked WHERE id = ?)) <= 5 ORDER BY rank`).all(userId || 0);
++ const me = rows.find(r => Number(r.id) === Number(userId));
++ const clean = r => ({ subject: r.subject, username: r.username, level: Number(r.level), xp: Number(r.xp), rank: Number(r.rank) });
++ return { top: rows.filter(r => Number(r.rank) <= 50).map(clean), me: me ? clean(me) : null, around: me ? rows.filter(r => Math.abs(Number(r.rank) - Number(me.rank)) <= 5).map(clean) : [] };
++ }
++ return { all_time: await period(false), week: await period(true) };
++}
++module.exports = { total, append, award, person, byUsername, batch, board, ACTIVE, PERSON_DAILY, SERVICE_DAILY };
++
++/** Existing account merge transaction: preserve earned history and the survivor's visibility choice. */
++async function merge(db, fromId, intoId) {
++ await db.prepare('INSERT INTO person_levels(user_id) VALUES (?), (?) ON CONFLICT DO NOTHING').run(fromId, intoId);
++ const rows = await db.prepare('SELECT user_id, xp FROM person_levels WHERE user_id IN (?, ?) ORDER BY user_id FOR UPDATE').all(fromId, intoId);
++ const xp = rows.reduce((sum, row) => sum + Number(row.xp), 0);
++ if (xp > 1000000000000) fail(409, 'levels.total_limit');
++ await db.prepare('UPDATE experience_events SET user_id = ? WHERE user_id = ?').run(intoId, fromId);
++ await db.prepare('UPDATE person_levels SET xp = ?, level = ? WHERE user_id = ?').run(xp, levelOf(xp).level, intoId);
++ await db.prepare('DELETE FROM person_levels WHERE user_id = ?').run(fromId);
++}
++module.exports.merge = merge;
ddiff --git a/server/profile/public.js b/server/profile/public.js
iindex 229e6fe0..d09a450e 100644
---- a/server/profile/public.js
++++ b/server/profile/public.js
@@@ -311,6 +311,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
avatar_url: avatarUrl(u.username),
profile_url: profileUrl(u.username),
private: !Number(u.profile_public),
++ level: await require('../levels/store').total(db, u.id),
};
if (out.private) return out;
const inv = await wearsOf(u.subject_id);
@@@ -345,7 +346,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
// A person's public profile lives on openvibe.network (the owner, 11-08: "OpenVibe.My is for users' stuff,
// the profiles should be on .Network"). openvibe.my is the person's own hub: /@name there moves here.
-- res.send(renderPage(u, inv, SITE, page.places));
++ res.send(renderPage(u, inv, SITE, page.places, await require('../levels/store').total(db, u.id)));
} catch (err) { next(err); }
}
@@@ -475,7 +476,7 @@ ${sorted.map((g) => {
<p class="pf-note"><a href="${esc(all)}">${inv.more ? 'Every item' : 'This inventory'} on OpenVibe.Inventory</a> · <a href="${INVENTORY_SITE}/items">Every item there is</a></p></section>`;
}
--function renderPage(u, inv, origin = SITE, places = { spaces: [], community: [], videos: [] }) {
++function renderPage(u, inv, origin = SITE, places = { spaces: [], community: [], videos: [] }, level = null) {
const canonicalUrl = `${origin}/@${encodeURIComponent(u.username)}`;
const name = u.display_name || u.username;
const isPublic = !!inv;
@@@ -600,7 +601,7 @@ ${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links
<div class="pf-banner" aria-hidden="true"></div>
<div class="pf-head">
<div class="pf-pic"><img class="pf-avatar" src="${esc(avatarUrl(u.username))}" alt="" width="112" height="112">${hatGlyph ? `<span class="pf-hat" title="${esc(hat.name)}" aria-hidden="true">${esc(hatGlyph)}</span>` : ''}</div>
--<div class="pf-who"><h1>${badgeId ? `<img class="pf-name-badge" src="https://openvibe.media/o/${badgeId}" alt="" title="${esc(badge.name)}" width="32" height="32">` : ''}${fxClass ? `<span class="ov-fx ${fxClass}" title="${esc(fx.name)}">${esc(name)}</span>` : esc(name)}</h1><p class="pf-handle">@${esc(u.username)}</p></div>
++<div class="pf-who">${level ? `<p><a href="https://openvibe.quest/levels">Level ${level.level}</a> · ${level.xp} experience <progress aria-label="Experience to next level" value="${level.into}" max="${level.span}"></progress></p>` : ''}<h1>${badgeId ? `<img class="pf-name-badge" src="https://openvibe.media/o/${badgeId}" alt="" title="${esc(badge.name)}" width="32" height="32">` : ''}${fxClass ? `<span class="ov-fx ${fxClass}" title="${esc(fx.name)}">${esc(name)}</span>` : esc(name)}</h1><p class="pf-handle">@${esc(u.username)}</p></div>
</div>
${bio ? `<p class="pf-bio">${esc(bio)}</p>` : ''}
${isPublic && Array.isArray(publicLinks) && publicLinks.length ? `<nav aria-label="${esc(name)}'s links"><ul class="pf-links">${publicLinks.slice(0, 8).map((link) => { let href = ''; try { const parsed = new URL(link.url); if (parsed.protocol === 'https:' && !parsed.username && !parsed.password) href = parsed.href; } catch {} return href ? `<li><a rel="me noopener noreferrer" href="${esc(href)}">${esc(String(link.label || '').slice(0, 40))}</a></li>` : ''; }).join('')}</ul></nav>` : ''}
ddiff --git a/server/public-cors.js b/server/public-cors.js
iindex c1a270cf..2a60742c 100644
---- a/server/public-cors.js
++++ b/server/public-cors.js
@@@ -28,6 +28,7 @@ const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\
function isPublicDiscoveryPath(p) {
const path = String(p || '');
if (path === '/.well-known/openvibe') return true;
++ if (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
if (path === '/api/v1/frame/today' || path === '/api/v1/frame/today/') return true;
if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every site
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 5c296d35..34829a6b 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -226,6 +226,9 @@ const server = http.createServer(app);
.run(principal, nodeId, subjectId, crypto.randomBytes(32).toString('hex'));
pairedNodes.push([principal, nodeId, subjectId]);
}
++ await require('../server/levels/store').award(db, 'quest', { subject: dana.subject_id, reason: 'quest.completed', amount: 25, idempotency_key: 'deletion-level' });
++ const experiencePart = (await accountData.networkPart(db, dana.id)).files.find(f => f.name === 'experience.json');
++ assert.equal(experiencePart.content.events.length, 1, 'experience exported');
const oldToken = tok(dana);
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);
const nodeEvents = await events('network.link.revoked');
@@@ -245,7 +248,7 @@ const server = http.createServer(app);
assert.match(gone.username, /^deleted-/);
assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);
assert.match((await db.prepare('SELECT username FROM users WHERE id = ?').get(old.id)).username, /^deleted-/, 'the merged-in account too');
-- for (const [t, col] of [['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id']]) {
++ for (const [t, col] of [['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id'], ['experience_events', 'user_id'], ['person_levels', 'user_id']]) {
assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id)).n, 0, `${t} erased`);
}
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id)).n, 0);
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex af3762c3..feb4c3ab 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/levels-http.test.js b/test/levels-http.test.js
nnew file mode 100644
iindex 00000000..ebcc6636
---- /dev/null
++++ b/test/levels-http.test.js
@@@ -0,0 +1,41 @@
++'use strict';
++const assert = require('assert');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const { publicRouter, settingsRouter, internalRoutes } = require('../server/levels/routes');
++const { gate } = require('../server/public-cors');
++(async () => {
++ const db = getDb(); const subject = require('openvibe-contracts').ids.newId('user');
++ const user = await db.prepare("INSERT INTO users(username,password_hash,subject_id) VALUES ('levels_http','unused',?) RETURNING id").get(subject);
++ const auth = (req,res,next) => { if (req.headers.authorization !== 'Bearer test-person') return res.sendStatus(401); req.user = user; next(); };
++ const app = express(); app.locals.db = db; app.use(express.json());
++ app.use(gate((_req,_res,next) => next()));
++ app.use('/api/v1/levels', publicRouter({ db, requireAuth: auth }));
++ app.use('/api/v1/me/levels', settingsRouter({ db, requireAuth: auth }));
++ const internal = express.Router();
++ internalRoutes(internal, { guard: cap => (req,res,next) => {
++ assert.equal(cap,'network.levels.award');
++ if(req.headers.authorization !== 'Bearer test-service') return res.sendStatus(403);
++ req.principal = { sub:'svc:quest' };next();
++ }}); app.use('/internal',internal);
++ const server = app.listen(0,'127.0.0.1'); await new Promise(r => server.once('listening',r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const get = (path,options) => fetch(base+path,options);
++ try {
++ let r = await get('/api/v1/levels/people/levels_http'); assert.equal(r.status,200); assert.equal(r.headers.get('access-control-allow-origin'),'*'); assert.equal(r.headers.get('cache-control'),'public, max-age=30');
++ r = await get('/api/v1/levels/batch?subjects='+subject);assert.deepEqual(await r.json(),{levels:{[subject]:1}});
++ r = await get('/api/v1/levels/board',{headers:{Authorization:'Bearer test-person'}});assert.equal(r.headers.get('cache-control'),'private, max-age=30'); assert.match(r.headers.get('vary'),/Authorization/); assert((await r.json()).all_time.me);
++ const award = {subject,reason:'quest.completed',amount:25,idempotency_key:'http-completion'};
++ const options = {method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer test-service'},body:JSON.stringify(award)};
++ assert.equal((await get('/internal/levels/award',{...options,headers:{'Content-Type':'application/json'}})).status,403);
++ assert.equal((await get('/internal/levels/award',options)).status,201);
++ r = await get('/internal/levels/award',options); assert.equal(r.status,409); assert.equal((await r.json()).code,'levels.duplicate');
++ r = await get('/api/v1/me/levels',{method:'PUT',headers:{'Content-Type':'application/json',Authorization:'Bearer test-person'},body:'{"leaderboard":false}'});assert.equal(r.status,200);
++ r = await get('/api/v1/levels/board',{headers:{Authorization:'Bearer test-person'}});assert.equal((await r.json()).all_time.me,null);
++ await db.prepare('UPDATE users SET is_banned = 1 WHERE id = ?').run(user.id);
++ assert.equal((await get('/api/v1/levels/people/levels_http')).status,404);
++ r = await get('/api/v1/levels/batch?subjects='+subject);assert.deepEqual(await r.json(),{levels:{}});
++ console.log('levels HTTP: public CORS/cache, private own rank, capability gate, duplicate 409, opt-out and banned exclusion passed');
++ } finally { server.close(); }
++ process.exit(0);
++})().catch(e => { console.error(e);process.exit(1); });
ddiff --git a/test/levels.test.js b/test/levels.test.js
nnew file mode 100644
iindex 00000000..62339dd0
---- /dev/null
++++ b/test/levels.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++const assert = require('assert');
++const { getDb } = require('../server/db/database');
++const levels = require('../server/levels/store');
++const wallet = require('../server/coins/wallet');
++const { backfill } = require('../scripts/backfill-levels');
++(async () => {
++ const db = getDb();
++ const { ids } = require('openvibe-contracts');
++ const subject = ids.newId('user');
++ const user = await db.prepare("INSERT INTO users(username, password_hash, subject_id) VALUES ('levels_probe', 'unused', ?) RETURNING id").get(subject);
++ const award = (key, amount = 25) => levels.award(db, 'quest', { subject, reason: 'quest.completed', amount, idempotency_key: key });
++ assert.equal((await levels.byUsername(db, 'levels_probe')).level, 1);
++ await award('one', 100);
++ await assert.rejects(award('one', 100), e => e.status === 409);
++ assert.equal((await levels.byUsername(db, 'levels_probe')).xp, 100);
++ assert.equal((await levels.batch(db, [subject])).levels[subject], 2);
++ await assert.rejects(levels.batch(db, Array(101).fill(subject)), e => e.status === 400);
++ await wallet.credit(db, { user_id: user.id, app_id: 'quest', amount: 10, reason: 'quest:first-follow', idempotency_key: 'qcp:earned' });
++ await wallet.credit(db, { user_id: user.id, app_id: 'quest', amount: 10, reason: 'quest:first-follow', idempotency_key: 'qcp:earned' });
++ await wallet.credit(db, { user_id: user.id, app_id: 'live', amount: 10, reason: 'Refund: example', idempotency_key: 'refund:1' });
++ await wallet.credit(db, { user_id: user.id, app_id: 'live', amount: 10, reason: 'Admin grant', idempotency_key: 'admin:1' });
++ assert.equal((await levels.person(db, user.id)).xp, 110, 'only earned coins, exactly once');
++ const other = await db.prepare("INSERT INTO users(username, password_hash, subject_id) VALUES ('levels_other', 'unused', ?) RETURNING id").get(ids.newId('user'));
++ await wallet.transfer(db, { from_user_id: user.id, to_user_id: other.id, app_id: 'quest', amount: 5, reason: 'quest:first-follow', idempotency_key: 'transfer:1' });
++ assert.equal((await levels.person(db, other.id)).xp, 0, 'transfer never earns');
++ const board = await levels.board(db, user.id);
++ assert(board.all_time.me); assert(board.week.me);
++ await db.prepare('UPDATE person_levels SET leaderboard = false WHERE user_id = ?').run(user.id);
++ assert.equal((await levels.board(db, user.id)).all_time.me, null);
++ assert.equal((await levels.byUsername(db, 'levels_probe')).level, 2);
++ await assert.rejects(award('over-cap', 10000), e => e.status === 429);
++ assert.equal((await levels.person(db, user.id)).xp, 110, 'quota refusal rolls back');
++ await db.prepare('UPDATE users SET is_banned = 1 WHERE id = ?').run(other.id);
++ assert.equal((await levels.batch(db, [subject])).levels[subject], 2);
++ const dry = await backfill(db); assert.equal(dry.awarded, 0); assert.equal(dry.existing, 1);
++ const applied = await backfill(db, { apply: true }); assert.equal(applied.awarded, 0);
++ await db.prepare('UPDATE users SET is_banned = 0 WHERE id = ?').run(other.id);
++ await db.tx(() => levels.merge(db, user.id, other.id));
++ assert.equal((await levels.total(db, other.id)).xp, 110);
++ assert.equal((await levels.total(db, user.id)).xp, 0);
++ const p = await levels.person(db, other.id); assert(p.recent.every(r => r.name === 'Finished a quest')); assert(!JSON.stringify(p).includes('quest:'));
++ console.log('levels: duplicate refused; earned coins once; refunds/transfers excluded; privacy, caps and dry-run passed');
++ process.exit(0);
++})().catch(e => { console.error(e); process.exit(1); });
ddiff --git a/test/public-profile.test.js b/test/public-profile.test.js
iindex 28fc6fe4..4a7afa51 100644
---- a/test/public-profile.test.js
++++ b/test/public-profile.test.js
@@@ -185,7 +185,7 @@ const INSTANCES = [
assert.ok(!r.text.includes('secret bio') && !r.text.includes('Member since') && !r.text.includes('class="pf-grid"'));
assert.ok(r.text.includes('<h1>Hidden</h1>') && !r.text.includes('class="pf-hat"') && !r.text.includes('/shared/items.css'), 'a hidden profile wears nothing');
r = await get('/api/v1/profiles/hidden');
-- assert.deepStrictEqual(JSON.parse(r.text).profile, { username: 'hidden', display_name: 'Hidden', avatar_url: 'https://openvibe.network/avatar/hidden?s=160', profile_url: 'https://openvibe.network/@hidden', private: true });
++ assert.deepStrictEqual(JSON.parse(r.text).profile, { username: 'hidden', display_name: 'Hidden', avatar_url: 'https://openvibe.network/avatar/hidden?s=160', profile_url: 'https://openvibe.network/@hidden', private: true, level: { xp: 0, level: 1, into: 0, span: 100, next_at: 100 } });
// No profile: anonymous, deleted, unknown.
for (const name of ['ghost', 'gone', 'nobody', 'x']) {
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 768fde33..72b7edaf 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.