openvibers/openvibe.network Commits
Keep probe and test accounts off Network's public surfaces (#839)
Changes
nnew file mode 100644@@@ -0,0 +1,16 @@++-- phase: expand++-- Test accounts: the production probes and the accounts older manual tests signed up. A test account is a real++-- account (it can sign in, act and be tested as itself) that must never reach a public surface: member lists, feeds,++-- leaderboards and the account counts. The column is the authority; OpenVibe.Network's /api/auth/register sets it for++-- every account whose name starts with ovprobe_ (the probe tooling's sign-up path), and this migration marks the++-- accounts that existed before the column did. Matching is on the exact names below, never a pattern, so a person++-- whose name merely looks like a test is not hidden.++ALTER TABLE users ADD COLUMN IF NOT EXISTS is_test bigint NOT NULL DEFAULT 0;++++UPDATE users SET is_test = 1, updated_at = ov_now()++WHERE lower(username) IN (++ 'ovprobe_renamed', 'ovprobe_revoke2', 'ovprobe_revoke3', 'ovprobe_modules', 'ovprobe_modules2',++ 'ovprobe_devpath', 'ovprobe_publish', 'ovprobe_rt1', 'ovprobe_rt2', 'ovprobe_rt3', 'ovprobe_rt4', 'ovprobe_rt5',++ 'ovprobe_merge_keep', 'ovprobe_merge_fold', 'ovprobe_worker_other', 'ovprobe_call',++ 'testing_tester', 'testing_testing', 'testing_testers', 'Testtestuser123'++) AND is_test = 0;@@@ -171,9 +171,9 @@ router.post('/register', async (req, res) => {const passwordHash = bcrypt.hashSync(password, 10);const result = await db.prepare(`-- INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id)-- VALUES (?, ?, ?, ?, '#8b5cf6', ?) RETURNING id-- `).run(username, email || null, passwordHash, username, subjects.newUserSubjectId());++ INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id, is_test)++ VALUES (?, ?, ?, ?, '#8b5cf6', ?, ?) RETURNING id++ `).run(username, email || null, passwordHash, username, subjects.newUserSubjectId(), subjects.isTestUsername(username) ? 1 : 0);// Create default preferencesawait db.prepare('INSERT INTO user_preferences (user_id) VALUES (?) ON CONFLICT DO NOTHING').run(result.lastInsertRowid);@@@ -509,7 +509,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, isconst out = [];try {const since = new Date(nowMs - 7 * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');-- const r = await db.prepare("SELECT COUNT(*) AS n FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(deleted_at, '') = '' AND created_at >= ?").get(since);++ const r = await db.prepare("SELECT COUNT(*) AS n FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(deleted_at, '') = '' AND COALESCE(is_test, 0) = 0 AND created_at >= ?").get(since);const n = r ? Number(r.n) : NaN;if (Number.isFinite(n) && n >= 0) out.push({ id: 'joined', label: 'Joined this week', value: n, icon: 'members', url: 'https://openvibe.network/stats' });} catch { /* the count is optional */ }@@@ -530,16 +530,18 @@ async function createFrameService(db, config, analytics, { privateKey = null, islet rows = [];if (subjects.length) {try {-- rows = await db.prepare(`SELECT u.username, u.display_name, u.subject_id, p.show_status, p.state++ rows = await db.prepare(`SELECT u.username, u.display_name, u.subject_id, u.is_test, p.show_status, p.stateFROM person_presence p JOIN users u ON u.subject_id = p.subject_idWHERE p.subject_id = ANY(?) AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.deleted_at, '') = ''`).all(subjects);} catch { rows = []; }}const bySubject = new Map(rows.map((r) => [r.subject_id, r]));++ const testSeen = rows.filter((r) => Number(r.is_test)).length;const visible = [];for (const [subject, entry] of snap.members) {const r = bySubject.get(subject);-- if (!r) continue;++ // A test account (the probes) is never a public member: it is counted out, not just unnamed.++ if (!r || Number(r.is_test)) continue;// The person's own presence choice decides whether they may be named: the "Show my status"// switch off, or Invisible, means counted among members and never in the list.if (!Number(r.show_status) || r.state === 'invisible') continue;@@@ -552,10 +554,11 @@ async function createFrameService(db, config, analytics, { privateKey = null, isvisible.sort((a, b) => b.at - a.at);const live = await readLive(nowMs);const views = Object.entries(rank.scores || {}).filter(([k]) => !k.includes(':')).reduce((a, [, n]) => a + (Number(n) || 0), 0);++ const members = Math.max(0, snap.members.size - testSeen);onlineBase = {-- members: snap.members.size,++ members,guests: snap.guests,-- total: snap.members.size + snap.guests,++ total: members + snap.guests,visible,counts: await countsOf(nowMs),live: live ? { channels: live.channels, watching: live.watching, first: live.list.slice(0, 3).map((s) => ({ name: s.name, url: s.url })) } : { channels: 0, watching: 0, first: [] },@@@ -41,6 +41,16 @@ router.get('/resolve', async (req, res) => {* -> { results: { <subject_id | id>: projection | null } }* Used by services that show authors (Community pastes) and by importers mapping legacy ids.*/++/**++ * GET /internal/identity/test-subjects++ * -> { subjects: [identity.subject-projection@1] }++ * The probe and test accounts (users.is_test), so a service can leave them out of a public member list, feed or++ * leaderboard without asking about everyone. Small by design; services cache it and refresh on a timer.++ */++router.get('/test-subjects', async (req, res) => {++ res.json({ subjects: await subjects.testProjections(req.app.locals.db) });++});++router.post('/resolve-batch', express.json({ limit: '256kb' }), async (req, res) => {const db = req.app.locals.db;const b = req.body || {};@@@ -14,6 +14,15 @@ const { ids, validate } = require('openvibe-contracts');const SELF = 'network';++/**++ * The probe tooling signs its accounts up through the ordinary /api/auth/register form with this prefix. A name++ * starting with it is a test account forever: `is_test` is what every public surface filters on, so the prefix is++ * the one way a new probe account can be created already marked (server/auth/routes.js). Ordinary people never see++ * or claim these names on a public list.++ */++const TEST_USERNAME_PREFIX = 'ovprobe_';++const isTestUsername = (username) => String(username || '').toLowerCase().startsWith(TEST_USERNAME_PREFIX);++/** SQLite ov_now() ('YYYY-MM-DD HH:MM:SS', UTC) -> ms, so backfilled ULIDs sort by account age. */function createdMs(value) {const t = value ? Date.parse(String(value).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? '' : 'Z')) : NaN;@@@ -93,9 +102,9 @@ async function projection(db, subjectId) {const u = await db.prepare('SELECT * FROM users WHERE subject_id = ?').get(subjectId);if (!u) return null;// A deleted account (ADR-033) resolves as a tombstone, so services show "Deleted account" and keep nothing.-- if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, deleted: true };++ if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, is_test: Boolean(u.is_test), deleted: true };return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,-- display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };++ display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned), is_test: Boolean(u.is_test) };}if (subjectId.startsWith('gst_')) {const g = await db.prepare('SELECT id, subject_id, anon_number, display_name FROM anon_users WHERE subject_id = ?').get(subjectId);@@@ -106,6 +115,16 @@ async function projection(db, subjectId) {return null;}++/** Every test account's projection, for the services that must leave them out of public surfaces. */++async function testProjections(db) {++ const rows = await db.prepare('SELECT * FROM users WHERE COALESCE(is_test, 0) = 1 ORDER BY id').all();++ return rows.map((u) => ({++ subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,++ display_name: u.display_name || u.username, avatar_url: u.avatar_url || null,++ banned: Boolean(u.is_banned), is_test: true,++ })).filter((p) => p.subject.id);++}++/** Resolve by subject id or by (system, type, id). Returns the projection plus the matching legacy ids. */async function resolve(db, { subject_id, source_system, source_type, source_id }) {let sid = subject_id;@@@ -177,4 +196,4 @@ async function upsertLegacy(db, entries) {return out;}--module.exports = { ensureSchema, backfill, seedLegacyMap, ensureUserSubject, newUserSubjectId, newGuestSubjectId, projection, resolve, upsertLegacy, createdMs, resolveUsername };++module.exports = { ensureSchema, backfill, seedLegacyMap, ensureUserSubject, newUserSubjectId, newGuestSubjectId, projection, testProjections, resolve, upsertLegacy, createdMs, resolveUsername, TEST_USERNAME_PREFIX, isTestUsername };@@@ -1101,7 +1101,7 @@ require('./home/activity').configure({const d = await openStats.collect(7);return d && Array.isArray(d.sites) ? Object.fromEntries(d.sites.map((s) => [s.id, Number(s.pageviews) || 0])) : null;},-- accounts: async () => (await db.prepare('SELECT COUNT(*) AS cnt FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0').get()).cnt,++ accounts: async () => (await db.prepare('SELECT COUNT(*) AS cnt FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0').get()).cnt,services: () => {const live = require('./status/routes').rows(ecosystem).filter((r) => r.exposure && r.exposure.state === 'live');const known = live.filter((r) => r.status !== 'unknown');@@@ -52,6 +52,7 @@ router.use('/chat-app-links', principals.guard('identity.subject.resolve'), requ// 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.post('/identity/resolve-batch', principals.guard('identity.subject.resolve'));++router.get('/identity/test-subjects', principals.guard('identity.subject.resolve'));// Writing the legacy map is part of identity resolution (ADR-001; identity.subject.resolve takes// identity.legacy-identity-map@1). TODO(contracts): a narrower identity.legacy_map.write capability.router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);@@@ -72,7 +72,8 @@ async function board(db, userId) {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);++ AND (COALESCE(u.is_test, 0) = 0 OR u.id = ?)++ ) SELECT * FROM ranked WHERE rank <= 50 OR ABS(rank - (SELECT rank FROM ranked WHERE id = ?)) <= 5 ORDER BY rank`).all(userId || 0, 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) : [] };@@@ -134,7 +134,7 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr// ── Accounts and OpenCoins: Network's own tables ──async function accounts(days) {-- const real = 'COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0';++ const real = 'COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0';const since = (d) => new Date(now() - d * DAY).toISOString().slice(0, 19).replace('T', ' ');const total = await one(`SELECT COUNT(*) AS n FROM users WHERE ${real}`);if (total == null) return null;@@@ -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'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', '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'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', '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.@@@ -20,6 +20,7 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';++const PROBE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE';const MIN = 60_000;(async () => {@@@ -31,13 +32,16 @@ const ago = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');let NOW = Date.parse('2026-11-10T10:00:00Z');await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role, created_at) VALUES(1, 'ann', 'Ann', 'x', ?, 'user', ?), (2, 'bob', 'Bob', 'x', ?, 'streamer', ?),-- (3, 'cat', 'Cat', 'x', ?, 'user', ?), (4, 'dan', 'Dan', 'x', ?, 'user', ?)`)-- .run(ANN, ago(NOW - 2 * 86_400_000), BOB, ago(NOW - 3 * 86_400_000), CAT, ago(NOW - 4 * 86_400_000), DAN, ago(NOW - 40 * 86_400_000));++ (3, 'cat', 'Cat', 'x', ?, 'user', ?), (4, 'dan', 'Dan', 'x', ?, 'user', ?),++ (5, 'ovprobe_frame', 'Probe', 'x', ?, 'user', ?)`)++ .run(ANN, ago(NOW - 2 * 86_400_000), BOB, ago(NOW - 3 * 86_400_000), CAT, ago(NOW - 4 * 86_400_000), DAN, ago(NOW - 40 * 86_400_000), PROBE, ago(NOW - 86_400_000));++await db.prepare('UPDATE users SET is_test = 1 WHERE id = 5').run();// Ann shows her status; Bob turned it off; Cat is invisible. Dan has never beaten (no presence row).const ins = db.prepare(`INSERT INTO person_presence (subject_id, state, show_status, last_active_at, last_heartbeat_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`);await ins.run(ANN, 'auto', 1, ago(NOW), ago(NOW), ago(NOW));await ins.run(BOB, 'auto', 0, ago(NOW), ago(NOW), ago(NOW));await ins.run(CAT, 'invisible', 1, ago(NOW), ago(NOW), ago(NOW));++await ins.run(PROBE, 'auto', 1, ago(NOW), ago(NOW), ago(NOW));// A Live edge whose list the test controls; Live is the only service /online reads over the network.let liveRows = [{ username: 'eve', display_name: 'Eve', is_live: 1, total_viewer_count: 7 },@@@ -79,11 +83,13 @@ seen.seenMember(ANN, 'live', NOW - MIN);seen.seenMember(BOB, 'games', NOW - 2 * MIN);seen.seenMember(CAT, 'live', NOW - 3 * MIN);seen.seenMember(DAN, 'community', NOW - 4 * MIN); // seen, but no presence row: counted, not named++seen.seenMember(PROBE, 'live', NOW); // a test account: counted out and never namedlet o = await svc.online('live');--assert.strictEqual(o.members, 4, 'four signed-in people were seen, including the two who hide');++assert.strictEqual(o.members, 4, 'four signed-in people were seen, including the two who hide; the test account is not one of them');assert.strictEqual(o.guests, 0);--assert.deepStrictEqual(o.people.map((p) => p.username), ['ann'], 'only Ann may be named');++assert.deepStrictEqual(o.people.map((p) => p.username), ['ann'], 'only Ann may be named; a test account never is');++assert.ok(!JSON.stringify(o).includes('ovprobe_frame'), 'the test account is nowhere in the public read');assert.deepStrictEqual(o.people[0], { name: 'Ann', username: 'ann', avatar: 'https://openvibe.network/avatar/ann?s=64', site: 'Live' });assert.ok(!JSON.stringify(o).includes('bob') && !JSON.stringify(o).includes('cat'), 'a hidden member is never named');assert.strictEqual(o.more, 0);@@@ -55,6 +55,15 @@ assert.strictEqual((await subjects.resolve(db, { subject_id: guest.subject_id })assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '321' })).subject.id, beth.subject_id, 'seeded from a reported Live link');assert.strictEqual((await db.prepare("SELECT COUNT(*) n FROM identity_legacy_map WHERE source_system = 'tools'").get()).n, 0, "OAuth 'network:<id>' links say nothing about site ids");assert.strictEqual(await subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);++assert.strictEqual((await subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 1 })).is_test, false, 'a person is not a test account');++++// Test accounts: the flag is in every projection, and the internal list names only the test accounts.++await db.prepare("INSERT INTO users (id, username, password_hash, is_test) VALUES (4, 'ovprobe_merge_keep', 'x', 1)").run();++const probeProjection = await subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 4 });++assert.strictEqual(probeProjection.is_test, true, 'the projection says the account is a test one');++assert.deepStrictEqual((await subjects.testProjections(db)).map((p) => p.username), ['ovprobe_merge_keep']);++assert.ok(subjects.isTestUsername('ovprobe_call') && subjects.isTestUsername('OVPROBE_new'), 'the probe sign-up prefix marks a new account');++assert.ok(!subjects.isTestUsername('alex') && !subjects.isTestUsername('probe_alex'), 'an ordinary name is not a test account');// A row inserted by a path that forgot subject_id is fixed lazily.await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (3, 'cat', 'x')").run();@@@ -132,6 +141,11 @@ const { signToken } = require('../server/auth/routes');assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '555' })).network_user_id, 3, '/internal/link-account also writes the legacy map');h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });assert.strictEqual(h.status, 400);++ h = await call('/test-subjects');++ assert.strictEqual(h.status, 200);++ assert.deepStrictEqual(h.body.subjects.map((p) => [p.username, p.is_test]), [['ovprobe_merge_keep', true]]);++ h = await fetch(base + '/test-subjects').then(async x => ({ status: x.status, body: await x.json() }));++ assert.strictEqual(h.status, 401, 'the test-account list is internal');h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status, code: x.headers.get('content-type') }));assert.strictEqual(h.status, 401, 'no service token, no identity data');h = await fetch(base + '/resolve?system=live&id=77', { headers: { 'x-internal-key': 'k-test' } }).then(async x => ({ status: x.status, body: await x.json() }));@@@ -36,6 +36,14 @@ const { backfill } = require('../scripts/backfill-levels');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);++ // A test account is left out of the public leaderboard, but its own signed-in board still shows it.++ const probeSubject = ids.newId('user');++ const probe = await db.prepare("INSERT INTO users(username, password_hash, subject_id, is_test) VALUES ('ovprobe_levels', 'unused', ?, 1) RETURNING id").get(probeSubject);++ await levels.award(db, 'quest', { subject: probeSubject, reason: 'quest.completed', amount: 500, idempotency_key: 'probe-hi' });++ const publicBoard = await levels.board(db, null);++ assert.ok(!publicBoard.all_time.top.some((r) => r.subject === probeSubject), 'the public board leaves a test account out');++ const ownBoard = await levels.board(db, probe.id);++ assert.ok(ownBoard.all_time.me && ownBoard.all_time.me.subject === probeSubject, 'its own signed-in board still shows it');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); });@@@ -28,6 +28,8 @@ const { createOpenStats, render } = require('../server/stats/open');await user.run(9003, 'osc', 'x', at(9), 0, 0);await user.run(9004, 'osguest', 'x', at(1), 1, 0);await user.run(9005, 'osbanned', 'x', at(1), 0, 1);++ await user.run(9006, 'ovprobe_os', 'x', at(1), 0, 0);++ await db.prepare('UPDATE users SET is_test = 1 WHERE id = 9006').run();// Live's analytics and home numbers; Prometheus down.const asked = [];@@@ -55,7 +57,9 @@ const { createOpenStats, render } = require('../server/stats/open');assert.ok(!d.sites.some((s) => s.host === 'evil.example'), 'only OpenVibe sites');assert.strictEqual(d.sites[0].id, 'live', 'most visited first');assert.strictEqual(d.totals.pageviews, d.sites.reduce((a, s) => a + (s.pageviews || 0), 0));-- assert.strictEqual(d.accounts.new, 2, 'two new accounts this week (no guests, no banned)');++ assert.strictEqual(d.accounts.new, 2, 'two new accounts this week (no guests, no banned, no test accounts)');++ assert.strictEqual(d.accounts.total, 3, 'a test account is not in the public account count');++ assert.strictEqual(d.accounts.daily.reduce((a, p) => a + p.n, 0), 2, 'the daily series leaves test accounts out too');assert.strictEqual(d.accounts.prev, 1);assert.deepStrictEqual(d.totals.services, { up: 1, checked: 2 });assert.strictEqual(d.live.live_now, 2);nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++// Test accounts (migrations/0075_test_accounts.sql; server/auth/routes.js): a name the probe tooling signs up with++// (ovprobe_) is created already marked is_test, and an ordinary account is not. The flag is the identity authority's++// answer to every public surface's "leave test accounts out".++// node test/test-accounts.test.js++const assert = require('assert');++const { bootServer } = require('./helpers/boot-server');++++(async () => {++ const srv = await bootServer();++ try {++ const register = (username) => fetch(`${srv.base}/api/auth/register`, {++ method: 'POST', headers: { 'content-type': 'application/json' },++ body: JSON.stringify({ username, password: 'correct-horse-battery' }),++ }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++ const db = srv.app.locals.db;++ // The test's bootstrap profile may leave sign-ups closed; this test is about how a sign-up is marked.++ await db.prepare("INSERT INTO site_settings (key, value, type) VALUES ('registration_open', 'true', 'boolean') ON CONFLICT (key) DO UPDATE SET value = 'true'").run();++++ let r = await register('ovprobe_registered');++ assert.strictEqual(r.status, 201, `register answered ${r.status}`);++ const probe = await db.prepare('SELECT username, is_test, subject_id FROM users WHERE lower(username) = ?').get('ovprobe_registered');++ assert.strictEqual(Number(probe.is_test), 1, 'the probe sign-up path marks the account as a test account');++ assert.ok(/^usr_/.test(probe.subject_id), 'it is still a real account with a subject');++++ r = await register('ordinary_person');++ assert.strictEqual(r.status, 201);++ const person = await db.prepare('SELECT username, is_test FROM users WHERE lower(username) = ?').get('ordinary_person');++ assert.strictEqual(Number(person.is_test), 0, 'an ordinary account is never marked as a test account');++++ console.log('test accounts: the probe sign-up path marks is_test; an ordinary registration does not');++ } catch (err) {++ console.error(err);++ console.error(srv.logs());++ process.exitCode = 1;++ } finally {++ await srv.stop();++ }++ process.exit(process.exitCode || 0);++})();@@@ -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'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', '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'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', '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,16 @@
@@ -0,0 +1,16 @@
+-- phase: expand
+-- Test accounts: the production probes and the accounts older manual tests signed up. A test account is a real
+-- account (it can sign in, act and be tested as itself) that must never reach a public surface: member lists, feeds,
+-- leaderboards and the account counts. The column is the authority; OpenVibe.Network's /api/auth/register sets it for
+-- every account whose name starts with ovprobe_ (the probe tooling's sign-up path), and this migration marks the
+-- accounts that existed before the column did. Matching is on the exact names below, never a pattern, so a person
+-- whose name merely looks like a test is not hidden.
+ALTER TABLE users ADD COLUMN IF NOT EXISTS is_test bigint NOT NULL DEFAULT 0;
+
+UPDATE users SET is_test = 1, updated_at = ov_now()
+WHERE lower(username) IN (
+ 'ovprobe_renamed', 'ovprobe_revoke2', 'ovprobe_revoke3', 'ovprobe_modules', 'ovprobe_modules2',
+ 'ovprobe_devpath', 'ovprobe_publish', 'ovprobe_rt1', 'ovprobe_rt2', 'ovprobe_rt3', 'ovprobe_rt4', 'ovprobe_rt5',
+ 'ovprobe_merge_keep', 'ovprobe_merge_fold', 'ovprobe_worker_other', 'ovprobe_call',
+ 'testing_tester', 'testing_testing', 'testing_testers', 'Testtestuser123'
+) AND is_test = 0;
@@ -171,9 +171,9 @@ router.post('/register', async (req, res) => {
@@ -171,9 +171,9 @@ router.post('/register', async (req, res) => {
const passwordHash = bcrypt.hashSync(password, 10);
const passwordHash = bcrypt.hashSync(password, 10);
const result = await db.prepare(`
const result = await db.prepare(`
- INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id)
- VALUES (?, ?, ?, ?, '#8b5cf6', ?) RETURNING id
- `).run(username, email || null, passwordHash, username, subjects.newUserSubjectId());
+ INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id, is_test)
+ VALUES (?, ?, ?, ?, '#8b5cf6', ?, ?) RETURNING id
+ `).run(username, email || null, passwordHash, username, subjects.newUserSubjectId(), subjects.isTestUsername(username) ? 1 : 0);
// Create default preferences
// Create default preferences
await db.prepare('INSERT INTO user_preferences (user_id) VALUES (?) ON CONFLICT DO NOTHING').run(result.lastInsertRowid);
await db.prepare('INSERT INTO user_preferences (user_id) VALUES (?) ON CONFLICT DO NOTHING').run(result.lastInsertRowid);
@@ -509,7 +509,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -509,7 +509,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const out = [];
const out = [];
try {
try {
const since = new Date(nowMs - 7 * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
const since = new Date(nowMs - 7 * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
- const r = await db.prepare("SELECT COUNT(*) AS n FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(deleted_at, '') = '' AND created_at >= ?").get(since);
+ const r = await db.prepare("SELECT COUNT(*) AS n FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(deleted_at, '') = '' AND COALESCE(is_test, 0) = 0 AND created_at >= ?").get(since);
const n = r ? Number(r.n) : NaN;
const n = r ? Number(r.n) : NaN;
if (Number.isFinite(n) && n >= 0) out.push({ id: 'joined', label: 'Joined this week', value: n, icon: 'members', url: 'https://openvibe.network/stats' });
if (Number.isFinite(n) && n >= 0) out.push({ id: 'joined', label: 'Joined this week', value: n, icon: 'members', url: 'https://openvibe.network/stats' });
} catch { /* the count is optional */ }
} catch { /* the count is optional */ }
@@ -530,16 +530,18 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -530,16 +530,18 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
let rows = [];
let rows = [];
if (subjects.length) {
if (subjects.length) {
try {
try {
- rows = await db.prepare(`SELECT u.username, u.display_name, u.subject_id, p.show_status, p.state
+ rows = await db.prepare(`SELECT u.username, u.display_name, u.subject_id, u.is_test, p.show_status, p.state
FROM person_presence p JOIN users u ON u.subject_id = p.subject_id
FROM person_presence p JOIN users u ON u.subject_id = p.subject_id
WHERE p.subject_id = ANY(?) AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.deleted_at, '') = ''`).all(subjects);
WHERE p.subject_id = ANY(?) AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.deleted_at, '') = ''`).all(subjects);
} catch { rows = []; }
} catch { rows = []; }
}
}
const bySubject = new Map(rows.map((r) => [r.subject_id, r]));
const bySubject = new Map(rows.map((r) => [r.subject_id, r]));
+ const testSeen = rows.filter((r) => Number(r.is_test)).length;
const visible = [];
const visible = [];
for (const [subject, entry] of snap.members) {
for (const [subject, entry] of snap.members) {
const r = bySubject.get(subject);
const r = bySubject.get(subject);
- if (!r) continue;
+ // A test account (the probes) is never a public member: it is counted out, not just unnamed.
+ if (!r || Number(r.is_test)) continue;
// The person's own presence choice decides whether they may be named: the "Show my status"
// The person's own presence choice decides whether they may be named: the "Show my status"
// switch off, or Invisible, means counted among members and never in the list.
// switch off, or Invisible, means counted among members and never in the list.
if (!Number(r.show_status) || r.state === 'invisible') continue;
if (!Number(r.show_status) || r.state === 'invisible') continue;
@@ -552,10 +554,11 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -552,10 +554,11 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
visible.sort((a, b) => b.at - a.at);
visible.sort((a, b) => b.at - a.at);
const live = await readLive(nowMs);
const live = await readLive(nowMs);
const views = Object.entries(rank.scores || {}).filter(([k]) => !k.includes(':')).reduce((a, [, n]) => a + (Number(n) || 0), 0);
const views = Object.entries(rank.scores || {}).filter(([k]) => !k.includes(':')).reduce((a, [, n]) => a + (Number(n) || 0), 0);
+ const members = Math.max(0, snap.members.size - testSeen);
onlineBase = {
onlineBase = {
- members: snap.members.size,
+ members,
guests: snap.guests,
guests: snap.guests,
- total: snap.members.size + snap.guests,
+ total: members + snap.guests,
visible,
visible,
counts: await countsOf(nowMs),
counts: await countsOf(nowMs),
live: live ? { channels: live.channels, watching: live.watching, first: live.list.slice(0, 3).map((s) => ({ name: s.name, url: s.url })) } : { channels: 0, watching: 0, first: [] },
live: live ? { channels: live.channels, watching: live.watching, first: live.list.slice(0, 3).map((s) => ({ name: s.name, url: s.url })) } : { channels: 0, watching: 0, first: [] },
@@ -41,6 +41,16 @@ router.get('/resolve', async (req, res) => {
@@ -41,6 +41,16 @@ router.get('/resolve', async (req, res) => {
* -> { results: { <subject_id | id>: projection | null } }
* -> { results: { <subject_id | id>: projection | null } }
* Used by services that show authors (Community pastes) and by importers mapping legacy ids.
* Used by services that show authors (Community pastes) and by importers mapping legacy ids.
*/
*/
+/**
+ * GET /internal/identity/test-subjects
+ * -> { subjects: [identity.subject-projection@1] }
+ * The probe and test accounts (users.is_test), so a service can leave them out of a public member list, feed or
+ * leaderboard without asking about everyone. Small by design; services cache it and refresh on a timer.
+ */
+router.get('/test-subjects', async (req, res) => {
+ res.json({ subjects: await subjects.testProjections(req.app.locals.db) });
+});
+
router.post('/resolve-batch', express.json({ limit: '256kb' }), async (req, res) => {
router.post('/resolve-batch', express.json({ limit: '256kb' }), async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
const b = req.body || {};
const b = req.body || {};
@@ -14,6 +14,15 @@ const { ids, validate } = require('openvibe-contracts');
@@ -14,6 +14,15 @@ const { ids, validate } = require('openvibe-contracts');
const SELF = 'network';
const SELF = 'network';
+/**
+ * The probe tooling signs its accounts up through the ordinary /api/auth/register form with this prefix. A name
+ * starting with it is a test account forever: `is_test` is what every public surface filters on, so the prefix is
+ * the one way a new probe account can be created already marked (server/auth/routes.js). Ordinary people never see
+ * or claim these names on a public list.
+ */
+const TEST_USERNAME_PREFIX = 'ovprobe_';
+const isTestUsername = (username) => String(username || '').toLowerCase().startsWith(TEST_USERNAME_PREFIX);
+
/** SQLite ov_now() ('YYYY-MM-DD HH:MM:SS', UTC) -> ms, so backfilled ULIDs sort by account age. */
/** SQLite ov_now() ('YYYY-MM-DD HH:MM:SS', UTC) -> ms, so backfilled ULIDs sort by account age. */
function createdMs(value) {
function createdMs(value) {
const t = value ? Date.parse(String(value).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? '' : 'Z')) : NaN;
const t = value ? Date.parse(String(value).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? '' : 'Z')) : NaN;
@@ -93,9 +102,9 @@ async function projection(db, subjectId) {
@@ -93,9 +102,9 @@ async function projection(db, subjectId) {
const u = await db.prepare('SELECT * FROM users WHERE subject_id = ?').get(subjectId);
const u = await db.prepare('SELECT * FROM users WHERE subject_id = ?').get(subjectId);
if (!u) return null;
if (!u) return null;
// A deleted account (ADR-033) resolves as a tombstone, so services show "Deleted account" and keep nothing.
// A deleted account (ADR-033) resolves as a tombstone, so services show "Deleted account" and keep nothing.
- if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, deleted: true };
+ if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, is_test: Boolean(u.is_test), deleted: true };
return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,
return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,
- display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };
+ display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned), is_test: Boolean(u.is_test) };
}
}
if (subjectId.startsWith('gst_')) {
if (subjectId.startsWith('gst_')) {
const g = await db.prepare('SELECT id, subject_id, anon_number, display_name FROM anon_users WHERE subject_id = ?').get(subjectId);
const g = await db.prepare('SELECT id, subject_id, anon_number, display_name FROM anon_users WHERE subject_id = ?').get(subjectId);
@@ -106,6 +115,16 @@ async function projection(db, subjectId) {
@@ -106,6 +115,16 @@ async function projection(db, subjectId) {
return null;
return null;
}
}
+/** Every test account's projection, for the services that must leave them out of public surfaces. */
+async function testProjections(db) {
+ const rows = await db.prepare('SELECT * FROM users WHERE COALESCE(is_test, 0) = 1 ORDER BY id').all();
+ return rows.map((u) => ({
+ subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,
+ display_name: u.display_name || u.username, avatar_url: u.avatar_url || null,
+ banned: Boolean(u.is_banned), is_test: true,
+ })).filter((p) => p.subject.id);
+}
+
/** Resolve by subject id or by (system, type, id). Returns the projection plus the matching legacy ids. */
/** Resolve by subject id or by (system, type, id). Returns the projection plus the matching legacy ids. */
async function resolve(db, { subject_id, source_system, source_type, source_id }) {
async function resolve(db, { subject_id, source_system, source_type, source_id }) {
let sid = subject_id;
let sid = subject_id;
@@ -177,4 +196,4 @@ async function upsertLegacy(db, entries) {
@@ -177,4 +196,4 @@ async function upsertLegacy(db, entries) {
return out;
return out;
}
}
-module.exports = { ensureSchema, backfill, seedLegacyMap, ensureUserSubject, newUserSubjectId, newGuestSubjectId, projection, resolve, upsertLegacy, createdMs, resolveUsername };
+module.exports = { ensureSchema, backfill, seedLegacyMap, ensureUserSubject, newUserSubjectId, newGuestSubjectId, projection, testProjections, resolve, upsertLegacy, createdMs, resolveUsername, TEST_USERNAME_PREFIX, isTestUsername };
@@ -1101,7 +1101,7 @@ require('./home/activity').configure({
@@ -1101,7 +1101,7 @@ require('./home/activity').configure({
const d = await openStats.collect(7);
const d = await openStats.collect(7);
return d && Array.isArray(d.sites) ? Object.fromEntries(d.sites.map((s) => [s.id, Number(s.pageviews) || 0])) : null;
return d && Array.isArray(d.sites) ? Object.fromEntries(d.sites.map((s) => [s.id, Number(s.pageviews) || 0])) : null;
},
},
- accounts: async () => (await db.prepare('SELECT COUNT(*) AS cnt FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0').get()).cnt,
+ accounts: async () => (await db.prepare('SELECT COUNT(*) AS cnt FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0').get()).cnt,
services: () => {
services: () => {
const live = require('./status/routes').rows(ecosystem).filter((r) => r.exposure && r.exposure.state === 'live');
const live = require('./status/routes').rows(ecosystem).filter((r) => r.exposure && r.exposure.state === 'live');
const known = live.filter((r) => r.status !== 'unknown');
const known = live.filter((r) => r.status !== 'unknown');
@@ -52,6 +52,7 @@ router.use('/chat-app-links', principals.guard('identity.subject.resolve'), requ
@@ -52,6 +52,7 @@ router.use('/chat-app-links', principals.guard('identity.subject.resolve'), requ
// 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'));
router.post('/identity/resolve-batch', principals.guard('identity.subject.resolve'));
router.post('/identity/resolve-batch', principals.guard('identity.subject.resolve'));
+router.get('/identity/test-subjects', principals.guard('identity.subject.resolve'));
// Writing the legacy map is part of identity resolution (ADR-001; identity.subject.resolve takes
// Writing the legacy map is part of identity resolution (ADR-001; identity.subject.resolve takes
// identity.legacy-identity-map@1). TODO(contracts): a narrower identity.legacy_map.write capability.
// identity.legacy-identity-map@1). TODO(contracts): a narrower identity.legacy_map.write capability.
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
@@ -72,7 +72,8 @@ async function board(db, userId) {
@@ -72,7 +72,8 @@ async function board(db, userId) {
FROM users u LEFT JOIN person_levels l ON l.user_id = u.id
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" : ''}
${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)
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);
+ AND (COALESCE(u.is_test, 0) = 0 OR u.id = ?)
+ ) SELECT * FROM ranked WHERE rank <= 50 OR ABS(rank - (SELECT rank FROM ranked WHERE id = ?)) <= 5 ORDER BY rank`).all(userId || 0, userId || 0);
const me = rows.find(r => Number(r.id) === Number(userId));
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) });
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 { 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) : [] };
@@ -134,7 +134,7 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
@@ -134,7 +134,7 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
// ── Accounts and OpenCoins: Network's own tables ──
// ── Accounts and OpenCoins: Network's own tables ──
async function accounts(days) {
async function accounts(days) {
- const real = 'COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0';
+ const real = 'COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0';
const since = (d) => new Date(now() - d * DAY).toISOString().slice(0, 19).replace('T', ' ');
const since = (d) => new Date(now() - d * DAY).toISOString().slice(0, 19).replace('T', ' ');
const total = await one(`SELECT COUNT(*) AS n FROM users WHERE ${real}`);
const total = await one(`SELECT COUNT(*) AS n FROM users WHERE ${real}`);
if (total == null) return null;
if (total == null) return null;
@@ -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'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', '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'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', '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.
@@ -20,6 +20,7 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
@@ -20,6 +20,7 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
+const PROBE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE';
const MIN = 60_000;
const MIN = 60_000;
(async () => {
(async () => {
@@ -31,13 +32,16 @@ const ago = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
@@ -31,13 +32,16 @@ const ago = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
let NOW = Date.parse('2026-11-10T10:00:00Z');
let NOW = Date.parse('2026-11-10T10:00:00Z');
await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role, created_at) VALUES
await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role, created_at) VALUES
(1, 'ann', 'Ann', 'x', ?, 'user', ?), (2, 'bob', 'Bob', 'x', ?, 'streamer', ?),
(1, 'ann', 'Ann', 'x', ?, 'user', ?), (2, 'bob', 'Bob', 'x', ?, 'streamer', ?),
- (3, 'cat', 'Cat', 'x', ?, 'user', ?), (4, 'dan', 'Dan', 'x', ?, 'user', ?)`)
- .run(ANN, ago(NOW - 2 * 86_400_000), BOB, ago(NOW - 3 * 86_400_000), CAT, ago(NOW - 4 * 86_400_000), DAN, ago(NOW - 40 * 86_400_000));
+ (3, 'cat', 'Cat', 'x', ?, 'user', ?), (4, 'dan', 'Dan', 'x', ?, 'user', ?),
+ (5, 'ovprobe_frame', 'Probe', 'x', ?, 'user', ?)`)
+ .run(ANN, ago(NOW - 2 * 86_400_000), BOB, ago(NOW - 3 * 86_400_000), CAT, ago(NOW - 4 * 86_400_000), DAN, ago(NOW - 40 * 86_400_000), PROBE, ago(NOW - 86_400_000));
+await db.prepare('UPDATE users SET is_test = 1 WHERE id = 5').run();
// Ann shows her status; Bob turned it off; Cat is invisible. Dan has never beaten (no presence row).
// Ann shows her status; Bob turned it off; Cat is invisible. Dan has never beaten (no presence row).
const ins = db.prepare(`INSERT INTO person_presence (subject_id, state, show_status, last_active_at, last_heartbeat_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`);
const ins = db.prepare(`INSERT INTO person_presence (subject_id, state, show_status, last_active_at, last_heartbeat_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`);
await ins.run(ANN, 'auto', 1, ago(NOW), ago(NOW), ago(NOW));
await ins.run(ANN, 'auto', 1, ago(NOW), ago(NOW), ago(NOW));
await ins.run(BOB, 'auto', 0, ago(NOW), ago(NOW), ago(NOW));
await ins.run(BOB, 'auto', 0, ago(NOW), ago(NOW), ago(NOW));
await ins.run(CAT, 'invisible', 1, ago(NOW), ago(NOW), ago(NOW));
await ins.run(CAT, 'invisible', 1, ago(NOW), ago(NOW), ago(NOW));
+await ins.run(PROBE, 'auto', 1, ago(NOW), ago(NOW), ago(NOW));
// A Live edge whose list the test controls; Live is the only service /online reads over the network.
// A Live edge whose list the test controls; Live is the only service /online reads over the network.
let liveRows = [{ username: 'eve', display_name: 'Eve', is_live: 1, total_viewer_count: 7 },
let liveRows = [{ username: 'eve', display_name: 'Eve', is_live: 1, total_viewer_count: 7 },
@@ -79,11 +83,13 @@ seen.seenMember(ANN, 'live', NOW - MIN);
@@ -79,11 +83,13 @@ seen.seenMember(ANN, 'live', NOW - MIN);
seen.seenMember(BOB, 'games', NOW - 2 * MIN);
seen.seenMember(BOB, 'games', NOW - 2 * MIN);
seen.seenMember(CAT, 'live', NOW - 3 * MIN);
seen.seenMember(CAT, 'live', NOW - 3 * MIN);
seen.seenMember(DAN, 'community', NOW - 4 * MIN); // seen, but no presence row: counted, not named
seen.seenMember(DAN, 'community', NOW - 4 * MIN); // seen, but no presence row: counted, not named
+seen.seenMember(PROBE, 'live', NOW); // a test account: counted out and never named
let o = await svc.online('live');
let o = await svc.online('live');
-assert.strictEqual(o.members, 4, 'four signed-in people were seen, including the two who hide');
+assert.strictEqual(o.members, 4, 'four signed-in people were seen, including the two who hide; the test account is not one of them');
assert.strictEqual(o.guests, 0);
assert.strictEqual(o.guests, 0);
-assert.deepStrictEqual(o.people.map((p) => p.username), ['ann'], 'only Ann may be named');
+assert.deepStrictEqual(o.people.map((p) => p.username), ['ann'], 'only Ann may be named; a test account never is');
+assert.ok(!JSON.stringify(o).includes('ovprobe_frame'), 'the test account is nowhere in the public read');
assert.deepStrictEqual(o.people[0], { name: 'Ann', username: 'ann', avatar: 'https://openvibe.network/avatar/ann?s=64', site: 'Live' });
assert.deepStrictEqual(o.people[0], { name: 'Ann', username: 'ann', avatar: 'https://openvibe.network/avatar/ann?s=64', site: 'Live' });
assert.ok(!JSON.stringify(o).includes('bob') && !JSON.stringify(o).includes('cat'), 'a hidden member is never named');
assert.ok(!JSON.stringify(o).includes('bob') && !JSON.stringify(o).includes('cat'), 'a hidden member is never named');
assert.strictEqual(o.more, 0);
assert.strictEqual(o.more, 0);
@@ -55,6 +55,15 @@ assert.strictEqual((await subjects.resolve(db, { subject_id: guest.subject_id })
@@ -55,6 +55,15 @@ assert.strictEqual((await subjects.resolve(db, { subject_id: guest.subject_id })
assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '321' })).subject.id, beth.subject_id, 'seeded from a reported Live link');
assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '321' })).subject.id, beth.subject_id, 'seeded from a reported Live link');
assert.strictEqual((await db.prepare("SELECT COUNT(*) n FROM identity_legacy_map WHERE source_system = 'tools'").get()).n, 0, "OAuth 'network:<id>' links say nothing about site ids");
assert.strictEqual((await db.prepare("SELECT COUNT(*) n FROM identity_legacy_map WHERE source_system = 'tools'").get()).n, 0, "OAuth 'network:<id>' links say nothing about site ids");
assert.strictEqual(await subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);
assert.strictEqual(await subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);
+assert.strictEqual((await subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 1 })).is_test, false, 'a person is not a test account');
+
+// Test accounts: the flag is in every projection, and the internal list names only the test accounts.
+await db.prepare("INSERT INTO users (id, username, password_hash, is_test) VALUES (4, 'ovprobe_merge_keep', 'x', 1)").run();
+const probeProjection = await subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 4 });
+assert.strictEqual(probeProjection.is_test, true, 'the projection says the account is a test one');
+assert.deepStrictEqual((await subjects.testProjections(db)).map((p) => p.username), ['ovprobe_merge_keep']);
+assert.ok(subjects.isTestUsername('ovprobe_call') && subjects.isTestUsername('OVPROBE_new'), 'the probe sign-up prefix marks a new account');
+assert.ok(!subjects.isTestUsername('alex') && !subjects.isTestUsername('probe_alex'), 'an ordinary name is not a test account');
// A row inserted by a path that forgot subject_id is fixed lazily.
// A row inserted by a path that forgot subject_id is fixed lazily.
await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (3, 'cat', 'x')").run();
await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (3, 'cat', 'x')").run();
@@ -132,6 +141,11 @@ const { signToken } = require('../server/auth/routes');
@@ -132,6 +141,11 @@ const { signToken } = require('../server/auth/routes');
assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '555' })).network_user_id, 3, '/internal/link-account also writes the legacy map');
assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '555' })).network_user_id, 3, '/internal/link-account also writes the legacy map');
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });
assert.strictEqual(h.status, 400);
assert.strictEqual(h.status, 400);
+ h = await call('/test-subjects');
+ assert.strictEqual(h.status, 200);
+ assert.deepStrictEqual(h.body.subjects.map((p) => [p.username, p.is_test]), [['ovprobe_merge_keep', true]]);
+ h = await fetch(base + '/test-subjects').then(async x => ({ status: x.status, body: await x.json() }));
+ assert.strictEqual(h.status, 401, 'the test-account list is internal');
h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status, code: x.headers.get('content-type') }));
h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status, code: x.headers.get('content-type') }));
assert.strictEqual(h.status, 401, 'no service token, no identity data');
assert.strictEqual(h.status, 401, 'no service token, no identity data');
h = await fetch(base + '/resolve?system=live&id=77', { headers: { 'x-internal-key': 'k-test' } }).then(async x => ({ status: x.status, body: await x.json() }));
h = await fetch(base + '/resolve?system=live&id=77', { headers: { 'x-internal-key': 'k-test' } }).then(async x => ({ status: x.status, body: await x.json() }));
@@ -36,6 +36,14 @@ const { backfill } = require('../scripts/backfill-levels');
@@ -36,6 +36,14 @@ const { backfill } = require('../scripts/backfill-levels');
const dry = await backfill(db); assert.equal(dry.awarded, 0); assert.equal(dry.existing, 1);
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);
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.prepare('UPDATE users SET is_banned = 0 WHERE id = ?').run(other.id);
+ // A test account is left out of the public leaderboard, but its own signed-in board still shows it.
+ const probeSubject = ids.newId('user');
+ const probe = await db.prepare("INSERT INTO users(username, password_hash, subject_id, is_test) VALUES ('ovprobe_levels', 'unused', ?, 1) RETURNING id").get(probeSubject);
+ await levels.award(db, 'quest', { subject: probeSubject, reason: 'quest.completed', amount: 500, idempotency_key: 'probe-hi' });
+ const publicBoard = await levels.board(db, null);
+ assert.ok(!publicBoard.all_time.top.some((r) => r.subject === probeSubject), 'the public board leaves a test account out');
+ const ownBoard = await levels.board(db, probe.id);
+ assert.ok(ownBoard.all_time.me && ownBoard.all_time.me.subject === probeSubject, 'its own signed-in board still shows it');
console.log('levels: duplicate refused; earned coins once; refunds/transfers excluded; privacy, caps and dry-run passed');
console.log('levels: duplicate refused; earned coins once; refunds/transfers excluded; privacy, caps and dry-run passed');
process.exit(0);
process.exit(0);
})().catch(e => { console.error(e); process.exit(1); });
})().catch(e => { console.error(e); process.exit(1); });
@@ -28,6 +28,8 @@ const { createOpenStats, render } = require('../server/stats/open');
@@ -28,6 +28,8 @@ const { createOpenStats, render } = require('../server/stats/open');
await user.run(9003, 'osc', 'x', at(9), 0, 0);
await user.run(9003, 'osc', 'x', at(9), 0, 0);
await user.run(9004, 'osguest', 'x', at(1), 1, 0);
await user.run(9004, 'osguest', 'x', at(1), 1, 0);
await user.run(9005, 'osbanned', 'x', at(1), 0, 1);
await user.run(9005, 'osbanned', 'x', at(1), 0, 1);
+ await user.run(9006, 'ovprobe_os', 'x', at(1), 0, 0);
+ await db.prepare('UPDATE users SET is_test = 1 WHERE id = 9006').run();
// Live's analytics and home numbers; Prometheus down.
// Live's analytics and home numbers; Prometheus down.
const asked = [];
const asked = [];
@@ -55,7 +57,9 @@ const { createOpenStats, render } = require('../server/stats/open');
@@ -55,7 +57,9 @@ const { createOpenStats, render } = require('../server/stats/open');
assert.ok(!d.sites.some((s) => s.host === 'evil.example'), 'only OpenVibe sites');
assert.ok(!d.sites.some((s) => s.host === 'evil.example'), 'only OpenVibe sites');
assert.strictEqual(d.sites[0].id, 'live', 'most visited first');
assert.strictEqual(d.sites[0].id, 'live', 'most visited first');
assert.strictEqual(d.totals.pageviews, d.sites.reduce((a, s) => a + (s.pageviews || 0), 0));
assert.strictEqual(d.totals.pageviews, d.sites.reduce((a, s) => a + (s.pageviews || 0), 0));
- assert.strictEqual(d.accounts.new, 2, 'two new accounts this week (no guests, no banned)');
+ assert.strictEqual(d.accounts.new, 2, 'two new accounts this week (no guests, no banned, no test accounts)');
+ assert.strictEqual(d.accounts.total, 3, 'a test account is not in the public account count');
+ assert.strictEqual(d.accounts.daily.reduce((a, p) => a + p.n, 0), 2, 'the daily series leaves test accounts out too');
assert.strictEqual(d.accounts.prev, 1);
assert.strictEqual(d.accounts.prev, 1);
assert.deepStrictEqual(d.totals.services, { up: 1, checked: 2 });
assert.deepStrictEqual(d.totals.services, { up: 1, checked: 2 });
assert.strictEqual(d.live.live_now, 2);
assert.strictEqual(d.live.live_now, 2);
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+// Test accounts (migrations/0075_test_accounts.sql; server/auth/routes.js): a name the probe tooling signs up with
+// (ovprobe_) is created already marked is_test, and an ordinary account is not. The flag is the identity authority's
+// answer to every public surface's "leave test accounts out".
+// node test/test-accounts.test.js
+const assert = require('assert');
+const { bootServer } = require('./helpers/boot-server');
+
+(async () => {
+ const srv = await bootServer();
+ try {
+ const register = (username) => fetch(`${srv.base}/api/auth/register`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ username, password: 'correct-horse-battery' }),
+ }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+ const db = srv.app.locals.db;
+ // The test's bootstrap profile may leave sign-ups closed; this test is about how a sign-up is marked.
+ await db.prepare("INSERT INTO site_settings (key, value, type) VALUES ('registration_open', 'true', 'boolean') ON CONFLICT (key) DO UPDATE SET value = 'true'").run();
+
+ let r = await register('ovprobe_registered');
+ assert.strictEqual(r.status, 201, `register answered ${r.status}`);
+ const probe = await db.prepare('SELECT username, is_test, subject_id FROM users WHERE lower(username) = ?').get('ovprobe_registered');
+ assert.strictEqual(Number(probe.is_test), 1, 'the probe sign-up path marks the account as a test account');
+ assert.ok(/^usr_/.test(probe.subject_id), 'it is still a real account with a subject');
+
+ r = await register('ordinary_person');
+ assert.strictEqual(r.status, 201);
+ const person = await db.prepare('SELECT username, is_test FROM users WHERE lower(username) = ?').get('ordinary_person');
+ assert.strictEqual(Number(person.is_test), 0, 'an ordinary account is never marked as a test account');
+
+ console.log('test accounts: the probe sign-up path marks is_test; an ordinary registration does not');
+ } catch (err) {
+ console.error(err);
+ console.error(srv.logs());
+ process.exitCode = 1;
+ } finally {
+ await srv.stop();
+ }
+ process.exit(process.exitCode || 0);
+})();
@@ -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'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', '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'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', '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.