openvibers/openvibe.network Commits
The user card's actions, computed for the viewer (ask 287) (#812)
Changes
@@@ -8,12 +8,64 @@ Example (optional fields are omitted when hidden or unavailable):{"username":"Example","display_name":"Example","avatar_url":"https://openvibe.network/avatar/Example?s=160","profile_url":"https://openvibe.network/@Example","level":4,"streak":3,"badges_count":2,"followers":10,"live":{"now":true,"site":"live","url":"https://openvibe.live/@Example"},"channel_url":"https://openvibe.live/@Example","active_sites":[{"id":"live","name":"Live","url":"https://openvibe.live"}]}```--Network owns global levels (ask 241), followers, identity and history. Quest supplies the daily completion streak in the person's chosen time zone, with yesterday's streak retained until today's end, and the badge count. Live supplies current channel state; a missing channel or failed source is absent, never an invented offline state. No presence/last-seen timestamps, page titles, visited URLs, shared relationships or viewer-dependent data leave this public read.++Network owns global levels (ask 241), followers, identity and history. Quest supplies the daily completion streak in the person's chosen time zone, with yesterday's streak retained until today's end, and the badge count. Live supplies current channel state; a missing channel or failed source is absent, never an invented offline state. No presence/last-seen timestamps, page titles, visited URLs or shared relationships leave the public read. The only viewer-dependent data is `actions`, and only when the viewer sends their token (see Actions).The aggregate has a process-local 30-second cache capped at 2,000 entries; concurrent misses share one promise. All card enrichments share this aggregate cache. Quest/Live card reads time out after two seconds, refuse redirects, and use only operator-configured service origins. Account eligibility and visibility are reread before cache use. HTTP responses use `Cache-Control: no-store` so browsers/CDNs cannot keep a hidden field alive. Unknown, banned, deleted, merged and anonymous accounts return 404; old handles temporarily redirect to the canonical read.`GET/PUT /api/v1/me/card-visibility` reads/patches booleans for `display_name`, `avatar_url`, `live`, `level`, `streak`, `badges_count`, `followers`, `active_sites`, `channel_url`. Fields default on except `active_sites`, which requires explicit opt-in to share site names from the last seven days of history. A private profile suppresses every enrichment; hidden Live places suppress live/channel links, hidden presence suppresses live state, and leaderboard opt-out suppresses level. Paused history or retaining private/mature history suppresses weekly sites. Public-place exclusions also filter sites. These stronger choices override card preferences. Preferences are included in account export and cleared on deletion.++## Actions (ask 287)++++The read gains `actions: [{ id, label, href|method, state }]`, computed per request with the++viewer's token and never cached. Send the Network session as `Authorization: Bearer …` (or the++session cookie); a missing, expired or revoked token reads as anonymous, never a 401. Without++a viewer — or on the viewer's own card — the card keeps only Profile/Channel. A viewer who++blocked the person sees Unblock + Report; a viewer the person blocked sees Block + Report.++++For a signed-in viewer and another person, neither blocking the other:++++```json++{"actions": [++ {"id": "message", "label": "Message", "href": "https://openvibe.chat/messages"},++ {"id": "follow", "label": "Follow", "method": "PUT", "path": "/api/v1/me/follows/channel/Example", "state": {"following": false}},++ {"id": "add-friend", "label": "Add friend", "method": "PUT", "path": "/api/v1/me/friends/Example", "state": {"friendship": "none"}},++ {"id": "tip", "label": "Tip", "href": "https://openvibe.tips/Example"},++ {"id": "watch", "label": "Watch together", "href": "https://openvibe.live/@Example"},++ {"id": "mute", "label": "Mute", "method": "PUT", "path": "/api/v1/me/mutes/Example", "state": {"muted": false}},++ {"id": "block", "label": "Block", "method": "PUT", "path": "/api/v1/me/blocks/Example", "state": {"blocked": false}},++ {"id": "report", "label": "Report", "method": "POST", "path": "/api/v1/me/reports", "body": {"target": "Example", "reason": "other", "detail": ""}, "state": {"reported": false}}++]}++```++++`{ id, label, href }` opens somewhere: Message starts in the chat inbox, Tip opens the++person's tip page on OpenVibe.Tips, and `watch` opens their channel and appears only while they++are live. `{ id, label, method, path, state }` calls the Network route with the viewer's token++(a `report` also carries `body`). `state.friendship` is one of `none`, `pending` (I added them++— the action reads Cancel request), `requested` (they added me — the action reads Accept),++`friends`. Mute/Unmute and Block/Unblock carry their state the same way. The client must send++the viewer's Network token with the read for these to appear; the bare public read (a site++fetching with no token) keeps Profile/Channel only.++++Staff tools follow Contracts' staff-role map, not a second role list here: a viewer holding++`staff.moderation.logs` also gets the Review queue and the User-reports queue filtered to the++person. Follow/Unfollow reuse `PUT/DELETE /api/v1/me/follows/channel/:target`; Block/Unblock++reuse `PUT/DELETE /api/v1/me/blocks/:target`. New beside them:++++- `GET /api/v1/me/mutes`, `PUT/DELETE /api/v1/me/mutes/:subjectOrUsername` — hush one person's++ non-staff notices (server/identity/mutes.js). Nobody mutes themselves; guests neither mute++ nor are muted.++- `GET /api/v1/me/friends`, `PUT/DELETE /api/v1/me/friends/:subjectOrUsername` — add one++ direction at a time; friends when both are active (server/identity/friends.js).++- `POST /api/v1/me/reports { target, reason, detail }` — file about another person (reasons:++ `spam`, `harassment`, `impersonation`, `explicit`, `scam`, `other`; 201 new, 200 while one is++ still open). Staff holding `staff.moderation.logs` read `GET /api/v1/staff/user-reports`++ (`?status=open&target=usr_…&limit=`).++++Mute, friend and report changes carry no relayed event yet: nothing consumes them, so the++revision only orders the card's state. A service that should honour a mute reads the same++subject pairs the way it reads blocks; that internal read (with its own Contracts capability)++is a follow-up.++## Next for the design (For Fable)--Build the common card appearance and the account controls for field visibility (including explicit weekly-site sharing), using this read and the Shared hook. No new page styling ships here. Relationship actions and site-specific rows remain follow-up work outside this read.++Build the common card appearance and the account controls for field visibility (including explicit weekly-site sharing), using this read and the Shared hook. No new page styling ships here. Render the `actions` array (kit buttons: `ovk-btn ovk-btn--primary|quiet`); a `method` action calls its Network route with the viewer's token. Site-specific rows (Live: mod actions; Quest: rank) remain follow-up work outside this read.nnew file mode 100644@@@ -0,0 +1,44 @@++-- phase: expand++-- Ask 287: the user card's actions. Mutes, friendships and user reports, beside blocks.++-- Mutes hush one person's non-staff notices the way blocks stop them; friendships are++-- mutual adds (friends when both directions are active); reports queue for staff.++-- Nothing here changes an existing table, and no production setting is touched.++CREATE TABLE IF NOT EXISTS user_mutes (++ muter_subject text COLLATE "C" NOT NULL,++ muted_subject text COLLATE "C" NOT NULL,++ active bigint NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),++ revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ PRIMARY KEY (muter_subject, muted_subject),++ CHECK (muter_subject <> muted_subject)++);++CREATE INDEX IF NOT EXISTS idx_user_mutes_muted ON user_mutes(muted_subject, active);++++CREATE TABLE IF NOT EXISTS user_friends (++ user_subject text COLLATE "C" NOT NULL,++ friend_subject text COLLATE "C" NOT NULL,++ active bigint NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),++ revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ PRIMARY KEY (user_subject, friend_subject),++ CHECK (user_subject <> friend_subject)++);++CREATE INDEX IF NOT EXISTS idx_user_friends_friend ON user_friends(friend_subject, active);++++CREATE TABLE IF NOT EXISTS user_reports (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ reporter_subject text COLLATE "C" NOT NULL,++ target_subject text COLLATE "C" NOT NULL,++ reason text COLLATE "C" NOT NULL,++ detail text COLLATE "C" NOT NULL DEFAULT '',++ status text COLLATE "C" NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'reviewed', 'dismissed')),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ CHECK (reporter_subject <> target_subject)++);++CREATE INDEX IF NOT EXISTS idx_user_reports_target ON user_reports(target_subject, status);++CREATE INDEX IF NOT EXISTS idx_user_reports_reporter ON user_reports(reporter_subject, target_subject);++-- One open report per pair: filing again while it is open returns the open one, never a duplicate.++CREATE UNIQUE INDEX IF NOT EXISTS uq_user_reports_open ON user_reports(reporter_subject, target_subject) WHERE status = 'open';@@@ -218,6 +218,11 @@ async function networkPart(db, userId) {const followers = await tableExists(db, 'user_follows') ? (await db.prepare("SELECT COUNT(*) AS n FROM user_follows WHERE target_id = ? AND active = 1").get(u.subject_id)).n : 0;add('follows.json', { following: follows.rows, followers_count: followers }, follows.truncated);add('blocks.json', (await rowsOf(db, 'user_blocks', 'blocker_subject = ?', [u.subject_id])).rows);++ // Ask 287: who they muted and friended, and the reports they filed. These are the person's own++ // rows; a report about them names them as the target, so it is not their export.++ add('mutes.json', (await rowsOf(db, 'user_mutes', 'muter_subject = ?', [u.subject_id])).rows);++ add('friends.json', (await rowsOf(db, 'user_friends', 'user_subject = ?', [u.subject_id])).rows);++ add('reports.json', (await rowsOf(db, 'user_reports', 'reporter_subject = ?', [u.subject_id])).rows);// Presence (plan X9): the chosen state, status line, emoji, switch and the heartbeat times. No last seen// beyond the heartbeat the person's own tabs sent.add('presence.json', (await rowsOf(db, 'person_presence', 'subject_id = ?', [u.subject_id])).rows);@@@ -374,6 +379,9 @@ async function erase(db, deletion, { now = Date.now() } = {}) {await del('dock_states', `subject IN ${inS}`, subjects, 'dock_states');await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');++ await del('user_mutes', `muter_subject IN ${inS} OR muted_subject IN ${inS}`, [...subjects, ...subjects], 'mutes');++ await del('user_friends', `user_subject IN ${inS} OR friend_subject IN ${inS}`, [...subjects, ...subjects], 'friends');++ await del('user_reports', `reporter_subject IN ${inS} OR target_subject IN ${inS}`, [...subjects, ...subjects], 'reports');// The person's agents first (WS-Z2), then the agents of the projects archived below.// Their projects' locks first, as agent creation takes them (developer/store.lockProject): no agent of theirs is created after this.if (await tableExists(db, 'dev_projects')) {nnew file mode 100644@@@ -0,0 +1,139 @@++'use strict';++/**++ * Friendships (ask 287): "Add friend" on the user card. No request inbox: adding is one++ * direction at a time, and the pair are friends when both directions are active. Removing++ * either direction ends it. The card reads the pair's state (none, pending, requested,++ * friends) so the action can say Add friend, Requested, or Friends.++ *++ * user_friends is keyed by subjects (usr_…), one row per (user, friend). An unfriend keeps++ * the row with active = 0 so the pair's revision keeps growing. Nobody friends themselves,++ * and guests neither friend nor are friended. No change event yet: nothing relays friends,++ * so the revision only orders the card's state.++ *++ * GET /api/v1/me/friends whom I friended (subject, username, display name)++ * PUT /api/v1/me/friends/:subjectOrUsername add (201 new, 200 already added)++ * DELETE /api/v1/me/friends/:subjectOrUsername remove (200; 404 when not added)++ */++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { http } = require('openvibe-contracts');++const { findTarget } = require('./blocks');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const MAX_ACTIVE = 5000;++++class FriendError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }++++/** The person a path segment names, with friend-shaped errors (blocks.findTarget raises blocks.*). */++async function target(db, key) {++ try {++ return await findTarget(db, key);++ } catch (err) {++ if (err && err.code === 'blocks.guest') throw new FriendError(400, 'friends.guest', 'guests cannot be friended');++ throw err;++ }++}++++async function outgoing(db, userSubject, friendSubject) {++ return !!await db.prepare('SELECT 1 FROM user_friends WHERE user_subject = ? AND friend_subject = ? AND active = 1').get(String(userSubject), String(friendSubject));++}++++/** The pair's state from `viewer`'s side: none, pending (I added them), requested (they added me), friends. */++async function stateOf(db, viewer, other) {++ const mine = await outgoing(db, viewer, other);++ const theirs = await outgoing(db, other, viewer);++ if (mine && theirs) return 'friends';++ if (mine) return 'pending';++ if (theirs) return 'requested';++ return 'none';++}++++/** Set (user, friend) to `active`. Both must be usr_ subjects. → { changed, active, revision, at } */++async function setFriend(db, user, friend, active) {++ if (!SUBJECT_RE.test(String(user)) || !SUBJECT_RE.test(String(friend))) throw new FriendError(400, 'friends.bad_subject', 'friends are between usr_ subjects');++ if (user === friend) throw new FriendError(400, 'friends.self', 'you cannot friend yourself');++ return await db.tx(async () => {++ const prev = await db.prepare('SELECT active, revision, updated_at FROM user_friends WHERE user_subject = ? AND friend_subject = ?').get(user, friend);++ if (prev && !!prev.active === !!active) return { changed: false, active: !!active, revision: prev.revision, at: prev.updated_at };++ if (!prev && !active) return { changed: false, active: false, revision: 0, at: null };++ if (active) {++ const n = (await db.prepare('SELECT COUNT(*) AS n FROM user_friends WHERE user_subject = ? AND active = 1').get(user)).n;++ if (n >= MAX_ACTIVE) throw new FriendError(409, 'friends.limit', `at most ${MAX_ACTIVE} people can be friended`);++ }++ const at = new Date().toISOString();++ const revision = prev ? prev.revision + 1 : 1;++ if (prev) {++ await db.prepare('UPDATE user_friends SET active = ?, revision = ?, updated_at = ? WHERE user_subject = ? AND friend_subject = ?')++ .run(active ? 1 : 0, revision, at, user, friend);++ } else {++ await db.prepare('INSERT INTO user_friends (user_subject, friend_subject, active, revision, created_at, updated_at) VALUES (?, ?, 1, 1, ?, ?)')++ .run(user, friend, at, at);++ }++ return { changed: true, active: !!active, revision, at };++ });++}++++/** Whom `subject` added (with names, newest first), for the person's own list. */++async function listFor(db, subject) {++ return (await db.prepare(`SELECT f.friend_subject, f.revision, f.updated_at, u.username, u.display_name++ FROM user_friends f LEFT JOIN users u ON u.subject_id = f.friend_subject++ WHERE f.user_subject = ? AND f.active = 1 ORDER BY f.updated_at DESC, f.friend_subject`).all(String(subject)))++ .map((r) => ({++ subject: r.friend_subject, username: r.username || null, display_name: r.username ? (r.display_name || r.username) : null,++ added_at: r.updated_at, revision: r.revision,++ }));++}++++/** The signed-in person as a friend, or a FriendError. */++async function meOf(db, user) {++ if (!user || user.is_anon) throw new FriendError(403, 'friends.guest', 'sign in with an account to add friends');++ const sid = await require('./subjects').ensureUserSubject(db, user);++ if (!SUBJECT_RE.test(String(sid || ''))) throw new FriendError(403, 'friends.guest', 'sign in with an account to add friends');++ return sid;++}++const view = (u, out, state) => ({ subject: u.subject_id, username: u.username, display_name: u.display_name || u.username, active: out.active, revision: out.revision, state, ...(out.active ? { added_at: out.at } : {}) });++++function userRouter(requireAuth) {++ const router = express.Router();++ router.use(http.middleware());++ const send = async (req, res, fn) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ try { return await fn(req.app.locals.db); } catch (err) {++ if (err instanceof FriendError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });++ console.error('[Friends]', err.message);++ return http.sendProblem(res, 500, 'friends.failed', { detail: 'the friendship could not be changed', ctx: req.ov });++ }++ };++++ router.get('/', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);++ res.json({ subject: me, friends: await listFor(db, me) });++ }));++++ router.put('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);++ const u = await target(db, req.params.target);++ if (!u) throw new FriendError(404, 'friends.unknown_person', 'no such person');++ if (u.is_anon) throw new FriendError(400, 'friends.guest', 'guests cannot be friended');++ if (u.subject_id === me) throw new FriendError(400, 'friends.self', 'you cannot friend yourself');++ const out = await setFriend(db, me, u.subject_id, true);++ res.status(out.changed ? 201 : 200).json({ changed: out.changed, friend: view(u, out, await stateOf(db, me, u.subject_id)) });++ }));++++ router.delete('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);++ const key = String(req.params.target || '');++ const u = await target(db, key) || (SUBJECT_RE.test(key) ? { subject_id: key, username: null, display_name: null } : null);++ if (!u || !u.subject_id || !await outgoing(db, me, u.subject_id)) throw new FriendError(404, 'friends.not_added', 'that person is not added');++ const out = await setFriend(db, me, u.subject_id, false);++ res.json({ changed: out.changed, friend: view(u, out, await stateOf(db, me, u.subject_id)) });++ }));++++ return router;++}++++module.exports = { MAX_ACTIVE, FriendError, ensureSchema, setFriend, outgoing, stateOf, listFor, userRouter };nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * Platform mutes (ask 287): hush one person for the person who muted them. Blocks stop++ * every contact; a mute only hushes their non-staff notices — their messages and mentions++ * stop pinging, while moderation, system and admin notices are always delivered.++ *++ * user_mutes is keyed by subjects (usr_…), one row per (muter, muted). An unmute keeps the++ * row with active = 0 so the pair's revision keeps growing. Nobody mutes themselves, and++ * guests neither mute nor are muted. Chat, Community and Network's own notifications read++ * this the way they read blocks (server/identity/blocks.js) once its internal read ships.++ *++ * No change event yet: nothing relays mutes, so the revision only orders the card's state.++ *++ * GET /api/v1/me/mutes whom I muted (subject, username, display name)++ * PUT /api/v1/me/mutes/:subjectOrUsername mute (201 new, 200 already muted)++ * DELETE /api/v1/me/mutes/:subjectOrUsername unmute (200; 404 when not muted)++ */++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { http } = require('openvibe-contracts');++const { findTarget } = require('./blocks');++++/** The person a path segment names, with mute-shaped errors (blocks.findTarget raises blocks.*). */++async function target(db, key) {++ try {++ return await findTarget(db, key);++ } catch (err) {++ if (err && err.code === 'blocks.guest') throw new MuteError(400, 'mutes.guest', 'guests cannot be muted');++ throw err;++ }++}++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const MAX_ACTIVE = 5000;++++class MuteError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }++++async function isMuted(db, muterSubject, mutedSubject) {++ return !!await db.prepare('SELECT 1 FROM user_mutes WHERE muter_subject = ? AND muted_subject = ? AND active = 1').get(String(muterSubject), String(mutedSubject));++}++++/** Set (muter, muted) to `active`. Both must be usr_ subjects. → { changed, active, revision, at } */++async function setMute(db, muter, muted, active) {++ if (!SUBJECT_RE.test(String(muter)) || !SUBJECT_RE.test(String(muted))) throw new MuteError(400, 'mutes.bad_subject', 'mutes are between usr_ subjects');++ if (muter === muted) throw new MuteError(400, 'mutes.self', 'you cannot mute yourself');++ return await db.tx(async () => {++ const prev = await db.prepare('SELECT active, revision, updated_at FROM user_mutes WHERE muter_subject = ? AND muted_subject = ?').get(muter, muted);++ if (prev && !!prev.active === !!active) return { changed: false, active: !!active, revision: prev.revision, at: prev.updated_at };++ if (!prev && !active) return { changed: false, active: false, revision: 0, at: null };++ if (active) {++ const n = (await db.prepare('SELECT COUNT(*) AS n FROM user_mutes WHERE muter_subject = ? AND active = 1').get(muter)).n;++ if (n >= MAX_ACTIVE) throw new MuteError(409, 'mutes.limit', `at most ${MAX_ACTIVE} people can be muted`);++ }++ const at = new Date().toISOString();++ const revision = prev ? prev.revision + 1 : 1;++ if (prev) {++ await db.prepare('UPDATE user_mutes SET active = ?, revision = ?, updated_at = ? WHERE muter_subject = ? AND muted_subject = ?')++ .run(active ? 1 : 0, revision, at, muter, muted);++ } else {++ await db.prepare('INSERT INTO user_mutes (muter_subject, muted_subject, active, revision, created_at, updated_at) VALUES (?, ?, 1, 1, ?, ?)')++ .run(muter, muted, at, at);++ }++ return { changed: true, active: !!active, revision, at };++ });++}++++/** Whom `subject` muted (with names, newest first), for the person's own list. */++async function listFor(db, subject) {++ return (await db.prepare(`SELECT m.muted_subject, m.revision, m.updated_at, u.username, u.display_name++ FROM user_mutes m LEFT JOIN users u ON u.subject_id = m.muted_subject++ WHERE m.muter_subject = ? AND m.active = 1 ORDER BY m.updated_at DESC, m.muted_subject`).all(String(subject)))++ .map((r) => ({++ subject: r.muted_subject, username: r.username || null, display_name: r.username ? (r.display_name || r.username) : null,++ muted_at: r.updated_at, revision: r.revision,++ }));++}++++/** The signed-in person as a muter, or a MuteError. */++async function meOf(db, user) {++ if (!user || user.is_anon) throw new MuteError(403, 'mutes.guest', 'sign in with an account to mute people');++ const sid = await require('./subjects').ensureUserSubject(db, user);++ if (!SUBJECT_RE.test(String(sid || ''))) throw new MuteError(403, 'mutes.guest', 'sign in with an account to mute people');++ return sid;++}++const view = (u, out) => ({ subject: u.subject_id, username: u.username, display_name: u.display_name || u.username, active: out.active, revision: out.revision, ...(out.active ? { muted_at: out.at } : {}) });++++function userRouter(requireAuth) {++ const router = express.Router();++ router.use(http.middleware());++ const send = async (req, res, fn) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ try { return await fn(req.app.locals.db); } catch (err) {++ if (err instanceof MuteError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });++ console.error('[Mutes]', err.message);++ return http.sendProblem(res, 500, 'mutes.failed', { detail: 'the mute could not be changed', ctx: req.ov });++ }++ };++++ router.get('/', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);++ res.json({ subject: me, mutes: await listFor(db, me) });++ }));++++ router.put('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);++ const u = await target(db, req.params.target);++ if (!u) throw new MuteError(404, 'mutes.unknown_person', 'no such person');++ if (u.is_anon) throw new MuteError(400, 'mutes.guest', 'guests cannot be muted');++ if (u.subject_id === me) throw new MuteError(400, 'mutes.self', 'you cannot mute yourself');++ const out = await setMute(db, me, u.subject_id, true);++ res.status(out.changed ? 201 : 200).json({ changed: out.changed, mute: view(u, out) });++ }));++++ router.delete('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);++ const key = String(req.params.target || '');++ const u = await target(db, key) || (SUBJECT_RE.test(key) ? { subject_id: key, username: null, display_name: null } : null);++ if (!u || !u.subject_id || !await isMuted(db, me, u.subject_id)) throw new MuteError(404, 'mutes.not_muted', 'that person is not muted');++ const out = await setMute(db, me, u.subject_id, false);++ res.json({ changed: out.changed, mute: view(u, out) });++ }));++++ return router;++}++++module.exports = { MAX_ACTIVE, MuteError, ensureSchema, setMute, isMuted, listFor, userRouter };nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++/**++ * User reports (ask 287): "Report" on the user card. A signed-in person reports another++ * person with a reason; staff read the queue. Reports never resolve themselves — a staff++ * decision lives in the moderation audit log (server/admin/moderation-audit.js), and the++ * report's status only records whether staff have looked.++ *++ * user_reports holds one row per filing (reporter, target, reason, detail, status). Filing++ * twice about the same person while the first is still open returns the open one instead++ * of a duplicate. Nobody reports themselves, and guests report nothing.++ *++ * POST /api/v1/me/reports file (201 new, 200 already open)++ * GET /api/v1/staff/user-reports the queue for staff holding++ * staff.moderation.logs (newest first,++ * ?status=open&target=usr_…&limit=)++ */++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { http, staff } = require('openvibe-contracts');++const { staffClaims } = require('../auth/staff-claims');++const { findTarget } = require('./blocks');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const REASONS = new Set(['spam', 'harassment', 'impersonation', 'explicit', 'scam', 'other']);++const STATUSES = new Set(['open', 'reviewed', 'dismissed']);++const MAX_DETAIL = 2000;++++class ReportError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }++++/** The person a value names, with report-shaped errors (blocks.findTarget raises blocks.*). */++async function target(db, key) {++ try {++ return await findTarget(db, key);++ } catch (err) {++ if (err && err.code === 'blocks.guest') throw new ReportError(400, 'user_reports.guest', 'guests cannot be reported');++ throw err;++ }++}++++/** The signed-in person as a reporter, or a ReportError. */++async function meOf(db, user) {++ if (!user || user.is_anon) throw new ReportError(403, 'user_reports.guest', 'sign in with an account to report people');++ const sid = await require('./subjects').ensureUserSubject(db, user);++ if (!SUBJECT_RE.test(String(sid || ''))) throw new ReportError(403, 'user_reports.guest', 'sign in with an account to report people');++ return sid;++}++++const cleanReason = (value) => {++ const r = String(value || '').trim().toLowerCase();++ if (!REASONS.has(r)) throw new ReportError(400, 'user_reports.reason', `reason must be one of ${[...REASONS].join(', ')}`);++ return r;++};++const cleanDetail = (value) => {++ const d = String(value == null ? '' : value).trim().slice(0, MAX_DETAIL);++ return d;++};++++const view = (r) => ({ id: r.id, reporter: r.reporter_subject, target: r.target_subject, reason: r.reason, detail: r.detail || '', status: r.status, created_at: r.created_at, updated_at: r.updated_at });++++/** File a report, or return the still-open one for the pair. → { report, created } */++async function file(db, reporter, targetSubject, reason, detail) {++ if (!SUBJECT_RE.test(String(reporter)) || !SUBJECT_RE.test(String(targetSubject))) throw new ReportError(400, 'user_reports.bad_subject', 'reports are between usr_ subjects');++ if (reporter === targetSubject) throw new ReportError(400, 'user_reports.self', 'you cannot report yourself');++ return await db.tx(async () => {++ const open = () => db.prepare(`SELECT * FROM user_reports WHERE reporter_subject = ? AND target_subject = ? AND status = 'open'++ ORDER BY id DESC LIMIT 1`).get(reporter, targetSubject);++ const found = await open();++ if (found) return { report: view(found), created: false };++ // The partial unique index turns a concurrent double filing into one row: the loser reads++ // the winner's report back instead of creating a duplicate.++ const row = await db.prepare(`INSERT INTO user_reports (reporter_subject, target_subject, reason, detail)++ VALUES (?, ?, ?, ?) ON CONFLICT (reporter_subject, target_subject) WHERE status = 'open' DO NOTHING RETURNING *`)++ .get(reporter, targetSubject, reason, detail);++ if (row) return { report: view(row), created: true };++ const raced = await open();++ return { report: view(raced), created: false };++ });++}++++/** Whether `reporter` has an open report about `targetSubject`. */++async function hasOpen(db, reporter, targetSubject) {++ return !!await db.prepare(`SELECT 1 FROM user_reports WHERE reporter_subject = ? AND target_subject = ? AND status = 'open' LIMIT 1`)++ .get(String(reporter), String(targetSubject));++}++++/** The staff queue, newest first. Filters: status (default open), target subject. */++async function listForStaff(db, { status = 'open', target = null, limit = 50 } = {}) {++ if (!STATUSES.has(status)) throw new ReportError(400, 'user_reports.status', `status must be one of ${[...STATUSES].join(', ')}`);++ if (target !== null && !SUBJECT_RE.test(String(target))) throw new ReportError(400, 'user_reports.bad_subject', 'target must be a usr_ subject id');++ const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);++ const where = ['status = ?'];++ const args = [status];++ if (target !== null) { where.push('target_subject = ?'); args.push(String(target)); }++ args.push(n);++ return (await db.prepare(`SELECT * FROM user_reports WHERE ${where.join(' AND ')} ORDER BY id DESC LIMIT ?`).all(...args)).map(view);++}++++function userRouter(requireAuth) {++ const router = express.Router();++ router.use(http.middleware());++ const send = async (req, res, fn) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ try { return await fn(req.app.locals.db); } catch (err) {++ if (err instanceof ReportError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });++ console.error('[UserReports]', err.message);++ return http.sendProblem(res, 500, 'user_reports.failed', { detail: 'the report could not be filed', ctx: req.ov });++ }++ };++++ router.post('/', requireAuth, express.json({ limit: '16kb' }), async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ const u = await target(db, body.target);++ if (!u) throw new ReportError(404, 'user_reports.unknown_person', 'no such person');++ if (u.is_anon) throw new ReportError(400, 'user_reports.guest', 'guests cannot be reported');++ if (u.subject_id === me) throw new ReportError(400, 'user_reports.self', 'you cannot report yourself');++ const out = await file(db, me, u.subject_id, cleanReason(body.reason), cleanDetail(body.detail));++ res.status(out.created ? 201 : 200).json({ created: out.created, report: out.report });++ }));++++ return router;++}++++/** GET /api/v1/staff/user-reports (mount behind requireAuth; the capability check is inside). */++function staffRouter() {++ const router = express.Router();++ router.use(http.middleware());++ router.get('/', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ try {++ if (!req.user || !staff.can(staffClaims(req.user), 'staff.moderation.logs')) {++ return http.sendProblem(res, 403, 'user_reports.forbidden', { detail: 'staff.moderation.logs required', ctx: req.ov });++ }++ res.json({ reports: await listForStaff(req.app.locals.db, { status: req.query.status, target: req.query.target || null, limit: req.query.limit }) });++ } catch (err) {++ if (err instanceof ReportError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });++ console.error('[UserReports]', err.message);++ return http.sendProblem(res, 500, 'user_reports.failed', { detail: 'the queue could not be read', ctx: req.ov });++ }++ });++ return router;++}++++module.exports = { REASONS, STATUSES, ReportError, ensureSchema, file, hasOpen, listForStaff, userRouter, staffRouter };@@@ -659,6 +659,15 @@ const notifyAccountData = async (userId, n) => await notificationService.create(app.use('/internal', machineInternal.internal);}app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read'), require('./identity/blocks').internalHandler(db));++// Platform mutes and friendships (ask 287): a person's own lists and the changes the card's++// actions make. Services read them through the internal routes once Contracts names their++// capabilities; until then the person's own routes are the complete surface.++app.use('/api/v1/me/mutes', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/mutes').userRouter(requireAuth));++app.use('/api/v1/me/friends', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/friends').userRouter(requireAuth));++// User reports (ask 287): a signed-in person files about another person; staff holding++// staff.moderation.logs read the queue. The capability check lives in the staff router.++app.use('/api/v1/me/reports', rateLimit({ windowMs: 60_000, max: 30 }), require('./identity/user-reports').userRouter(requireAuth));++app.use('/api/v1/staff/user-reports', rateLimit({ windowMs: 60_000, max: 60 }), requireAuth, require('./identity/user-reports').staffRouter());// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).{@@@ -972,7 +981,24 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)app.locals.avatarService = avatarService;// Public profiles (plan T21 step 3): openvibe.network/@<username> and GET /api/v1/profiles/:username (CORS-open,// server/public-cors.js), with the items from OpenVibe.Inventory (server/profile/public.js).--const userCards = require('./profile/card').createUserCards({ db, profiles: publicProfiles, selfToken, questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980', liveUrl: config.services.live.internalUrl });++const userCards = require('./profile/card').createUserCards({ db, profiles: publicProfiles, selfToken, questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980', liveUrl: config.services.live.internalUrl, identify: identifyCardViewer });++function identifyCardViewer(req) {++ // Optional viewer identity for the card's actions: a missing, expired or revoked token++ // reads as anonymous (Profile/Channel only), never a 401. No renewal here — a plain read++ // must not rotate the session's cookies.++ const { sessionToken, verifySession } = require('./auth/session');++ const token = sessionToken(req);++ if (!token) return Promise.resolve(null);++ return verifySession(token, authCtx()).then(async (out) => {++ if (out.error || !out.user || out.user.is_anon) return null;++ const subject = await require('./identity/subjects').ensureUserSubject(db, out.user).catch(() => null);++ if (!subject) return null;++ // The card's staff tools follow the same capability map every service uses (ADR-022),++ // so a role change reaches the card without a second list of who counts as staff.++ const claims = require('./auth/staff-claims').staffClaims(out.user);++ return { id: out.user.id, subject, username: out.user.username, role: out.user.role, staff: claims };++ });++}app.get('/api/v1/people/:username/card', rateLimit({ windowMs: 60_000, max: 240 }), userCards.api);app.use('/api/v1/me/card-visibility', rateLimit({ windowMs: 60_000, max: 60 }), userCards.settings(requireAuth));app.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);@@@ -2,6 +2,7 @@const express = require('express');const levels = require('../levels/store');const follows = require('../identity/follows');++const { staff } = require('openvibe-contracts');const { SITES } = require('../frame/sites');const TTL_MS = 30_000;const MAX_CACHE = 2000;@@@ -9,7 +10,7 @@ const FIELDS = ['display_name', 'avatar_url', 'live', 'level', 'streak', 'badgesconst defaults = () => Object.fromEntries(FIELDS.map(k => [k, k !== 'active_sites']));const object = value => { try { return typeof value === 'string' ? JSON.parse(value) : (value || {}); } catch { return {}; } };--function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'http://127.0.0.1:4980', liveUrl = 'http://127.0.0.1:3000', fetchImpl = (...args) => fetch(...args), now = Date.now }) {++function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'http://127.0.0.1:4980', liveUrl = 'http://127.0.0.1:3000', fetchImpl = (...args) => fetch(...args), now = Date.now, identify = null }) {const cache = new Map();const pending = new Map();async function enabled() {@@@ -88,6 +89,61 @@ function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'httpending.set(key, task);return task;}++ // What the viewer can do with the person on the card (ask 287). Action descriptors are++ // plain data Shared renders: { id, label, href } opens somewhere, { id, label, method, path,++ // state } calls a Network route with the viewer's token (a report also carries its body).++ // Computed per request with the viewer's token — never cached, never stored. Without a++ // viewer (or for the viewer's own card) the card keeps only Profile/Channel.++ const staffCan = (viewer, capability) => !!(viewer && viewer.staff && staff.can(viewer.staff, capability));++ async function actionsFor(viewer, target, card, username) {++ const acts = [{ id: 'profile', label: 'Profile', href: card.profile_url }];++ if (card.channel_url) acts.push({ id: 'channel', label: 'Channel', href: card.channel_url });++ if (!viewer || !target || viewer.subject === target.subject_id) return acts;++ const blocks = require('../identity/blocks');++ const reports = require('../identity/user-reports');++ const [iBlocked, blockedMe] = await Promise.all([++ blocks.isBlocked(db, viewer.subject, target.subject_id),++ blocks.isBlocked(db, target.subject_id, viewer.subject),++ ]);++ const report = { id: 'report', label: 'Report', method: 'POST', path: '/api/v1/me/reports', body: { target: username, reason: 'other', detail: '' }, state: { reported: await reports.hasOpen(db, viewer.subject, target.subject_id).catch(() => false) } };++ if (blockedMe && !iBlocked) return [...acts,++ { id: 'block', label: 'Block', method: 'PUT', path: `/api/v1/me/blocks/${encodeURIComponent(username)}`, state: { blocked: false } },++ report];++ if (iBlocked) return [...acts,++ { id: 'unblock', label: 'Unblock', method: 'DELETE', path: `/api/v1/me/blocks/${encodeURIComponent(username)}`, state: { blocked: true } },++ report];++ const follows = require('../identity/follows');++ const mutes = require('../identity/mutes');++ const friends = require('../identity/friends');++ const [following, muted, friendship] = await Promise.all([++ follows.status(db, 'channel', target.subject_id, viewer.subject).then((s) => !!s.following).catch(() => false),++ mutes.isMuted(db, viewer.subject, target.subject_id).catch(() => false),++ friends.stateOf(db, viewer.subject, target.subject_id).catch(() => 'none'),++ ]);++ const out = [...acts,++ { id: 'message', label: 'Message', href: 'https://openvibe.chat/messages' },++ following++ ? { id: 'unfollow', label: 'Unfollow', method: 'DELETE', path: `/api/v1/me/follows/channel/${encodeURIComponent(username)}`, state: { following: true } }++ : { id: 'follow', label: 'Follow', method: 'PUT', path: `/api/v1/me/follows/channel/${encodeURIComponent(username)}`, state: { following: false } },++ ];++ if (friendship === 'friends') out.push({ id: 'unfriend', label: 'Unfriend', method: 'DELETE', path: `/api/v1/me/friends/${encodeURIComponent(username)}`, state: { friendship } });++ else if (friendship === 'pending') out.push({ id: 'cancel-request', label: 'Requested', method: 'DELETE', path: `/api/v1/me/friends/${encodeURIComponent(username)}`, state: { friendship } });++ else out.push({ id: 'add-friend', label: friendship === 'requested' ? 'Accept' : 'Add friend', method: 'PUT', path: `/api/v1/me/friends/${encodeURIComponent(username)}`, state: { friendship } });++ out.push({ id: 'tip', label: 'Tip', href: `https://openvibe.tips/${encodeURIComponent(username)}` });++ if (card.live && card.live.now === true && card.live.url) out.push({ id: 'watch', label: 'Watch together', href: card.live.url });++ out.push(muted++ ? { id: 'unmute', label: 'Unmute', method: 'DELETE', path: `/api/v1/me/mutes/${encodeURIComponent(username)}`, state: { muted: true } }++ : { id: 'mute', label: 'Mute', method: 'PUT', path: `/api/v1/me/mutes/${encodeURIComponent(username)}`, state: { muted: false } });++ out.push({ id: 'block', label: 'Block', method: 'PUT', path: `/api/v1/me/blocks/${encodeURIComponent(username)}`, state: { blocked: false } });++ out.push(report);++ // Staff tools by capability (the role map lives in Contracts' staff-roles, not in this++ // read): moderators with the moderation log can jump to the queue that names this person.++ if (staffCan(viewer, 'staff.moderation.logs')) {++ out.push({ id: 'staff-review', label: 'Review queue', href: 'https://openvibe.network/admin/review' });++ out.push({ id: 'staff-reports', label: 'User reports', href: `https://openvibe.network/api/v1/staff/user-reports?target=${encodeURIComponent(target.subject_id)}&status=open` });++ }++ return out;++ }const handle = fn => async (req, res, next) => { try { await fn(req, res); } catch (e) { next(e); } };const api = handle(async (req, res) => {res.set('Cache-Control', 'no-store'); // Never let a browser/CDN outlive a privacy change.@@@ -95,7 +151,15 @@ function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'httconst value = await read(req.params.username);if (!value) return res.status(404).json({ error: 'user_card.not_found' });if (value.moved) return res.redirect(307, `/api/v1/people/${encodeURIComponent(value.moved)}/card`);-- return res.json(value);++ let viewer = null, target = null;++ if (typeof identify === 'function') {++ viewer = await identify(req).catch(() => null);++ if (viewer && viewer.subject) {++ const found = await profiles.find(req.params.username).catch(() => null);++ target = found && !found.moved ? found.user : null;++ }++ }++ return res.json({ ...value, actions: await actionsFor(viewer, target, value, req.params.username) });});function settings(requireAuth) {const router = express.Router();@@@ -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']]);++ 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']]);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,132 @@++'use strict';++// Ask 287: the user card's actions. The card read gains viewer-aware `actions` (computed per++// request, never cached); mutes, friendships and user reports are new Network resources beside++// blocks, reusing the follow/block route shapes.++// ov test test/user-card-actions.test.js++const assert = require('assert');++const express = require('express');++const { getDb } = require('../server/db/database');++const { createPublicProfiles } = require('../server/profile/public');++const { createUserCards } = require('../server/profile/card');++const { setFollow } = require('../server/identity/follows');++const { setBlock } = require('../server/identity/blocks');++const { setMute } = require('../server/identity/mutes');++const { setFriend } = require('../server/identity/friends');++const cors = require('../server/public-cors');++(async () => {++ const db = getDb();++ const VIC = 'usr_01JAB2C3D4E5F6G7H8J9K0MNV1';++ const TED = 'usr_01JAB2C3D4E5F6G7H8J9K0MNT2';++ await db.prepare("INSERT INTO users(username,password_hash,display_name,subject_id,role) VALUES ('VicAct','x','Vic',?,'user'),('TedAct','x','Ted',?,'user') ON CONFLICT DO NOTHING").run(VIC, TED);++ const vic = await db.prepare("SELECT * FROM users WHERE username='VicAct'").get();++ const ted = await db.prepare("SELECT * FROM users WHERE username='TedAct'").get();++ // A fresh pair: clear any rows an earlier run left behind.++ await db.prepare('DELETE FROM user_mutes WHERE muter_subject IN (?,?) OR muted_subject IN (?,?)').run(VIC, TED, VIC, TED);++ await db.prepare('DELETE FROM user_friends WHERE user_subject IN (?,?) OR friend_subject IN (?,?)').run(VIC, TED, VIC, TED);++ await db.prepare('DELETE FROM user_reports WHERE reporter_subject IN (?,?) OR target_subject IN (?,?)').run(VIC, TED, VIC, TED);++ await db.prepare('DELETE FROM user_blocks WHERE blocker_subject IN (?,?) OR blocked_subject IN (?,?)').run(VIC, TED, VIC, TED);++ await db.prepare('DELETE FROM user_follows WHERE follower_subject IN (?,?) OR target_id IN (?,?)').run(VIC, TED, VIC, TED);++ const fetchImpl = async (url) => {++ if (url.endsWith('/badges')) return { ok: true, json: async () => ({ badges: [] }) };++ if (url.includes('/streak')) return { ok: true, json: async () => ({}) };++ return { ok: true, json: async () => ({ channel: { username: 'TedAct' }, streams: [{ id: 7 }] }) };++ };++ let viewer = null; // the stubbed viewer identify() returns++ const profiles = createPublicProfiles({ db });++ const cards = createUserCards({ db, profiles, fetchImpl, identify: async () => viewer });++ const authStub = (user) => (req, res, next) => { req.user = user; next(); };++ const app = express(); app.use(express.json());++ app.locals.db = db; app.locals.publicKey = 'x';++ app.get('/api/v1/people/:username/card', cors.publicCors, cards.api);++ const asVic = { id: vic.id, username: 'VicAct', subject_id: VIC, is_anon: false, role: 'user' };++ const asTed = { id: ted.id, username: 'TedAct', subject_id: TED, is_anon: false, role: 'user' };++ app.use('/api/v1/me/mutes', require('../server/identity/mutes').userRouter(authStub(asVic)));++ app.use('/api/v1/me/friends', require('../server/identity/friends').userRouter(authStub(asVic)));++ app.use('/api/v1/me/reports', require('../server/identity/user-reports').userRouter(authStub(asVic)));++ app.use('/api/v1/staff/user-reports', authStub(asVic), require('../server/identity/user-reports').staffRouter());++ const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const card = (who) => fetch(`${base}/api/v1/people/${who}/card`).then(r => r.json());++ try {++ await db.prepare("UPDATE site_settings SET value='true' WHERE key='user_card_enabled'").run();++ // Anonymous: only Profile/Channel.++ viewer = null;++ let tedCard = await card('TedAct');++ assert.deepStrictEqual(tedCard.actions, [++ { id: 'profile', label: 'Profile', href: 'https://openvibe.network/@TedAct' },++ { id: 'channel', label: 'Channel', href: 'https://openvibe.live/@TedAct' },++ ], 'anonymous card keeps Profile/Channel');++ // Own card, signed in: still only Profile/Channel.++ viewer = { subject: VIC, username: 'VicAct', role: 'user' };++ let vicCard = await card('VicAct');++ assert.deepStrictEqual(vicCard.actions.map(a => a.id), ['profile'], 'own card has no self actions (no channel without live)');++ // Viewer + other: the full set with clean state.++ viewer = { subject: VIC, username: 'VicAct', role: 'user' };++ tedCard = await card('TedAct');++ assert.deepStrictEqual(tedCard.actions.map(a => a.id),++ ['profile', 'channel', 'message', 'follow', 'add-friend', 'tip', 'watch', 'mute', 'block', 'report'],++ 'viewer card lists every action, watch present while live');++ const byId = Object.fromEntries(tedCard.actions.map(a => [a.id, a]));++ assert.strictEqual(byId.follow.method, 'PUT');++ assert.strictEqual(byId.follow.path, '/api/v1/me/follows/channel/TedAct');++ assert.deepStrictEqual(byId.follow.state, { following: false });++ assert.deepStrictEqual(byId.report.body, { target: 'TedAct', reason: 'other', detail: '' });++ assert.strictEqual(byId.watch.href, 'https://openvibe.live/@TedAct');++ assert.ok(!tedCard.actions.some(a => a.id.startsWith('staff-')), 'no staff tools for a plain viewer');++ // Following flips the action to Unfollow.++ await setFollow(db, VIC, 'channel', TED, true, {});++ tedCard = await card('TedAct');++ assert.strictEqual(tedCard.actions.find(a => a.id === 'unfollow').state.following, true, 'following state is served');++ await setFollow(db, VIC, 'channel', TED, false, {});++ // Mute through its route, then the card state follows.++ assert.strictEqual((await (await fetch(`${base}/api/v1/me/mutes/TedAct`, { method: 'PUT' })).json()).mute.active, true);++ assert.strictEqual((await fetch(`${base}/api/v1/me/mutes`)).status, 200);++ tedCard = await card('TedAct');++ assert.strictEqual(tedCard.actions.find(a => a.id === 'unmute').state.muted, true, 'muted state is served');++ assert.strictEqual((await (await fetch(`${base}/api/v1/me/mutes/TedAct`, { method: 'DELETE' })).json()).mute.active, false);++ assert.strictEqual((await fetch(`${base}/api/v1/me/mutes/NoSuchPersonXYZ`, { method: 'PUT' })).status, 404, 'unknown people cannot be muted');++ assert.strictEqual((await fetch(`${base}/api/v1/me/friends/VicAct`, { method: 'PUT' })).status, 400, 'self adds are refused');++ // Friendship: none → pending → friends.++ assert.strictEqual((await (await fetch(`${base}/api/v1/me/friends/TedAct`, { method: 'PUT' })).json()).friend.state, 'pending');++ tedCard = await card('TedAct');++ assert.strictEqual(tedCard.actions.find(a => a.id === 'cancel-request').state.friendship, 'pending', 'outgoing request reads Requested');++ await setFriend(db, TED, VIC, true);++ tedCard = await card('TedAct');++ assert.strictEqual(tedCard.actions.find(a => a.id === 'unfriend').state.friendship, 'friends', 'mutual adds read Friends');++ // Report through its route, then the card state follows.++ const filed = await (await fetch(`${base}/api/v1/me/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'TedAct', reason: 'spam', detail: 'test' }) })).json();++ assert.strictEqual(filed.created, true);++ assert.strictEqual(filed.report.reason, 'spam');++ tedCard = await card('TedAct');++ assert.strictEqual(tedCard.actions.find(a => a.id === 'report').state.reported, true, 'open report is served');++ const again = await (await fetch(`${base}/api/v1/me/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'TedAct', reason: 'scam' }) })).json();++ assert.strictEqual(again.created, false, 'an open report is returned, never duplicated');++ const badReason = await fetch(`${base}/api/v1/me/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'TedAct', reason: 'bogus' }) });++ assert.strictEqual(badReason.status, 400, 'bad reasons are refused');++ const selfReport = await fetch(`${base}/api/v1/me/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'VicAct', reason: 'spam' }) });++ assert.strictEqual(selfReport.status, 400, 'self reports are refused');++ // A staff viewer sees the queue; a plain viewer is refused.++ assert.strictEqual((await fetch(`${base}/api/v1/staff/user-reports`)).status, 403, 'non-staff cannot read the queue');++ asVic.role = 'global_mod';++ const queue = await fetch(`${base}/api/v1/staff/user-reports?status=open&target=${TED}`);++ assert.strictEqual(queue.status, 200, 'a moderator reads the queue');++ const queueBody = await queue.json();++ assert.ok(queueBody.reports.some((r) => r.target === TED), 'the queue carries the open report');++ assert.strictEqual((await fetch(`${base}/api/v1/staff/user-reports?status=bogus`)).status, 400, 'a bad status is refused');++ asVic.role = 'user';++ // Blocked pair: only Unblock + Report.++ await setBlock(db, VIC, TED, true);++ tedCard = await card('TedAct');++ assert.deepStrictEqual(tedCard.actions.map(a => a.id), ['profile', 'channel', 'unblock', 'report'], 'a blocked person keeps Unblock + Report');++ await setBlock(db, VIC, TED, false);++ await setBlock(db, TED, VIC, true);++ tedCard = await card('TedAct');++ assert.deepStrictEqual(tedCard.actions.map(a => a.id), ['profile', 'channel', 'block', 'report'], 'being blocked keeps Block + Report');++ await setBlock(db, TED, VIC, false);++ // Staff tools for staff (by role).++ viewer = { subject: VIC, username: 'VicAct', role: 'global_mod', staff: require('../server/auth/staff-claims').staffClaims({ role: 'global_mod' }) };++ tedCard = await card('TedAct');++ assert.deepStrictEqual(tedCard.actions.filter(a => a.id.startsWith('staff-')).map(a => a.id), ['staff-review', 'staff-reports'], 'staff see their tools');++ console.log('user-card-actions: anonymous/self/viewer sets, follow/mute/friend/report/block states and staff tools passed');++ } finally { await new Promise(resolve => server.close(resolve)); }++})().catch(e => { console.error(e); process.exitCode = 1; });@@@ -39,7 +39,7 @@ const cors = require('../server/public-cors');assert.strictEqual(responses[0].headers.get('cache-control'), 'no-store');assert.strictEqual(responses[0].headers.get('access-control-allow-origin'), '*');assert.ok(cors.isPublicDiscoveryPath('/api/v1/people/CardTest/card'));-- assert.deepStrictEqual(card, { username: 'CardTest', display_name: 'Card Test', profile_url: 'https://openvibe.network/@CardTest', avatar_url: 'https://openvibe.network/avatar/CardTest?s=160', level: 1, followers: 0, badges_count: 1, streak: 3, channel_url: 'https://openvibe.live/@CardTest', live: { now: true, site: 'live', url: 'https://openvibe.live/@CardTest' } });++ assert.deepStrictEqual(card, { username: 'CardTest', display_name: 'Card Test', profile_url: 'https://openvibe.network/@CardTest', avatar_url: 'https://openvibe.network/avatar/CardTest?s=160', level: 1, followers: 0, badges_count: 1, streak: 3, channel_url: 'https://openvibe.live/@CardTest', live: { now: true, site: 'live', url: 'https://openvibe.live/@CardTest' }, actions: [{ id: 'profile', label: 'Profile', href: 'https://openvibe.network/@CardTest' }, { id: 'channel', label: 'Channel', href: 'https://openvibe.live/@CardTest' }] });assert.strictEqual(calls, 3, 'concurrent reads coalesce');await get(); assert.strictEqual(calls, 3, 'warm read reuses aggregate');clock += TTL_MS + 1; await get(); assert.strictEqual(calls, 6, 'expires after 30 seconds');@@@ -58,7 +58,7 @@ const cors = require('../server/public-cors');assert.ok(!('live' in visible) && !('channel_url' in visible));await db.prepare("UPDATE users SET profile_public=0, card_visibility='{}'::jsonb WHERE id=?").run(user.id);visible = await (await get()).json();-- assert.deepStrictEqual(Object.keys(visible).sort(), ['avatar_url', 'display_name', 'profile_url', 'username']);++ assert.deepStrictEqual(Object.keys(visible).sort(), ['actions', 'avatar_url', 'display_name', 'profile_url', 'username']);await db.prepare("UPDATE users SET profile_public=1, public_page_places='{}' WHERE id=?").run(user.id);await db.prepare("INSERT INTO person_presence(subject_id,state,show_status) VALUES (?, 'invisible', 1)").run(subject);visible = await (await get()).json();@@@ -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']]);++ 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']]);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.
@@ -8,12 +8,64 @@ Example (optional fields are omitted when hidden or unavailable):
@@ -8,12 +8,64 @@ Example (optional fields are omitted when hidden or unavailable):
{"username":"Example","display_name":"Example","avatar_url":"https://openvibe.network/avatar/Example?s=160","profile_url":"https://openvibe.network/@Example","level":4,"streak":3,"badges_count":2,"followers":10,"live":{"now":true,"site":"live","url":"https://openvibe.live/@Example"},"channel_url":"https://openvibe.live/@Example","active_sites":[{"id":"live","name":"Live","url":"https://openvibe.live"}]}
{"username":"Example","display_name":"Example","avatar_url":"https://openvibe.network/avatar/Example?s=160","profile_url":"https://openvibe.network/@Example","level":4,"streak":3,"badges_count":2,"followers":10,"live":{"now":true,"site":"live","url":"https://openvibe.live/@Example"},"channel_url":"https://openvibe.live/@Example","active_sites":[{"id":"live","name":"Live","url":"https://openvibe.live"}]}
```
```
-Network owns global levels (ask 241), followers, identity and history. Quest supplies the daily completion streak in the person's chosen time zone, with yesterday's streak retained until today's end, and the badge count. Live supplies current channel state; a missing channel or failed source is absent, never an invented offline state. No presence/last-seen timestamps, page titles, visited URLs, shared relationships or viewer-dependent data leave this public read.
+Network owns global levels (ask 241), followers, identity and history. Quest supplies the daily completion streak in the person's chosen time zone, with yesterday's streak retained until today's end, and the badge count. Live supplies current channel state; a missing channel or failed source is absent, never an invented offline state. No presence/last-seen timestamps, page titles, visited URLs or shared relationships leave the public read. The only viewer-dependent data is `actions`, and only when the viewer sends their token (see Actions).
The aggregate has a process-local 30-second cache capped at 2,000 entries; concurrent misses share one promise. All card enrichments share this aggregate cache. Quest/Live card reads time out after two seconds, refuse redirects, and use only operator-configured service origins. Account eligibility and visibility are reread before cache use. HTTP responses use `Cache-Control: no-store` so browsers/CDNs cannot keep a hidden field alive. Unknown, banned, deleted, merged and anonymous accounts return 404; old handles temporarily redirect to the canonical read.
The aggregate has a process-local 30-second cache capped at 2,000 entries; concurrent misses share one promise. All card enrichments share this aggregate cache. Quest/Live card reads time out after two seconds, refuse redirects, and use only operator-configured service origins. Account eligibility and visibility are reread before cache use. HTTP responses use `Cache-Control: no-store` so browsers/CDNs cannot keep a hidden field alive. Unknown, banned, deleted, merged and anonymous accounts return 404; old handles temporarily redirect to the canonical read.
`GET/PUT /api/v1/me/card-visibility` reads/patches booleans for `display_name`, `avatar_url`, `live`, `level`, `streak`, `badges_count`, `followers`, `active_sites`, `channel_url`. Fields default on except `active_sites`, which requires explicit opt-in to share site names from the last seven days of history. A private profile suppresses every enrichment; hidden Live places suppress live/channel links, hidden presence suppresses live state, and leaderboard opt-out suppresses level. Paused history or retaining private/mature history suppresses weekly sites. Public-place exclusions also filter sites. These stronger choices override card preferences. Preferences are included in account export and cleared on deletion.
`GET/PUT /api/v1/me/card-visibility` reads/patches booleans for `display_name`, `avatar_url`, `live`, `level`, `streak`, `badges_count`, `followers`, `active_sites`, `channel_url`. Fields default on except `active_sites`, which requires explicit opt-in to share site names from the last seven days of history. A private profile suppresses every enrichment; hidden Live places suppress live/channel links, hidden presence suppresses live state, and leaderboard opt-out suppresses level. Paused history or retaining private/mature history suppresses weekly sites. Public-place exclusions also filter sites. These stronger choices override card preferences. Preferences are included in account export and cleared on deletion.
+## Actions (ask 287)
+
+The read gains `actions: [{ id, label, href|method, state }]`, computed per request with the
+viewer's token and never cached. Send the Network session as `Authorization: Bearer …` (or the
+session cookie); a missing, expired or revoked token reads as anonymous, never a 401. Without
+a viewer — or on the viewer's own card — the card keeps only Profile/Channel. A viewer who
+blocked the person sees Unblock + Report; a viewer the person blocked sees Block + Report.
+
+For a signed-in viewer and another person, neither blocking the other:
+
+```json
+{"actions": [
+ {"id": "message", "label": "Message", "href": "https://openvibe.chat/messages"},
+ {"id": "follow", "label": "Follow", "method": "PUT", "path": "/api/v1/me/follows/channel/Example", "state": {"following": false}},
+ {"id": "add-friend", "label": "Add friend", "method": "PUT", "path": "/api/v1/me/friends/Example", "state": {"friendship": "none"}},
+ {"id": "tip", "label": "Tip", "href": "https://openvibe.tips/Example"},
+ {"id": "watch", "label": "Watch together", "href": "https://openvibe.live/@Example"},
+ {"id": "mute", "label": "Mute", "method": "PUT", "path": "/api/v1/me/mutes/Example", "state": {"muted": false}},
+ {"id": "block", "label": "Block", "method": "PUT", "path": "/api/v1/me/blocks/Example", "state": {"blocked": false}},
+ {"id": "report", "label": "Report", "method": "POST", "path": "/api/v1/me/reports", "body": {"target": "Example", "reason": "other", "detail": ""}, "state": {"reported": false}}
+]}
+```
+
+`{ id, label, href }` opens somewhere: Message starts in the chat inbox, Tip opens the
+person's tip page on OpenVibe.Tips, and `watch` opens their channel and appears only while they
+are live. `{ id, label, method, path, state }` calls the Network route with the viewer's token
+(a `report` also carries `body`). `state.friendship` is one of `none`, `pending` (I added them
+— the action reads Cancel request), `requested` (they added me — the action reads Accept),
+`friends`. Mute/Unmute and Block/Unblock carry their state the same way. The client must send
+the viewer's Network token with the read for these to appear; the bare public read (a site
+fetching with no token) keeps Profile/Channel only.
+
+Staff tools follow Contracts' staff-role map, not a second role list here: a viewer holding
+`staff.moderation.logs` also gets the Review queue and the User-reports queue filtered to the
+person. Follow/Unfollow reuse `PUT/DELETE /api/v1/me/follows/channel/:target`; Block/Unblock
+reuse `PUT/DELETE /api/v1/me/blocks/:target`. New beside them:
+
+- `GET /api/v1/me/mutes`, `PUT/DELETE /api/v1/me/mutes/:subjectOrUsername` — hush one person's
+ non-staff notices (server/identity/mutes.js). Nobody mutes themselves; guests neither mute
+ nor are muted.
+- `GET /api/v1/me/friends`, `PUT/DELETE /api/v1/me/friends/:subjectOrUsername` — add one
+ direction at a time; friends when both are active (server/identity/friends.js).
+- `POST /api/v1/me/reports { target, reason, detail }` — file about another person (reasons:
+ `spam`, `harassment`, `impersonation`, `explicit`, `scam`, `other`; 201 new, 200 while one is
+ still open). Staff holding `staff.moderation.logs` read `GET /api/v1/staff/user-reports`
+ (`?status=open&target=usr_…&limit=`).
+
+Mute, friend and report changes carry no relayed event yet: nothing consumes them, so the
+revision only orders the card's state. A service that should honour a mute reads the same
+subject pairs the way it reads blocks; that internal read (with its own Contracts capability)
+is a follow-up.
+
## Next for the design (For Fable)
## Next for the design (For Fable)
-Build the common card appearance and the account controls for field visibility (including explicit weekly-site sharing), using this read and the Shared hook. No new page styling ships here. Relationship actions and site-specific rows remain follow-up work outside this read.
+Build the common card appearance and the account controls for field visibility (including explicit weekly-site sharing), using this read and the Shared hook. No new page styling ships here. Render the `actions` array (kit buttons: `ovk-btn ovk-btn--primary|quiet`); a `method` action calls its Network route with the viewer's token. Site-specific rows (Live: mod actions; Quest: rank) remain follow-up work outside this read.
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+-- phase: expand
+-- Ask 287: the user card's actions. Mutes, friendships and user reports, beside blocks.
+-- Mutes hush one person's non-staff notices the way blocks stop them; friendships are
+-- mutual adds (friends when both directions are active); reports queue for staff.
+-- Nothing here changes an existing table, and no production setting is touched.
+CREATE TABLE IF NOT EXISTS user_mutes (
+ muter_subject text COLLATE "C" NOT NULL,
+ muted_subject text COLLATE "C" NOT NULL,
+ active bigint NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ PRIMARY KEY (muter_subject, muted_subject),
+ CHECK (muter_subject <> muted_subject)
+);
+CREATE INDEX IF NOT EXISTS idx_user_mutes_muted ON user_mutes(muted_subject, active);
+
+CREATE TABLE IF NOT EXISTS user_friends (
+ user_subject text COLLATE "C" NOT NULL,
+ friend_subject text COLLATE "C" NOT NULL,
+ active bigint NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ PRIMARY KEY (user_subject, friend_subject),
+ CHECK (user_subject <> friend_subject)
+);
+CREATE INDEX IF NOT EXISTS idx_user_friends_friend ON user_friends(friend_subject, active);
+
+CREATE TABLE IF NOT EXISTS user_reports (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ reporter_subject text COLLATE "C" NOT NULL,
+ target_subject text COLLATE "C" NOT NULL,
+ reason text COLLATE "C" NOT NULL,
+ detail text COLLATE "C" NOT NULL DEFAULT '',
+ status text COLLATE "C" NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'reviewed', 'dismissed')),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ CHECK (reporter_subject <> target_subject)
+);
+CREATE INDEX IF NOT EXISTS idx_user_reports_target ON user_reports(target_subject, status);
+CREATE INDEX IF NOT EXISTS idx_user_reports_reporter ON user_reports(reporter_subject, target_subject);
+-- One open report per pair: filing again while it is open returns the open one, never a duplicate.
+CREATE UNIQUE INDEX IF NOT EXISTS uq_user_reports_open ON user_reports(reporter_subject, target_subject) WHERE status = 'open';
@@ -218,6 +218,11 @@ async function networkPart(db, userId) {
@@ -218,6 +218,11 @@ async function networkPart(db, userId) {
const followers = await tableExists(db, 'user_follows') ? (await db.prepare("SELECT COUNT(*) AS n FROM user_follows WHERE target_id = ? AND active = 1").get(u.subject_id)).n : 0;
const followers = await tableExists(db, 'user_follows') ? (await db.prepare("SELECT COUNT(*) AS n FROM user_follows WHERE target_id = ? AND active = 1").get(u.subject_id)).n : 0;
add('follows.json', { following: follows.rows, followers_count: followers }, follows.truncated);
add('follows.json', { following: follows.rows, followers_count: followers }, follows.truncated);
add('blocks.json', (await rowsOf(db, 'user_blocks', 'blocker_subject = ?', [u.subject_id])).rows);
add('blocks.json', (await rowsOf(db, 'user_blocks', 'blocker_subject = ?', [u.subject_id])).rows);
+ // Ask 287: who they muted and friended, and the reports they filed. These are the person's own
+ // rows; a report about them names them as the target, so it is not their export.
+ add('mutes.json', (await rowsOf(db, 'user_mutes', 'muter_subject = ?', [u.subject_id])).rows);
+ add('friends.json', (await rowsOf(db, 'user_friends', 'user_subject = ?', [u.subject_id])).rows);
+ add('reports.json', (await rowsOf(db, 'user_reports', 'reporter_subject = ?', [u.subject_id])).rows);
// Presence (plan X9): the chosen state, status line, emoji, switch and the heartbeat times. No last seen
// Presence (plan X9): the chosen state, status line, emoji, switch and the heartbeat times. No last seen
// beyond the heartbeat the person's own tabs sent.
// beyond the heartbeat the person's own tabs sent.
add('presence.json', (await rowsOf(db, 'person_presence', 'subject_id = ?', [u.subject_id])).rows);
add('presence.json', (await rowsOf(db, 'person_presence', 'subject_id = ?', [u.subject_id])).rows);
@@ -374,6 +379,9 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -374,6 +379,9 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
await del('dock_states', `subject IN ${inS}`, subjects, 'dock_states');
await del('dock_states', `subject IN ${inS}`, subjects, 'dock_states');
await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
+ await del('user_mutes', `muter_subject IN ${inS} OR muted_subject IN ${inS}`, [...subjects, ...subjects], 'mutes');
+ await del('user_friends', `user_subject IN ${inS} OR friend_subject IN ${inS}`, [...subjects, ...subjects], 'friends');
+ await del('user_reports', `reporter_subject IN ${inS} OR target_subject IN ${inS}`, [...subjects, ...subjects], 'reports');
// The person's agents first (WS-Z2), then the agents of the projects archived below.
// The person's agents first (WS-Z2), then the agents of the projects archived below.
// Their projects' locks first, as agent creation takes them (developer/store.lockProject): no agent of theirs is created after this.
// Their projects' locks first, as agent creation takes them (developer/store.lockProject): no agent of theirs is created after this.
if (await tableExists(db, 'dev_projects')) {
if (await tableExists(db, 'dev_projects')) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+'use strict';
+/**
+ * Friendships (ask 287): "Add friend" on the user card. No request inbox: adding is one
+ * direction at a time, and the pair are friends when both directions are active. Removing
+ * either direction ends it. The card reads the pair's state (none, pending, requested,
+ * friends) so the action can say Add friend, Requested, or Friends.
+ *
+ * user_friends is keyed by subjects (usr_…), one row per (user, friend). An unfriend keeps
+ * the row with active = 0 so the pair's revision keeps growing. Nobody friends themselves,
+ * and guests neither friend nor are friended. No change event yet: nothing relays friends,
+ * so the revision only orders the card's state.
+ *
+ * GET /api/v1/me/friends whom I friended (subject, username, display name)
+ * PUT /api/v1/me/friends/:subjectOrUsername add (201 new, 200 already added)
+ * DELETE /api/v1/me/friends/:subjectOrUsername remove (200; 404 when not added)
+ */
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { http } = require('openvibe-contracts');
+const { findTarget } = require('./blocks');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MAX_ACTIVE = 5000;
+
+class FriendError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
+
+/** The person a path segment names, with friend-shaped errors (blocks.findTarget raises blocks.*). */
+async function target(db, key) {
+ try {
+ return await findTarget(db, key);
+ } catch (err) {
+ if (err && err.code === 'blocks.guest') throw new FriendError(400, 'friends.guest', 'guests cannot be friended');
+ throw err;
+ }
+}
+
+async function outgoing(db, userSubject, friendSubject) {
+ return !!await db.prepare('SELECT 1 FROM user_friends WHERE user_subject = ? AND friend_subject = ? AND active = 1').get(String(userSubject), String(friendSubject));
+}
+
+/** The pair's state from `viewer`'s side: none, pending (I added them), requested (they added me), friends. */
+async function stateOf(db, viewer, other) {
+ const mine = await outgoing(db, viewer, other);
+ const theirs = await outgoing(db, other, viewer);
+ if (mine && theirs) return 'friends';
+ if (mine) return 'pending';
+ if (theirs) return 'requested';
+ return 'none';
+}
+
+/** Set (user, friend) to `active`. Both must be usr_ subjects. → { changed, active, revision, at } */
+async function setFriend(db, user, friend, active) {
+ if (!SUBJECT_RE.test(String(user)) || !SUBJECT_RE.test(String(friend))) throw new FriendError(400, 'friends.bad_subject', 'friends are between usr_ subjects');
+ if (user === friend) throw new FriendError(400, 'friends.self', 'you cannot friend yourself');
+ return await db.tx(async () => {
+ const prev = await db.prepare('SELECT active, revision, updated_at FROM user_friends WHERE user_subject = ? AND friend_subject = ?').get(user, friend);
+ if (prev && !!prev.active === !!active) return { changed: false, active: !!active, revision: prev.revision, at: prev.updated_at };
+ if (!prev && !active) return { changed: false, active: false, revision: 0, at: null };
+ if (active) {
+ const n = (await db.prepare('SELECT COUNT(*) AS n FROM user_friends WHERE user_subject = ? AND active = 1').get(user)).n;
+ if (n >= MAX_ACTIVE) throw new FriendError(409, 'friends.limit', `at most ${MAX_ACTIVE} people can be friended`);
+ }
+ const at = new Date().toISOString();
+ const revision = prev ? prev.revision + 1 : 1;
+ if (prev) {
+ await db.prepare('UPDATE user_friends SET active = ?, revision = ?, updated_at = ? WHERE user_subject = ? AND friend_subject = ?')
+ .run(active ? 1 : 0, revision, at, user, friend);
+ } else {
+ await db.prepare('INSERT INTO user_friends (user_subject, friend_subject, active, revision, created_at, updated_at) VALUES (?, ?, 1, 1, ?, ?)')
+ .run(user, friend, at, at);
+ }
+ return { changed: true, active: !!active, revision, at };
+ });
+}
+
+/** Whom `subject` added (with names, newest first), for the person's own list. */
+async function listFor(db, subject) {
+ return (await db.prepare(`SELECT f.friend_subject, f.revision, f.updated_at, u.username, u.display_name
+ FROM user_friends f LEFT JOIN users u ON u.subject_id = f.friend_subject
+ WHERE f.user_subject = ? AND f.active = 1 ORDER BY f.updated_at DESC, f.friend_subject`).all(String(subject)))
+ .map((r) => ({
+ subject: r.friend_subject, username: r.username || null, display_name: r.username ? (r.display_name || r.username) : null,
+ added_at: r.updated_at, revision: r.revision,
+ }));
+}
+
+/** The signed-in person as a friend, or a FriendError. */
+async function meOf(db, user) {
+ if (!user || user.is_anon) throw new FriendError(403, 'friends.guest', 'sign in with an account to add friends');
+ const sid = await require('./subjects').ensureUserSubject(db, user);
+ if (!SUBJECT_RE.test(String(sid || ''))) throw new FriendError(403, 'friends.guest', 'sign in with an account to add friends');
+ return sid;
+}
+const view = (u, out, state) => ({ subject: u.subject_id, username: u.username, display_name: u.display_name || u.username, active: out.active, revision: out.revision, state, ...(out.active ? { added_at: out.at } : {}) });
+
+function userRouter(requireAuth) {
+ const router = express.Router();
+ router.use(http.middleware());
+ const send = async (req, res, fn) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ try { return await fn(req.app.locals.db); } catch (err) {
+ if (err instanceof FriendError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
+ console.error('[Friends]', err.message);
+ return http.sendProblem(res, 500, 'friends.failed', { detail: 'the friendship could not be changed', ctx: req.ov });
+ }
+ };
+
+ router.get('/', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
+ res.json({ subject: me, friends: await listFor(db, me) });
+ }));
+
+ router.put('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
+ const u = await target(db, req.params.target);
+ if (!u) throw new FriendError(404, 'friends.unknown_person', 'no such person');
+ if (u.is_anon) throw new FriendError(400, 'friends.guest', 'guests cannot be friended');
+ if (u.subject_id === me) throw new FriendError(400, 'friends.self', 'you cannot friend yourself');
+ const out = await setFriend(db, me, u.subject_id, true);
+ res.status(out.changed ? 201 : 200).json({ changed: out.changed, friend: view(u, out, await stateOf(db, me, u.subject_id)) });
+ }));
+
+ router.delete('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
+ const key = String(req.params.target || '');
+ const u = await target(db, key) || (SUBJECT_RE.test(key) ? { subject_id: key, username: null, display_name: null } : null);
+ if (!u || !u.subject_id || !await outgoing(db, me, u.subject_id)) throw new FriendError(404, 'friends.not_added', 'that person is not added');
+ const out = await setFriend(db, me, u.subject_id, false);
+ res.json({ changed: out.changed, friend: view(u, out, await stateOf(db, me, u.subject_id)) });
+ }));
+
+ return router;
+}
+
+module.exports = { MAX_ACTIVE, FriendError, ensureSchema, setFriend, outgoing, stateOf, listFor, userRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * Platform mutes (ask 287): hush one person for the person who muted them. Blocks stop
+ * every contact; a mute only hushes their non-staff notices — their messages and mentions
+ * stop pinging, while moderation, system and admin notices are always delivered.
+ *
+ * user_mutes is keyed by subjects (usr_…), one row per (muter, muted). An unmute keeps the
+ * row with active = 0 so the pair's revision keeps growing. Nobody mutes themselves, and
+ * guests neither mute nor are muted. Chat, Community and Network's own notifications read
+ * this the way they read blocks (server/identity/blocks.js) once its internal read ships.
+ *
+ * No change event yet: nothing relays mutes, so the revision only orders the card's state.
+ *
+ * GET /api/v1/me/mutes whom I muted (subject, username, display name)
+ * PUT /api/v1/me/mutes/:subjectOrUsername mute (201 new, 200 already muted)
+ * DELETE /api/v1/me/mutes/:subjectOrUsername unmute (200; 404 when not muted)
+ */
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { http } = require('openvibe-contracts');
+const { findTarget } = require('./blocks');
+
+/** The person a path segment names, with mute-shaped errors (blocks.findTarget raises blocks.*). */
+async function target(db, key) {
+ try {
+ return await findTarget(db, key);
+ } catch (err) {
+ if (err && err.code === 'blocks.guest') throw new MuteError(400, 'mutes.guest', 'guests cannot be muted');
+ throw err;
+ }
+}
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MAX_ACTIVE = 5000;
+
+class MuteError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
+
+async function isMuted(db, muterSubject, mutedSubject) {
+ return !!await db.prepare('SELECT 1 FROM user_mutes WHERE muter_subject = ? AND muted_subject = ? AND active = 1').get(String(muterSubject), String(mutedSubject));
+}
+
+/** Set (muter, muted) to `active`. Both must be usr_ subjects. → { changed, active, revision, at } */
+async function setMute(db, muter, muted, active) {
+ if (!SUBJECT_RE.test(String(muter)) || !SUBJECT_RE.test(String(muted))) throw new MuteError(400, 'mutes.bad_subject', 'mutes are between usr_ subjects');
+ if (muter === muted) throw new MuteError(400, 'mutes.self', 'you cannot mute yourself');
+ return await db.tx(async () => {
+ const prev = await db.prepare('SELECT active, revision, updated_at FROM user_mutes WHERE muter_subject = ? AND muted_subject = ?').get(muter, muted);
+ if (prev && !!prev.active === !!active) return { changed: false, active: !!active, revision: prev.revision, at: prev.updated_at };
+ if (!prev && !active) return { changed: false, active: false, revision: 0, at: null };
+ if (active) {
+ const n = (await db.prepare('SELECT COUNT(*) AS n FROM user_mutes WHERE muter_subject = ? AND active = 1').get(muter)).n;
+ if (n >= MAX_ACTIVE) throw new MuteError(409, 'mutes.limit', `at most ${MAX_ACTIVE} people can be muted`);
+ }
+ const at = new Date().toISOString();
+ const revision = prev ? prev.revision + 1 : 1;
+ if (prev) {
+ await db.prepare('UPDATE user_mutes SET active = ?, revision = ?, updated_at = ? WHERE muter_subject = ? AND muted_subject = ?')
+ .run(active ? 1 : 0, revision, at, muter, muted);
+ } else {
+ await db.prepare('INSERT INTO user_mutes (muter_subject, muted_subject, active, revision, created_at, updated_at) VALUES (?, ?, 1, 1, ?, ?)')
+ .run(muter, muted, at, at);
+ }
+ return { changed: true, active: !!active, revision, at };
+ });
+}
+
+/** Whom `subject` muted (with names, newest first), for the person's own list. */
+async function listFor(db, subject) {
+ return (await db.prepare(`SELECT m.muted_subject, m.revision, m.updated_at, u.username, u.display_name
+ FROM user_mutes m LEFT JOIN users u ON u.subject_id = m.muted_subject
+ WHERE m.muter_subject = ? AND m.active = 1 ORDER BY m.updated_at DESC, m.muted_subject`).all(String(subject)))
+ .map((r) => ({
+ subject: r.muted_subject, username: r.username || null, display_name: r.username ? (r.display_name || r.username) : null,
+ muted_at: r.updated_at, revision: r.revision,
+ }));
+}
+
+/** The signed-in person as a muter, or a MuteError. */
+async function meOf(db, user) {
+ if (!user || user.is_anon) throw new MuteError(403, 'mutes.guest', 'sign in with an account to mute people');
+ const sid = await require('./subjects').ensureUserSubject(db, user);
+ if (!SUBJECT_RE.test(String(sid || ''))) throw new MuteError(403, 'mutes.guest', 'sign in with an account to mute people');
+ return sid;
+}
+const view = (u, out) => ({ subject: u.subject_id, username: u.username, display_name: u.display_name || u.username, active: out.active, revision: out.revision, ...(out.active ? { muted_at: out.at } : {}) });
+
+function userRouter(requireAuth) {
+ const router = express.Router();
+ router.use(http.middleware());
+ const send = async (req, res, fn) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ try { return await fn(req.app.locals.db); } catch (err) {
+ if (err instanceof MuteError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
+ console.error('[Mutes]', err.message);
+ return http.sendProblem(res, 500, 'mutes.failed', { detail: 'the mute could not be changed', ctx: req.ov });
+ }
+ };
+
+ router.get('/', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
+ res.json({ subject: me, mutes: await listFor(db, me) });
+ }));
+
+ router.put('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
+ const u = await target(db, req.params.target);
+ if (!u) throw new MuteError(404, 'mutes.unknown_person', 'no such person');
+ if (u.is_anon) throw new MuteError(400, 'mutes.guest', 'guests cannot be muted');
+ if (u.subject_id === me) throw new MuteError(400, 'mutes.self', 'you cannot mute yourself');
+ const out = await setMute(db, me, u.subject_id, true);
+ res.status(out.changed ? 201 : 200).json({ changed: out.changed, mute: view(u, out) });
+ }));
+
+ router.delete('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
+ const key = String(req.params.target || '');
+ const u = await target(db, key) || (SUBJECT_RE.test(key) ? { subject_id: key, username: null, display_name: null } : null);
+ if (!u || !u.subject_id || !await isMuted(db, me, u.subject_id)) throw new MuteError(404, 'mutes.not_muted', 'that person is not muted');
+ const out = await setMute(db, me, u.subject_id, false);
+ res.json({ changed: out.changed, mute: view(u, out) });
+ }));
+
+ return router;
+}
+
+module.exports = { MAX_ACTIVE, MuteError, ensureSchema, setMute, isMuted, listFor, userRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+/**
+ * User reports (ask 287): "Report" on the user card. A signed-in person reports another
+ * person with a reason; staff read the queue. Reports never resolve themselves — a staff
+ * decision lives in the moderation audit log (server/admin/moderation-audit.js), and the
+ * report's status only records whether staff have looked.
+ *
+ * user_reports holds one row per filing (reporter, target, reason, detail, status). Filing
+ * twice about the same person while the first is still open returns the open one instead
+ * of a duplicate. Nobody reports themselves, and guests report nothing.
+ *
+ * POST /api/v1/me/reports file (201 new, 200 already open)
+ * GET /api/v1/staff/user-reports the queue for staff holding
+ * staff.moderation.logs (newest first,
+ * ?status=open&target=usr_…&limit=)
+ */
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { http, staff } = require('openvibe-contracts');
+const { staffClaims } = require('../auth/staff-claims');
+const { findTarget } = require('./blocks');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const REASONS = new Set(['spam', 'harassment', 'impersonation', 'explicit', 'scam', 'other']);
+const STATUSES = new Set(['open', 'reviewed', 'dismissed']);
+const MAX_DETAIL = 2000;
+
+class ReportError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
+
+/** The person a value names, with report-shaped errors (blocks.findTarget raises blocks.*). */
+async function target(db, key) {
+ try {
+ return await findTarget(db, key);
+ } catch (err) {
+ if (err && err.code === 'blocks.guest') throw new ReportError(400, 'user_reports.guest', 'guests cannot be reported');
+ throw err;
+ }
+}
+
+/** The signed-in person as a reporter, or a ReportError. */
+async function meOf(db, user) {
+ if (!user || user.is_anon) throw new ReportError(403, 'user_reports.guest', 'sign in with an account to report people');
+ const sid = await require('./subjects').ensureUserSubject(db, user);
+ if (!SUBJECT_RE.test(String(sid || ''))) throw new ReportError(403, 'user_reports.guest', 'sign in with an account to report people');
+ return sid;
+}
+
+const cleanReason = (value) => {
+ const r = String(value || '').trim().toLowerCase();
+ if (!REASONS.has(r)) throw new ReportError(400, 'user_reports.reason', `reason must be one of ${[...REASONS].join(', ')}`);
+ return r;
+};
+const cleanDetail = (value) => {
+ const d = String(value == null ? '' : value).trim().slice(0, MAX_DETAIL);
+ return d;
+};
+
+const view = (r) => ({ id: r.id, reporter: r.reporter_subject, target: r.target_subject, reason: r.reason, detail: r.detail || '', status: r.status, created_at: r.created_at, updated_at: r.updated_at });
+
+/** File a report, or return the still-open one for the pair. → { report, created } */
+async function file(db, reporter, targetSubject, reason, detail) {
+ if (!SUBJECT_RE.test(String(reporter)) || !SUBJECT_RE.test(String(targetSubject))) throw new ReportError(400, 'user_reports.bad_subject', 'reports are between usr_ subjects');
+ if (reporter === targetSubject) throw new ReportError(400, 'user_reports.self', 'you cannot report yourself');
+ return await db.tx(async () => {
+ const open = () => db.prepare(`SELECT * FROM user_reports WHERE reporter_subject = ? AND target_subject = ? AND status = 'open'
+ ORDER BY id DESC LIMIT 1`).get(reporter, targetSubject);
+ const found = await open();
+ if (found) return { report: view(found), created: false };
+ // The partial unique index turns a concurrent double filing into one row: the loser reads
+ // the winner's report back instead of creating a duplicate.
+ const row = await db.prepare(`INSERT INTO user_reports (reporter_subject, target_subject, reason, detail)
+ VALUES (?, ?, ?, ?) ON CONFLICT (reporter_subject, target_subject) WHERE status = 'open' DO NOTHING RETURNING *`)
+ .get(reporter, targetSubject, reason, detail);
+ if (row) return { report: view(row), created: true };
+ const raced = await open();
+ return { report: view(raced), created: false };
+ });
+}
+
+/** Whether `reporter` has an open report about `targetSubject`. */
+async function hasOpen(db, reporter, targetSubject) {
+ return !!await db.prepare(`SELECT 1 FROM user_reports WHERE reporter_subject = ? AND target_subject = ? AND status = 'open' LIMIT 1`)
+ .get(String(reporter), String(targetSubject));
+}
+
+/** The staff queue, newest first. Filters: status (default open), target subject. */
+async function listForStaff(db, { status = 'open', target = null, limit = 50 } = {}) {
+ if (!STATUSES.has(status)) throw new ReportError(400, 'user_reports.status', `status must be one of ${[...STATUSES].join(', ')}`);
+ if (target !== null && !SUBJECT_RE.test(String(target))) throw new ReportError(400, 'user_reports.bad_subject', 'target must be a usr_ subject id');
+ const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
+ const where = ['status = ?'];
+ const args = [status];
+ if (target !== null) { where.push('target_subject = ?'); args.push(String(target)); }
+ args.push(n);
+ return (await db.prepare(`SELECT * FROM user_reports WHERE ${where.join(' AND ')} ORDER BY id DESC LIMIT ?`).all(...args)).map(view);
+}
+
+function userRouter(requireAuth) {
+ const router = express.Router();
+ router.use(http.middleware());
+ const send = async (req, res, fn) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ try { return await fn(req.app.locals.db); } catch (err) {
+ if (err instanceof ReportError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
+ console.error('[UserReports]', err.message);
+ return http.sendProblem(res, 500, 'user_reports.failed', { detail: 'the report could not be filed', ctx: req.ov });
+ }
+ };
+
+ router.post('/', requireAuth, express.json({ limit: '16kb' }), async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ const u = await target(db, body.target);
+ if (!u) throw new ReportError(404, 'user_reports.unknown_person', 'no such person');
+ if (u.is_anon) throw new ReportError(400, 'user_reports.guest', 'guests cannot be reported');
+ if (u.subject_id === me) throw new ReportError(400, 'user_reports.self', 'you cannot report yourself');
+ const out = await file(db, me, u.subject_id, cleanReason(body.reason), cleanDetail(body.detail));
+ res.status(out.created ? 201 : 200).json({ created: out.created, report: out.report });
+ }));
+
+ return router;
+}
+
+/** GET /api/v1/staff/user-reports (mount behind requireAuth; the capability check is inside). */
+function staffRouter() {
+ const router = express.Router();
+ router.use(http.middleware());
+ router.get('/', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ try {
+ if (!req.user || !staff.can(staffClaims(req.user), 'staff.moderation.logs')) {
+ return http.sendProblem(res, 403, 'user_reports.forbidden', { detail: 'staff.moderation.logs required', ctx: req.ov });
+ }
+ res.json({ reports: await listForStaff(req.app.locals.db, { status: req.query.status, target: req.query.target || null, limit: req.query.limit }) });
+ } catch (err) {
+ if (err instanceof ReportError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
+ console.error('[UserReports]', err.message);
+ return http.sendProblem(res, 500, 'user_reports.failed', { detail: 'the queue could not be read', ctx: req.ov });
+ }
+ });
+ return router;
+}
+
+module.exports = { REASONS, STATUSES, ReportError, ensureSchema, file, hasOpen, listForStaff, userRouter, staffRouter };
@@ -659,6 +659,15 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -659,6 +659,15 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/internal', machineInternal.internal);
app.use('/internal', machineInternal.internal);
}
}
app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read'), require('./identity/blocks').internalHandler(db));
app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read'), require('./identity/blocks').internalHandler(db));
+// Platform mutes and friendships (ask 287): a person's own lists and the changes the card's
+// actions make. Services read them through the internal routes once Contracts names their
+// capabilities; until then the person's own routes are the complete surface.
+app.use('/api/v1/me/mutes', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/mutes').userRouter(requireAuth));
+app.use('/api/v1/me/friends', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/friends').userRouter(requireAuth));
+// User reports (ask 287): a signed-in person files about another person; staff holding
+// staff.moderation.logs read the queue. The capability check lives in the staff router.
+app.use('/api/v1/me/reports', rateLimit({ windowMs: 60_000, max: 30 }), require('./identity/user-reports').userRouter(requireAuth));
+app.use('/api/v1/staff/user-reports', rateLimit({ windowMs: 60_000, max: 60 }), requireAuth, require('./identity/user-reports').staffRouter());
// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target
// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
{
{
@@ -972,7 +981,24 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)
@@ -972,7 +981,24 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)
app.locals.avatarService = avatarService;
app.locals.avatarService = avatarService;
// Public profiles (plan T21 step 3): openvibe.network/@<username> and GET /api/v1/profiles/:username (CORS-open,
// Public profiles (plan T21 step 3): openvibe.network/@<username> and GET /api/v1/profiles/:username (CORS-open,
// server/public-cors.js), with the items from OpenVibe.Inventory (server/profile/public.js).
// server/public-cors.js), with the items from OpenVibe.Inventory (server/profile/public.js).
-const userCards = require('./profile/card').createUserCards({ db, profiles: publicProfiles, selfToken, questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980', liveUrl: config.services.live.internalUrl });
+const userCards = require('./profile/card').createUserCards({ db, profiles: publicProfiles, selfToken, questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980', liveUrl: config.services.live.internalUrl, identify: identifyCardViewer });
+function identifyCardViewer(req) {
+ // Optional viewer identity for the card's actions: a missing, expired or revoked token
+ // reads as anonymous (Profile/Channel only), never a 401. No renewal here — a plain read
+ // must not rotate the session's cookies.
+ const { sessionToken, verifySession } = require('./auth/session');
+ const token = sessionToken(req);
+ if (!token) return Promise.resolve(null);
+ return verifySession(token, authCtx()).then(async (out) => {
+ if (out.error || !out.user || out.user.is_anon) return null;
+ const subject = await require('./identity/subjects').ensureUserSubject(db, out.user).catch(() => null);
+ if (!subject) return null;
+ // The card's staff tools follow the same capability map every service uses (ADR-022),
+ // so a role change reaches the card without a second list of who counts as staff.
+ const claims = require('./auth/staff-claims').staffClaims(out.user);
+ return { id: out.user.id, subject, username: out.user.username, role: out.user.role, staff: claims };
+ });
+}
app.get('/api/v1/people/:username/card', rateLimit({ windowMs: 60_000, max: 240 }), userCards.api);
app.get('/api/v1/people/:username/card', rateLimit({ windowMs: 60_000, max: 240 }), userCards.api);
app.use('/api/v1/me/card-visibility', rateLimit({ windowMs: 60_000, max: 60 }), userCards.settings(requireAuth));
app.use('/api/v1/me/card-visibility', rateLimit({ windowMs: 60_000, max: 60 }), userCards.settings(requireAuth));
app.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);
app.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
const express = require('express');
const express = require('express');
const levels = require('../levels/store');
const levels = require('../levels/store');
const follows = require('../identity/follows');
const follows = require('../identity/follows');
+const { staff } = require('openvibe-contracts');
const { SITES } = require('../frame/sites');
const { SITES } = require('../frame/sites');
const TTL_MS = 30_000;
const TTL_MS = 30_000;
const MAX_CACHE = 2000;
const MAX_CACHE = 2000;
@@ -9,7 +10,7 @@ const FIELDS = ['display_name', 'avatar_url', 'live', 'level', 'streak', 'badges
@@ -9,7 +10,7 @@ const FIELDS = ['display_name', 'avatar_url', 'live', 'level', 'streak', 'badges
const defaults = () => Object.fromEntries(FIELDS.map(k => [k, k !== 'active_sites']));
const defaults = () => Object.fromEntries(FIELDS.map(k => [k, k !== 'active_sites']));
const object = value => { try { return typeof value === 'string' ? JSON.parse(value) : (value || {}); } catch { return {}; } };
const object = value => { try { return typeof value === 'string' ? JSON.parse(value) : (value || {}); } catch { return {}; } };
-function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'http://127.0.0.1:4980', liveUrl = 'http://127.0.0.1:3000', fetchImpl = (...args) => fetch(...args), now = Date.now }) {
+function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'http://127.0.0.1:4980', liveUrl = 'http://127.0.0.1:3000', fetchImpl = (...args) => fetch(...args), now = Date.now, identify = null }) {
const cache = new Map();
const cache = new Map();
const pending = new Map();
const pending = new Map();
async function enabled() {
async function enabled() {
@@ -88,6 +89,61 @@ function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'htt
@@ -88,6 +89,61 @@ function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'htt
pending.set(key, task);
pending.set(key, task);
return task;
return task;
}
}
+ // What the viewer can do with the person on the card (ask 287). Action descriptors are
+ // plain data Shared renders: { id, label, href } opens somewhere, { id, label, method, path,
+ // state } calls a Network route with the viewer's token (a report also carries its body).
+ // Computed per request with the viewer's token — never cached, never stored. Without a
+ // viewer (or for the viewer's own card) the card keeps only Profile/Channel.
+ const staffCan = (viewer, capability) => !!(viewer && viewer.staff && staff.can(viewer.staff, capability));
+ async function actionsFor(viewer, target, card, username) {
+ const acts = [{ id: 'profile', label: 'Profile', href: card.profile_url }];
+ if (card.channel_url) acts.push({ id: 'channel', label: 'Channel', href: card.channel_url });
+ if (!viewer || !target || viewer.subject === target.subject_id) return acts;
+ const blocks = require('../identity/blocks');
+ const reports = require('../identity/user-reports');
+ const [iBlocked, blockedMe] = await Promise.all([
+ blocks.isBlocked(db, viewer.subject, target.subject_id),
+ blocks.isBlocked(db, target.subject_id, viewer.subject),
+ ]);
+ const report = { id: 'report', label: 'Report', method: 'POST', path: '/api/v1/me/reports', body: { target: username, reason: 'other', detail: '' }, state: { reported: await reports.hasOpen(db, viewer.subject, target.subject_id).catch(() => false) } };
+ if (blockedMe && !iBlocked) return [...acts,
+ { id: 'block', label: 'Block', method: 'PUT', path: `/api/v1/me/blocks/${encodeURIComponent(username)}`, state: { blocked: false } },
+ report];
+ if (iBlocked) return [...acts,
+ { id: 'unblock', label: 'Unblock', method: 'DELETE', path: `/api/v1/me/blocks/${encodeURIComponent(username)}`, state: { blocked: true } },
+ report];
+ const follows = require('../identity/follows');
+ const mutes = require('../identity/mutes');
+ const friends = require('../identity/friends');
+ const [following, muted, friendship] = await Promise.all([
+ follows.status(db, 'channel', target.subject_id, viewer.subject).then((s) => !!s.following).catch(() => false),
+ mutes.isMuted(db, viewer.subject, target.subject_id).catch(() => false),
+ friends.stateOf(db, viewer.subject, target.subject_id).catch(() => 'none'),
+ ]);
+ const out = [...acts,
+ { id: 'message', label: 'Message', href: 'https://openvibe.chat/messages' },
+ following
+ ? { id: 'unfollow', label: 'Unfollow', method: 'DELETE', path: `/api/v1/me/follows/channel/${encodeURIComponent(username)}`, state: { following: true } }
+ : { id: 'follow', label: 'Follow', method: 'PUT', path: `/api/v1/me/follows/channel/${encodeURIComponent(username)}`, state: { following: false } },
+ ];
+ if (friendship === 'friends') out.push({ id: 'unfriend', label: 'Unfriend', method: 'DELETE', path: `/api/v1/me/friends/${encodeURIComponent(username)}`, state: { friendship } });
+ else if (friendship === 'pending') out.push({ id: 'cancel-request', label: 'Requested', method: 'DELETE', path: `/api/v1/me/friends/${encodeURIComponent(username)}`, state: { friendship } });
+ else out.push({ id: 'add-friend', label: friendship === 'requested' ? 'Accept' : 'Add friend', method: 'PUT', path: `/api/v1/me/friends/${encodeURIComponent(username)}`, state: { friendship } });
+ out.push({ id: 'tip', label: 'Tip', href: `https://openvibe.tips/${encodeURIComponent(username)}` });
+ if (card.live && card.live.now === true && card.live.url) out.push({ id: 'watch', label: 'Watch together', href: card.live.url });
+ out.push(muted
+ ? { id: 'unmute', label: 'Unmute', method: 'DELETE', path: `/api/v1/me/mutes/${encodeURIComponent(username)}`, state: { muted: true } }
+ : { id: 'mute', label: 'Mute', method: 'PUT', path: `/api/v1/me/mutes/${encodeURIComponent(username)}`, state: { muted: false } });
+ out.push({ id: 'block', label: 'Block', method: 'PUT', path: `/api/v1/me/blocks/${encodeURIComponent(username)}`, state: { blocked: false } });
+ out.push(report);
+ // Staff tools by capability (the role map lives in Contracts' staff-roles, not in this
+ // read): moderators with the moderation log can jump to the queue that names this person.
+ if (staffCan(viewer, 'staff.moderation.logs')) {
+ out.push({ id: 'staff-review', label: 'Review queue', href: 'https://openvibe.network/admin/review' });
+ out.push({ id: 'staff-reports', label: 'User reports', href: `https://openvibe.network/api/v1/staff/user-reports?target=${encodeURIComponent(target.subject_id)}&status=open` });
+ }
+ return out;
+ }
const handle = fn => async (req, res, next) => { try { await fn(req, res); } catch (e) { next(e); } };
const handle = fn => async (req, res, next) => { try { await fn(req, res); } catch (e) { next(e); } };
const api = handle(async (req, res) => {
const api = handle(async (req, res) => {
res.set('Cache-Control', 'no-store'); // Never let a browser/CDN outlive a privacy change.
res.set('Cache-Control', 'no-store'); // Never let a browser/CDN outlive a privacy change.
@@ -95,7 +151,15 @@ function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'htt
@@ -95,7 +151,15 @@ function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'htt
const value = await read(req.params.username);
const value = await read(req.params.username);
if (!value) return res.status(404).json({ error: 'user_card.not_found' });
if (!value) return res.status(404).json({ error: 'user_card.not_found' });
if (value.moved) return res.redirect(307, `/api/v1/people/${encodeURIComponent(value.moved)}/card`);
if (value.moved) return res.redirect(307, `/api/v1/people/${encodeURIComponent(value.moved)}/card`);
- return res.json(value);
+ let viewer = null, target = null;
+ if (typeof identify === 'function') {
+ viewer = await identify(req).catch(() => null);
+ if (viewer && viewer.subject) {
+ const found = await profiles.find(req.params.username).catch(() => null);
+ target = found && !found.moved ? found.user : null;
+ }
+ }
+ return res.json({ ...value, actions: await actionsFor(viewer, target, value, req.params.username) });
});
});
function settings(requireAuth) {
function settings(requireAuth) {
const router = express.Router();
const router = express.Router();
@@ -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']]);
+ 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']]);
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,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+// Ask 287: the user card's actions. The card read gains viewer-aware `actions` (computed per
+// request, never cached); mutes, friendships and user reports are new Network resources beside
+// blocks, reusing the follow/block route shapes.
+// ov test test/user-card-actions.test.js
+const assert = require('assert');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const { createPublicProfiles } = require('../server/profile/public');
+const { createUserCards } = require('../server/profile/card');
+const { setFollow } = require('../server/identity/follows');
+const { setBlock } = require('../server/identity/blocks');
+const { setMute } = require('../server/identity/mutes');
+const { setFriend } = require('../server/identity/friends');
+const cors = require('../server/public-cors');
+(async () => {
+ const db = getDb();
+ const VIC = 'usr_01JAB2C3D4E5F6G7H8J9K0MNV1';
+ const TED = 'usr_01JAB2C3D4E5F6G7H8J9K0MNT2';
+ await db.prepare("INSERT INTO users(username,password_hash,display_name,subject_id,role) VALUES ('VicAct','x','Vic',?,'user'),('TedAct','x','Ted',?,'user') ON CONFLICT DO NOTHING").run(VIC, TED);
+ const vic = await db.prepare("SELECT * FROM users WHERE username='VicAct'").get();
+ const ted = await db.prepare("SELECT * FROM users WHERE username='TedAct'").get();
+ // A fresh pair: clear any rows an earlier run left behind.
+ await db.prepare('DELETE FROM user_mutes WHERE muter_subject IN (?,?) OR muted_subject IN (?,?)').run(VIC, TED, VIC, TED);
+ await db.prepare('DELETE FROM user_friends WHERE user_subject IN (?,?) OR friend_subject IN (?,?)').run(VIC, TED, VIC, TED);
+ await db.prepare('DELETE FROM user_reports WHERE reporter_subject IN (?,?) OR target_subject IN (?,?)').run(VIC, TED, VIC, TED);
+ await db.prepare('DELETE FROM user_blocks WHERE blocker_subject IN (?,?) OR blocked_subject IN (?,?)').run(VIC, TED, VIC, TED);
+ await db.prepare('DELETE FROM user_follows WHERE follower_subject IN (?,?) OR target_id IN (?,?)').run(VIC, TED, VIC, TED);
+ const fetchImpl = async (url) => {
+ if (url.endsWith('/badges')) return { ok: true, json: async () => ({ badges: [] }) };
+ if (url.includes('/streak')) return { ok: true, json: async () => ({}) };
+ return { ok: true, json: async () => ({ channel: { username: 'TedAct' }, streams: [{ id: 7 }] }) };
+ };
+ let viewer = null; // the stubbed viewer identify() returns
+ const profiles = createPublicProfiles({ db });
+ const cards = createUserCards({ db, profiles, fetchImpl, identify: async () => viewer });
+ const authStub = (user) => (req, res, next) => { req.user = user; next(); };
+ const app = express(); app.use(express.json());
+ app.locals.db = db; app.locals.publicKey = 'x';
+ app.get('/api/v1/people/:username/card', cors.publicCors, cards.api);
+ const asVic = { id: vic.id, username: 'VicAct', subject_id: VIC, is_anon: false, role: 'user' };
+ const asTed = { id: ted.id, username: 'TedAct', subject_id: TED, is_anon: false, role: 'user' };
+ app.use('/api/v1/me/mutes', require('../server/identity/mutes').userRouter(authStub(asVic)));
+ app.use('/api/v1/me/friends', require('../server/identity/friends').userRouter(authStub(asVic)));
+ app.use('/api/v1/me/reports', require('../server/identity/user-reports').userRouter(authStub(asVic)));
+ app.use('/api/v1/staff/user-reports', authStub(asVic), require('../server/identity/user-reports').staffRouter());
+ const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const card = (who) => fetch(`${base}/api/v1/people/${who}/card`).then(r => r.json());
+ try {
+ await db.prepare("UPDATE site_settings SET value='true' WHERE key='user_card_enabled'").run();
+ // Anonymous: only Profile/Channel.
+ viewer = null;
+ let tedCard = await card('TedAct');
+ assert.deepStrictEqual(tedCard.actions, [
+ { id: 'profile', label: 'Profile', href: 'https://openvibe.network/@TedAct' },
+ { id: 'channel', label: 'Channel', href: 'https://openvibe.live/@TedAct' },
+ ], 'anonymous card keeps Profile/Channel');
+ // Own card, signed in: still only Profile/Channel.
+ viewer = { subject: VIC, username: 'VicAct', role: 'user' };
+ let vicCard = await card('VicAct');
+ assert.deepStrictEqual(vicCard.actions.map(a => a.id), ['profile'], 'own card has no self actions (no channel without live)');
+ // Viewer + other: the full set with clean state.
+ viewer = { subject: VIC, username: 'VicAct', role: 'user' };
+ tedCard = await card('TedAct');
+ assert.deepStrictEqual(tedCard.actions.map(a => a.id),
+ ['profile', 'channel', 'message', 'follow', 'add-friend', 'tip', 'watch', 'mute', 'block', 'report'],
+ 'viewer card lists every action, watch present while live');
+ const byId = Object.fromEntries(tedCard.actions.map(a => [a.id, a]));
+ assert.strictEqual(byId.follow.method, 'PUT');
+ assert.strictEqual(byId.follow.path, '/api/v1/me/follows/channel/TedAct');
+ assert.deepStrictEqual(byId.follow.state, { following: false });
+ assert.deepStrictEqual(byId.report.body, { target: 'TedAct', reason: 'other', detail: '' });
+ assert.strictEqual(byId.watch.href, 'https://openvibe.live/@TedAct');
+ assert.ok(!tedCard.actions.some(a => a.id.startsWith('staff-')), 'no staff tools for a plain viewer');
+ // Following flips the action to Unfollow.
+ await setFollow(db, VIC, 'channel', TED, true, {});
+ tedCard = await card('TedAct');
+ assert.strictEqual(tedCard.actions.find(a => a.id === 'unfollow').state.following, true, 'following state is served');
+ await setFollow(db, VIC, 'channel', TED, false, {});
+ // Mute through its route, then the card state follows.
+ assert.strictEqual((await (await fetch(`${base}/api/v1/me/mutes/TedAct`, { method: 'PUT' })).json()).mute.active, true);
+ assert.strictEqual((await fetch(`${base}/api/v1/me/mutes`)).status, 200);
+ tedCard = await card('TedAct');
+ assert.strictEqual(tedCard.actions.find(a => a.id === 'unmute').state.muted, true, 'muted state is served');
+ assert.strictEqual((await (await fetch(`${base}/api/v1/me/mutes/TedAct`, { method: 'DELETE' })).json()).mute.active, false);
+ assert.strictEqual((await fetch(`${base}/api/v1/me/mutes/NoSuchPersonXYZ`, { method: 'PUT' })).status, 404, 'unknown people cannot be muted');
+ assert.strictEqual((await fetch(`${base}/api/v1/me/friends/VicAct`, { method: 'PUT' })).status, 400, 'self adds are refused');
+ // Friendship: none → pending → friends.
+ assert.strictEqual((await (await fetch(`${base}/api/v1/me/friends/TedAct`, { method: 'PUT' })).json()).friend.state, 'pending');
+ tedCard = await card('TedAct');
+ assert.strictEqual(tedCard.actions.find(a => a.id === 'cancel-request').state.friendship, 'pending', 'outgoing request reads Requested');
+ await setFriend(db, TED, VIC, true);
+ tedCard = await card('TedAct');
+ assert.strictEqual(tedCard.actions.find(a => a.id === 'unfriend').state.friendship, 'friends', 'mutual adds read Friends');
+ // Report through its route, then the card state follows.
+ const filed = await (await fetch(`${base}/api/v1/me/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'TedAct', reason: 'spam', detail: 'test' }) })).json();
+ assert.strictEqual(filed.created, true);
+ assert.strictEqual(filed.report.reason, 'spam');
+ tedCard = await card('TedAct');
+ assert.strictEqual(tedCard.actions.find(a => a.id === 'report').state.reported, true, 'open report is served');
+ const again = await (await fetch(`${base}/api/v1/me/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'TedAct', reason: 'scam' }) })).json();
+ assert.strictEqual(again.created, false, 'an open report is returned, never duplicated');
+ const badReason = await fetch(`${base}/api/v1/me/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'TedAct', reason: 'bogus' }) });
+ assert.strictEqual(badReason.status, 400, 'bad reasons are refused');
+ const selfReport = await fetch(`${base}/api/v1/me/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'VicAct', reason: 'spam' }) });
+ assert.strictEqual(selfReport.status, 400, 'self reports are refused');
+ // A staff viewer sees the queue; a plain viewer is refused.
+ assert.strictEqual((await fetch(`${base}/api/v1/staff/user-reports`)).status, 403, 'non-staff cannot read the queue');
+ asVic.role = 'global_mod';
+ const queue = await fetch(`${base}/api/v1/staff/user-reports?status=open&target=${TED}`);
+ assert.strictEqual(queue.status, 200, 'a moderator reads the queue');
+ const queueBody = await queue.json();
+ assert.ok(queueBody.reports.some((r) => r.target === TED), 'the queue carries the open report');
+ assert.strictEqual((await fetch(`${base}/api/v1/staff/user-reports?status=bogus`)).status, 400, 'a bad status is refused');
+ asVic.role = 'user';
+ // Blocked pair: only Unblock + Report.
+ await setBlock(db, VIC, TED, true);
+ tedCard = await card('TedAct');
+ assert.deepStrictEqual(tedCard.actions.map(a => a.id), ['profile', 'channel', 'unblock', 'report'], 'a blocked person keeps Unblock + Report');
+ await setBlock(db, VIC, TED, false);
+ await setBlock(db, TED, VIC, true);
+ tedCard = await card('TedAct');
+ assert.deepStrictEqual(tedCard.actions.map(a => a.id), ['profile', 'channel', 'block', 'report'], 'being blocked keeps Block + Report');
+ await setBlock(db, TED, VIC, false);
+ // Staff tools for staff (by role).
+ viewer = { subject: VIC, username: 'VicAct', role: 'global_mod', staff: require('../server/auth/staff-claims').staffClaims({ role: 'global_mod' }) };
+ tedCard = await card('TedAct');
+ assert.deepStrictEqual(tedCard.actions.filter(a => a.id.startsWith('staff-')).map(a => a.id), ['staff-review', 'staff-reports'], 'staff see their tools');
+ console.log('user-card-actions: anonymous/self/viewer sets, follow/mute/friend/report/block states and staff tools passed');
+ } finally { await new Promise(resolve => server.close(resolve)); }
+})().catch(e => { console.error(e); process.exitCode = 1; });
@@ -39,7 +39,7 @@ const cors = require('../server/public-cors');
@@ -39,7 +39,7 @@ const cors = require('../server/public-cors');
assert.strictEqual(responses[0].headers.get('cache-control'), 'no-store');
assert.strictEqual(responses[0].headers.get('cache-control'), 'no-store');
assert.strictEqual(responses[0].headers.get('access-control-allow-origin'), '*');
assert.strictEqual(responses[0].headers.get('access-control-allow-origin'), '*');
assert.ok(cors.isPublicDiscoveryPath('/api/v1/people/CardTest/card'));
assert.ok(cors.isPublicDiscoveryPath('/api/v1/people/CardTest/card'));
- assert.deepStrictEqual(card, { username: 'CardTest', display_name: 'Card Test', profile_url: 'https://openvibe.network/@CardTest', avatar_url: 'https://openvibe.network/avatar/CardTest?s=160', level: 1, followers: 0, badges_count: 1, streak: 3, channel_url: 'https://openvibe.live/@CardTest', live: { now: true, site: 'live', url: 'https://openvibe.live/@CardTest' } });
+ assert.deepStrictEqual(card, { username: 'CardTest', display_name: 'Card Test', profile_url: 'https://openvibe.network/@CardTest', avatar_url: 'https://openvibe.network/avatar/CardTest?s=160', level: 1, followers: 0, badges_count: 1, streak: 3, channel_url: 'https://openvibe.live/@CardTest', live: { now: true, site: 'live', url: 'https://openvibe.live/@CardTest' }, actions: [{ id: 'profile', label: 'Profile', href: 'https://openvibe.network/@CardTest' }, { id: 'channel', label: 'Channel', href: 'https://openvibe.live/@CardTest' }] });
assert.strictEqual(calls, 3, 'concurrent reads coalesce');
assert.strictEqual(calls, 3, 'concurrent reads coalesce');
await get(); assert.strictEqual(calls, 3, 'warm read reuses aggregate');
await get(); assert.strictEqual(calls, 3, 'warm read reuses aggregate');
clock += TTL_MS + 1; await get(); assert.strictEqual(calls, 6, 'expires after 30 seconds');
clock += TTL_MS + 1; await get(); assert.strictEqual(calls, 6, 'expires after 30 seconds');
@@ -58,7 +58,7 @@ const cors = require('../server/public-cors');
@@ -58,7 +58,7 @@ const cors = require('../server/public-cors');
assert.ok(!('live' in visible) && !('channel_url' in visible));
assert.ok(!('live' in visible) && !('channel_url' in visible));
await db.prepare("UPDATE users SET profile_public=0, card_visibility='{}'::jsonb WHERE id=?").run(user.id);
await db.prepare("UPDATE users SET profile_public=0, card_visibility='{}'::jsonb WHERE id=?").run(user.id);
visible = await (await get()).json();
visible = await (await get()).json();
- assert.deepStrictEqual(Object.keys(visible).sort(), ['avatar_url', 'display_name', 'profile_url', 'username']);
+ assert.deepStrictEqual(Object.keys(visible).sort(), ['actions', 'avatar_url', 'display_name', 'profile_url', 'username']);
await db.prepare("UPDATE users SET profile_public=1, public_page_places='{}' WHERE id=?").run(user.id);
await db.prepare("UPDATE users SET profile_public=1, public_page_places='{}' WHERE id=?").run(user.id);
await db.prepare("INSERT INTO person_presence(subject_id,state,show_status) VALUES (?, 'invisible', 1)").run(subject);
await db.prepare("INSERT INTO person_presence(subject_id,state,show_status) VALUES (?, 'invisible', 1)").run(subject);
visible = await (await get()).json();
visible = await (await get()).json();
@@ -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']]);
+ 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']]);
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.