openvibers/openvibe.network Commits
One earned Network level, ledger and public leaderboards (#762)
Changes
nnew file mode 100644@@@ -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)++);@@@ -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",@@@ -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",@@@ -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; }++ });++})();@@@ -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>nnew file mode 100644@@@ -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 };@@@ -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)) },});@@@ -102,6 +102,7 @@ async function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, iref,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();@@@ -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.@@@ -167,6 +167,7 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubjawait 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@@@ -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).@@@ -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 }));@@@ -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'));nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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;@@@ -311,6 +311,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'httpavatar_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 = 'httpres.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>` : ''}@@@ -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 readsif (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@@@ -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);@@@ -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.nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });@@@ -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']) {@@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -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)
+);
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.187.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2303,9 +2303,9 @@
@@ -2303,9 +2303,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.187.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -615,3 +615,18 @@ function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
@@ -615,3 +615,18 @@ function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccounts(); }
function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccounts(); }
function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
function signOutAll() { OpenVibeAccountSwitcher.logoutAll(); }
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; }
+ });
+})();
@@ -215,6 +215,8 @@
@@ -215,6 +215,8 @@
<div class="form-field">
<div class="form-field">
<label>Public Profile</label>
<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 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>
<div class="btn-row">
<div class="btn-row">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -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 };
@@ -399,7 +399,7 @@ router.get('/me', requireAuth, async (req, res) => {
@@ -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);
const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);
await subjects.ensureUserSubject(db, req.user);
await subjects.ensureUserSubject(db, req.user);
res.json({
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' },
preferences: prefs || { theme_id: 'vibe' },
features: { qr_signin: require('./qr-signin').enabled(getConfig(req)) },
features: { qr_signin: require('./qr-signin').enabled(getConfig(req)) },
});
});
@@ -102,6 +102,7 @@ async function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, i
@@ -102,6 +102,7 @@ async function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, i
ref,
ref,
idempotency_key,
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 { balance, deduped: false };
});
});
return run();
return run();
@@ -208,6 +208,8 @@ async function networkPart(db, userId) {
@@ -208,6 +208,8 @@ async function networkPart(db, userId) {
await byUser('profile_changes.json', 'user_profile_changes');
await byUser('profile_changes.json', 'user_profile_changes');
await byUser('sign_in_log.json', 'ip_log');
await byUser('sign_in_log.json', 'ip_log');
await byUser('emails_sent.json', 'email_delivery_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]);
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('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))
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() } = {}) {
@@ -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',
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',
'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);
'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);
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);
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.
// Notifications this person sent others stay, without their name or picture.
@@ -167,6 +167,7 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
@@ -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` });
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;
moved.coins = pre.balance;
}
}
+ await require('../levels/store').merge(db, fromUser.id, intoUser.id);
const mods = await modules.onSubjectMerged(db, { from, into });
const mods = await modules.onSubjectMerged(db, { from, into });
moved.modules = mods.moved + (mods.filled || 0);
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
// Consent is the person's own and is never assumed: the folded-in account's active choices are revoked
@@ -46,6 +46,7 @@ const DEFAULT_GRANTS = [
@@ -46,6 +46,7 @@ const DEFAULT_GRANTS = [
// OpenVibe.Quest pays a completed quest's OpenCoins (app_id quest, idempotency_key = the completion id), resolving
// 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).
// 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.coins.credit', SELF_AUDIENCE, ['quest']],
+ ['quest', 'network.levels.award', SELF_AUDIENCE, []],
['quest', 'identity.subject.resolve', SELF_AUDIENCE, []],
['quest', 'identity.subject.resolve', SELF_AUDIENCE, []],
['quest', 'events.subscription.manage', 'openvibe.events', []],
['quest', 'events.subscription.manage', 'openvibe.events', []],
// They subscribe to network.account.export_requested and network.account.deleted at boot (ADR-033).
// They subscribe to network.account.export_requested and network.account.deleted at boot (ADR-033).
@@ -838,6 +838,8 @@ app.use('/api/themes', require('./themes/routes'));
@@ -838,6 +838,8 @@ app.use('/api/themes', require('./themes/routes'));
// OpenCoins wallet API (user-facing, Bearer JWT)
// OpenCoins wallet API (user-facing, Bearer JWT)
app.use('/api/coins', createCoinsRoutes(db, requireAuth));
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
// 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.
// 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 }));
app.use('/api/wallet', rateLimit({ windowMs: 60_000, max: 120 }), createWalletRoutes(db, requireAuth, { selfToken }));
@@ -47,6 +47,7 @@ function ownSourceSystem(req, res, next) {
@@ -47,6 +47,7 @@ function ownSourceSystem(req, res, next) {
}
}
router.use(requireServiceToken);
router.use(requireServiceToken);
+require('../levels/routes').internalRoutes(router, principals);
router.use('/chat-app-links', principals.guard('identity.subject.resolve'), require('../chat-apps/links').internalRouter());
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).
// Identity lookups accept a service token with identity.subject.resolve (or the key, as before).
router.get('/identity/resolve', principals.guard('identity.subject.resolve'));
router.get('/identity/resolve', principals.guard('identity.subject.resolve'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -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;
@@ -311,6 +311,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -311,6 +311,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
avatar_url: avatarUrl(u.username),
avatar_url: avatarUrl(u.username),
profile_url: profileUrl(u.username),
profile_url: profileUrl(u.username),
private: !Number(u.profile_public),
private: !Number(u.profile_public),
+ level: await require('../levels/store').total(db, u.id),
};
};
if (out.private) return out;
if (out.private) return out;
const inv = await wearsOf(u.subject_id);
const inv = await wearsOf(u.subject_id);
@@ -345,7 +346,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -345,7 +346,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
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,
// 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.
// 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); }
} catch (err) { next(err); }
}
}
@@ -475,7 +476,7 @@ ${sorted.map((g) => {
@@ -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>`;
<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 canonicalUrl = `${origin}/@${encodeURIComponent(u.username)}`;
const name = u.display_name || u.username;
const name = u.display_name || u.username;
const isPublic = !!inv;
const isPublic = !!inv;
@@ -600,7 +601,7 @@ ${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links
@@ -600,7 +601,7 @@ ${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links
<div class="pf-banner" aria-hidden="true"></div>
<div class="pf-banner" aria-hidden="true"></div>
<div class="pf-head">
<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-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>
</div>
${bio ? `<p class="pf-bio">${esc(bio)}</p>` : ''}
${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>` : ''}
${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>` : ''}
@@ -28,6 +28,7 @@ const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\
@@ -28,6 +28,7 @@ const CONTRACT_RE = /^\/contracts\/[a-z0-9-]+(?:\/[a-z0-9-]+)?\/[a-z0-9_.-]+\.v\
function isPublicDiscoveryPath(p) {
function isPublicDiscoveryPath(p) {
const path = String(p || '');
const path = String(p || '');
if (path === '/.well-known/openvibe') return true;
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/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 (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
if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every site
@@ -226,6 +226,9 @@ const server = http.createServer(app);
@@ -226,6 +226,9 @@ const server = http.createServer(app);
.run(principal, nodeId, subjectId, crypto.randomBytes(32).toString('hex'));
.run(principal, nodeId, subjectId, crypto.randomBytes(32).toString('hex'));
pairedNodes.push([principal, nodeId, subjectId]);
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);
const oldToken = tok(dana);
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);
const nodeEvents = await events('network.link.revoked');
const nodeEvents = await events('network.link.revoked');
@@ -245,7 +248,7 @@ const server = http.createServer(app);
@@ -245,7 +248,7 @@ const server = http.createServer(app);
assert.match(gone.username, /^deleted-/);
assert.match(gone.username, /^deleted-/);
assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);
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');
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 ${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);
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id)).n, 0);
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -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); });
@@ -185,7 +185,7 @@ const INSTANCES = [
@@ -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('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');
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');
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.
// No profile: anonymous, deleted, unknown.
for (const name of ['ghost', 'gone', 'nobody', 'x']) {
for (const name of ['ghost', 'gone', 'nobody', 'x']) {
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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.
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.