openvibers/openvibe.network Commits
Presence: Network owns the chosen online status, heartbeats and the public read (#626)
Changes
@@@ -28,6 +28,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA- **Public profiles** — `/@<username>`: one page per person with their picture, bio, when they joined, what they wear and the items they own in [OpenVibe.Inventory](https://inventory.openvibe.network) (plan T21). The same data is at `GET /api/v1/profiles/:username`, open to every origin. An old username answers 301 to the current one. A person can hide their profile from Edit Profile (`users.profile_public`); a hidden one shows only the name and picture and is not indexed. Items are read with the Network's own service token and kept 60 s per person (`server/profile/public.js`).- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.- **User modules** — versioned per-person preferences and summaries in namespaces owned by services (openvibe-contracts `manifests/namespaces`); see [User modules](#user-modules).++- **Online status** — each person has a presence of online, away, do not disturb or invisible, with an optional status line and one emoji, carried across every site. `POST /api/v1/me/presence/heartbeat` keeps it fresh (the shared navbar sends it every 60 s while the page is visible), `PUT /api/v1/me/presence` sets the chosen state, and `GET /api/v1/presence?subjects=…` answers only for people the caller follows or has a DM with (and themselves), never for a stranger and never for an invisible person; last seen is never returned. `network.presence@1` and `network.presence.changed@1`, contracts 0.318.0, `server/presence/`. Chat reads it through `GET /internal/presence` (`network.presence.read`) and asks back for the DM half of the rule (`chat.dm.peers.read`). Live streaming makes `live: true`. Every row is in the person's export and erased with the account (ADR-033).- **The Dock's state** — the little floating surface every OpenVibe site draws (your agent, your chats, your activity) follows the person across every domain, because `localStorage` is per site and the state lives here instead. `GET /api/v1/me/dock` and `PUT /api/v1/me/dock` (`network.dock-state@1`, `network.dock-result@1`, contracts 0.251.0; `server/dock/`). See [docs/dock.md](docs/dock.md).## Your account ID@@@ -107,7 +108,7 @@ person's username and picture (a service token, `identity.subject.resolve`).Implemented here (the service manifest's `capabilities`, audience `openvibe.network`):`identity.subject.resolve`, `mods.grant.manage`, `network.account.deletion.confirm`,`network.account.export.contribute`, `network.analytics.creator.read`, `network.blocks.read`,--`network.coins.credit|debit|transfer`, `network.follows.read|write`, `network.integration.github.read`,++`network.coins.credit|debit|transfer`, `network.follows.read|write`, `network.presence.read`, `network.integration.github.read`,`network.modules.read|write`, `network.notifications.push`, `network.operator.alert`,`network.project.manage|read`, `network.secret.use`, `network.staff.read` and `network.status.incident`. The grants otherservices hold are seeded in [server/identity/principals.js](server/identity/principals.js).nnew file mode 100644@@@ -0,0 +1,28 @@++-- phase: expand++-- Presence (plan 2026-10-22 track X item 9): one person's chosen online status, the heartbeat that says++-- they are around, and the public view last announced to OpenVibe.Events. Network owns it; Chat and the++-- navbar read it through GET /api/v1/presence (or GET /internal/presence with network.presence.read).++--++-- The derived state is not stored: online while last_heartbeat_at is under 2 minutes old, away after 10++-- minutes without input (last_active_at; an idle heartbeat leaves it alone), offline after 3 minutes++-- without a heartbeat. state is the person's chosen state; invisible and show_status = 0 read as offline.++-- announced_* is the public view in the last network.presence.changed event, so a change inside the 30 s++-- debounce sets pending_since and the flusher sends the view that stands when the window opens.++CREATE TABLE IF NOT EXISTS person_presence (++ subject_id text COLLATE "C" PRIMARY KEY REFERENCES users(subject_id) ON DELETE CASCADE,++ state text COLLATE "C" NOT NULL DEFAULT 'auto' CHECK (state IN ('auto', 'dnd', 'invisible')),++ status_text text COLLATE "C",++ emoji text COLLATE "C",++ show_status bigint NOT NULL DEFAULT 1 CHECK (show_status IN (0, 1)),++ last_active_at text COLLATE "C",++ last_heartbeat_at text COLLATE "C",++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ announced_state text COLLATE "C",++ announced_status_text text COLLATE "C",++ announced_emoji text COLLATE "C",++ announced_live bigint,++ announced_at text COLLATE "C",++ pending_since text COLLATE "C"++);++++CREATE INDEX IF NOT EXISTS person_presence_pending ON person_presence(pending_since) WHERE pending_since IS NOT NULL;@@@ -204,6 +204,9 @@ 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);++ // 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);add('developer_projects.json', {owned: (await rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id])).rows,memberships: (await rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] })).rows,@@@ -351,6 +354,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);if (await tableExists(db, 'analytics_events')) await db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);await del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');++ await del('person_presence', `subject_id IN ${inS}`, subjects, 'presence');// The consent registry (L24): the account's active choices are revoked (and its aliases' too), but the rows// stay as proof of what was allowed and when; they hold no content. The events go out after this commit.consentEvents = await require('../consent').revokeForDeletion(db, subjects, { at });@@@ -82,6 +82,9 @@ const DEFAULT_GRANTS = [['live', 'network.modules.write', SELF_AUDIENCE, ['live.profile', 'live.stats', 'live.loyalty']],['chat', 'network.modules.read', SELF_AUDIENCE, CHAT_NAMESPACES],['chat', 'network.modules.write', SELF_AUDIENCE, CHAT_NAMESPACES],++ // Presence (plan 2026-10-22 track X item 9): Chat draws the dot in its DMs and member lists. The API enforces++ // the follows-or-DM rule for the person it is asked about; Chat never sees a stranger's row.++ ['chat', 'network.presence.read', SELF_AUDIENCE, []],// Chat's chat-app bots (plan A4) resolve a Telegram/Discord/Slack account to its linked OpenVibe// subject, and register/confirm/unlink the one-time links. Only svc:chat may reach those routes;// the grant is what its service token is minted with.@@@ -620,6 +620,18 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.require('./identity/follows').setNotifier(require('./notifications/follow-notify').followNotifier(notificationService));}++// Presence (plan 2026-10-22 track X item 9; Contracts 0.318.0): the person's chosen online status, the++// heartbeat from any open tab and the public view their followers and DM peers may read. Chat and the++// navbar read /api/v1/presence; Chat may read /internal/presence with network.presence.read.++{++ const presence = require('./presence').createPresence({++ db, requireAuth, guard: require('./identity/principals').guard, selfToken, config,++ });++ app.use('/api/v1/me/presence', rateLimit({ windowMs: 60_000, max: 240 }), requireAuth, presence.me);++ app.use('/api/v1/presence', rateLimit({ windowMs: 60_000, max: 600 }), requireAuth, presence.pub);++ app.use('/internal/presence', require('./identity/principals').guard('network.presence.read'), presence.internal);++ presence.start();++}// Realtime tickets (WS-E task 3, WS-F task 1; ADR-005 amendment 2): the notification badge on any site opens// OpenVibe.Events' /realtime/stream as the signed-in person with a two-minute, single-use ticket// (server/auth/realtime-ticket.js). REALTIME_TICKETS=off answers 503 and every badge stays on polling.nnew file mode 100644@@@ -0,0 +1,409 @@++'use strict';++/**++ * A person's presence (plan 2026-10-22 track X item 9; Contracts 0.318.0 network.presence@1,++ * network.presence-update-request@1 and network.presence.changed@1).++ *++ * OpenVibe.Network owns the chosen online status. A heartbeat from any open tab keeps it fresh:++ *++ * POST /api/v1/me/presence/heartbeat { idle?: true } any signed-in person, at most once a minute++ * PUT /api/v1/me/presence network.presence-update-request@1++ * GET /api/v1/presence?subjects=… the people the caller follows or has a DM with, and themselves++ * GET /internal/presence?for=usr_…&subjects=… the same for a first-party service++ * (network.presence.read)++ *++ * Derived state: online while the newest heartbeat is under 2 minutes old; away after 10 minutes without++ * input (the client's idle heartbeat leaves last_active_at alone); offline after 3 minutes without a++ * heartbeat. invisible, or the "Show my status" switch off, reads as offline to everyone; an invisible++ * person is left out of another person's answer entirely. Last seen is never returned.++ *++ * live is true while the person is streaming on OpenVibe.Live. The live list is Live's public++ * GET /api/streams over loopback, cached 30 s, and mapped to Network subjects through++ * identity_legacy_map (source_system 'live'). Live outranks the chosen state; it is false while hidden.++ *++ * network.presence.changed is written into network_event_outbox in the same transaction as the row, only++ * for a real change of the public view, at most once per person per 30 s. A change inside the window sets++ * pending_since; flushDue() sends the view that stands when the window opens, so a tab that closes right++ * after a change still announces it. The payload is always the public view: invisible is offline with no++ * text or emoji.++ *++ * The DM part of the visibility rule is read from OpenVibe.Chat (POST /internal/chat/dm/peers,++ * chat.dm.peers.read), cached here for 30 s; when Chat does not answer, only follows count. The follow++ * part is Network's own user_follows ('channel' targets are the owner's subject).++ *++ * Tests: test/presence.test.js.++ */++const { ids, validate } = require('openvibe-contracts');++const eventRelay = require('../developer/event-relay');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const HEARTBEAT_ONLINE_MS = 2 * 60 * 1000;++const OFFLINE_MS = 3 * 60 * 1000;++const AWAY_MS = 10 * 60 * 1000;++const DEBOUNCE_MS = 30 * 1000;++const LIVE_TTL_MS = 30 * 1000;++const DM_TTL_MS = 30 * 1000;++const MAX_SUBJECTS = 100;++const MAX_STATUS = 80;++const MAX_EMOJI = 16;++++class PresenceError extends Error {++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }++}++++const iso = (ms) => new Date(ms).toISOString();++const msOf = (t) => { const n = Date.parse(String(t || '')); return Number.isFinite(n) ? n : null; };++++/** Plain single-line text: control characters and angle brackets never reach a status line. */++function cleanText(value) {++ if (value == null) return null;++ if (typeof value !== 'string') throw new PresenceError(400, 'presence.bad_status', 'status_text must be text or null');++ const s = value.replace(/[\u0000-\u001f\u007f<>]/g, ' ').replace(/\s+/g, ' ').trim();++ if (!s) return null;++ if ([...s].length > MAX_STATUS) throw new PresenceError(400, 'presence.status_too_long', `a status line is at most ${MAX_STATUS} characters`);++ return s;++}++++/** One emoji: one grapheme cluster, at most 16 code points (a family or a flag is one cluster). */++function cleanEmoji(value) {++ if (value == null) return null;++ if (typeof value !== 'string') throw new PresenceError(400, 'presence.bad_emoji', 'emoji must be text or null');++ const s = value.trim();++ if (!s) return null;++ if ([...s].length > MAX_EMOJI) throw new PresenceError(400, 'presence.emoji_too_long', 'emoji must be one emoji');++ const count = [...new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(s)].length;++ if (count !== 1) throw new PresenceError(400, 'presence.one_emoji', 'emoji must be one emoji');++ return s;++}++++/** The public view of one row, or null when there is no row. `self` keeps the chosen hidden state for its owner. */++function publicView(row, { nowMs, streaming = false, self = false } = {}) {++ if (!row) return null;++ const hidden = !Number(row.show_status) || row.state === 'invisible';++ const lastBeat = msOf(row.last_heartbeat_at);++ const lastActive = msOf(row.last_active_at) ?? lastBeat;++ let state;++ // The owner's chosen DND is theirs whatever the heartbeat says (Chat reads it to mute their pings);++ // everyone else sees DND only while the heartbeat is fresh, and offline after it.++ if (self && !hidden && row.state === 'dnd') state = 'dnd';++ else if (hidden) state = self ? (row.state === 'invisible' ? 'invisible' : 'offline') : 'offline';++ else if (lastBeat == null || nowMs - lastBeat > OFFLINE_MS || lastBeat - nowMs > 60_000) state = 'offline';++ else if (row.state === 'dnd') state = 'dnd';++ // A heartbeat two minutes old still counts, but the connection is fading: away until the three-minute++ // cutoff makes it offline, or the next heartbeat makes it online again.++ else if (nowMs - lastBeat > HEARTBEAT_ONLINE_MS) state = 'away';++ else if (lastActive == null || nowMs - lastActive > AWAY_MS) state = 'away';++ else state = 'online';++ const visible = !hidden && state !== 'offline';++ return {++ subject: row.subject_id,++ state,++ status_text: visible || self ? (row.status_text || null) : null,++ emoji: visible || self ? (row.emoji || null) : null,++ live: !!(visible && streaming),++ updated_at: row.updated_at,++ };++}++++/** Compare the public view with the last announced one (announced_at is not part of the view). */++function sameView(row, view) {++ return row.announced_state === view.state++ && (row.announced_status_text || null) === view.status_text++ && (row.announced_emoji || null) === view.emoji++ && Boolean(Number(row.announced_live)) === view.live;++}++++function buildEnvelope(view, nowMs) {++ const env = {++ event_id: ids.newId('event', nowMs), event_type: 'network.presence.changed', version: 1, source: 'network',++ actor: { type: 'user', id: view.subject }, timestamp: iso(nowMs), visibility: 'internal',++ subject: { type: 'user', id: view.subject }, payload: view,++ };++ const v = validate('events.event-envelope@1', env);++ const pv = validate('network.presence.changed@1', view);++ if (!v.valid || !pv.valid) throw new Error(`presence: bad event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);++ return env;++}++++/** The announced fields for a public view. */++const announcedParams = (view) => [view.state, view.status_text, view.emoji, view.live ? 1 : 0];++++/**++ * Record the public view, and announce it if it is a real change outside the debounce window.++ * Runs inside the caller's transaction. `row` is the row after the write.++ */++async function announce(tx, row, view, nowMs) {++ const at = iso(nowMs);++ const hadAnnouncement = row.announced_state != null;++ if (hadAnnouncement && sameView(row, view)) {++ if (row.pending_since) await tx.prepare('UPDATE person_presence SET pending_since = NULL WHERE subject_id = ?').run(row.subject_id);++ return false;++ }++ // The first write of someone who is already offline is not a change anyone can see; remember it quietly++ // so their first online/away/dnd transition announces at once.++ if (!hadAnnouncement && view.state === 'offline') {++ await tx.prepare(`UPDATE person_presence SET announced_state = ?, announced_status_text = ?, announced_emoji = ?, announced_live = ?, pending_since = NULL WHERE subject_id = ?`)++ .run(...announcedParams(view), row.subject_id);++ return false;++ }++ const lastAt = msOf(row.announced_at);++ if (hadAnnouncement && lastAt != null && nowMs - lastAt < DEBOUNCE_MS) {++ await tx.prepare('UPDATE person_presence SET pending_since = COALESCE(pending_since, ?) WHERE subject_id = ?').run(at, row.subject_id);++ return false;++ }++ await eventRelay.writerFor(tx).enqueue(buildEnvelope(view, nowMs));++ await tx.prepare(`UPDATE person_presence SET announced_state = ?, announced_status_text = ?, announced_emoji = ?, announced_live = ?, announced_at = ?, pending_since = NULL WHERE subject_id = ?`)++ .run(...announcedParams(view), at, row.subject_id);++ return true;++}++++/** Read one row and its public view. */++async function viewOf(db, subject, { nowMs, streaming = false, self = false } = {}) {++ const row = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);++ return { row, view: publicView(row, { nowMs, streaming, self }) };++}++++/** A heartbeat: fresh last_heartbeat_at; last_active_at moves only when the client is not idle. */++async function heartbeat(db, subject, { idle = false, nowMs = Date.now(), streaming = false } = {}) {++ if (!SUBJECT_RE.test(String(subject || ''))) throw new PresenceError(400, 'presence.bad_subject', 'not a person subject');++ const at = iso(nowMs);++ let view = null;++ await db.tx(async () => {++ const before = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);++ if (!before) {++ await db.prepare(`INSERT INTO person_presence (subject_id, state, show_status, last_active_at, last_heartbeat_at, updated_at)++ VALUES (?, 'auto', 1, ?, ?, ?)`).run(subject, at, at, at);++ } else {++ await db.prepare(`UPDATE person_presence SET last_heartbeat_at = ?, last_active_at = CASE WHEN ? THEN last_active_at ELSE ? END, updated_at = ? WHERE subject_id = ?`)++ .run(at, idle ? 1 : 0, at, at, subject);++ }++ const row = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);++ view = publicView(row, { nowMs, self: false, streaming });++ await announce(db, row, view, nowMs);++ view = publicView(row, { nowMs, self: true, streaming });++ });++ return view;++}++++/** PUT /api/v1/me/presence: only the fields sent change. The row is created on a first write. */++async function update(db, subject, body, { nowMs = Date.now(), streaming = false } = {}) {++ if (!SUBJECT_RE.test(String(subject || ''))) throw new PresenceError(401, 'token.missing', 'a signed-in person is required');++ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new PresenceError(400, 'presence.bad_body', 'a JSON object is required');++ const allowed = new Set(['state', 'status_text', 'emoji', 'show_status']);++ for (const k of Object.keys(body)) if (!allowed.has(k)) throw new PresenceError(400, 'presence.unknown_field', `unknown field ${k}`);++ if (!Object.keys(body).length) throw new PresenceError(400, 'presence.empty', 'send at least one field');++ if (body.state !== undefined && !['auto', 'dnd', 'invisible'].includes(body.state)) throw new PresenceError(400, 'presence.bad_state', 'state must be auto, dnd or invisible');++ if (body.status_text !== undefined) cleanText(body.status_text);++ if (body.emoji !== undefined) cleanEmoji(body.emoji);++ if (body.show_status !== undefined && typeof body.show_status !== 'boolean') throw new PresenceError(400, 'presence.bad_show_status', 'show_status must be true or false');++++ const at = iso(nowMs);++ let view = null;++ await db.tx(async () => {++ const before = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);++ if (!before) {++ await db.prepare(`INSERT INTO person_presence (subject_id, state, status_text, emoji, show_status, last_active_at, last_heartbeat_at, updated_at)++ VALUES (?, 'auto', NULL, NULL, 1, ?, NULL, ?)`).run(subject, at, at);++ }++ const row = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);++ const next = {++ state: body.state !== undefined ? body.state : row.state,++ status_text: body.status_text !== undefined ? cleanText(body.status_text) : row.status_text,++ emoji: body.emoji !== undefined ? cleanEmoji(body.emoji) : row.emoji,++ show_status: body.show_status !== undefined ? (body.show_status ? 1 : 0) : Number(row.show_status),++ };++ await db.prepare('UPDATE person_presence SET state = ?, status_text = ?, emoji = ?, show_status = ?, updated_at = ? WHERE subject_id = ?')++ .run(next.state, next.status_text, next.emoji, next.show_status, at, subject);++ const after = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);++ await announce(db, after, publicView(after, { nowMs, self: false, streaming }), nowMs);++ view = publicView(after, { nowMs, self: true, streaming });++ });++ return view;++}++++/** The public view of `subjects` for `viewer`: themselves, people they follow, and DM peers only. */++async function visiblePresence(db, viewer, subjects, { nowMs = Date.now(), streaming = new Set(), dmPeers } = {}) {++ const self = subjects.includes(viewer);++ const others = subjects.filter((s) => s !== viewer);++ if (!others.length) {++ const own = await viewOf(db, viewer, { nowMs, streaming: streaming.has(viewer), self: true });++ return own.view ? [own.view] : [];++ }++ const followed = new Set();++ const rows = await db.prepare(`SELECT target_id FROM user_follows WHERE follower_subject = ? AND target_type = 'channel' AND active = 1 AND target_id = ANY(?)`)++ .all(viewer, others);++ for (const r of rows) followed.add(r.target_id);++ // Only the people a follow does not already cover need Chat's DM answer.++ const dmCandidates = others.filter((s) => !followed.has(s));++ const peers = dmPeers && dmCandidates.length ? await dmPeers(viewer, dmCandidates) : new Set();++ const allowed = others.filter((s) => followed.has(s) || peers.has(s));++ const out = [];++ if (self) {++ const own = await viewOf(db, viewer, { nowMs, streaming: streaming.has(viewer), self: true });++ if (own.view) out.push(own.view);++ }++ if (allowed.length) {++ const found = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ANY(?)').all(allowed);++ for (const row of found) {++ // An invisible person (or one with show_status off) is never disclosed, not even as offline.++ if (!Number(row.show_status) || row.state === 'invisible') continue;++ out.push(publicView(row, { nowMs, streaming: streaming.has(row.subject_id), self: false }));++ }++ }++ return out;++}++++/** "usr_…,usr_…" or repeated ?subjects=; at most 100, deduplicated. */++function parseSubjects(input) {++ const raw = Array.isArray(input) ? input.join(',') : String(input == null ? '' : input);++ const list = raw.split(',').map((s) => s.trim()).filter(Boolean);++ if (list.length > MAX_SUBJECTS) throw new PresenceError(400, 'presence.too_many', `at most ${MAX_SUBJECTS} subjects`);++ for (const s of list) if (!SUBJECT_RE.test(s)) throw new PresenceError(400, 'presence.bad_subject', `${s.slice(0, 40)} is not a person subject`);++ return [...new Set(list)];++}++++/** Default live set: Live's public list over loopback, mapped to Network subjects, cached 30 s. */++function createLiveSource({ db, config, fetchImpl, now }) {++ let cache = { at: 0, value: new Set() };++ return async function streamingSubjects() {++ const t = now();++ if (t - cache.at < LIVE_TTL_MS) return cache.value;++ const base = String((config.services && config.services.live && config.services.live.internalUrl) || 'http://127.0.0.1:3000').replace(/\/+$/, '');++ try {++ const res = await fetchImpl(`${base}/api/streams`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(2000) });++ if (!res.ok) return cache.value;++ const body = await res.json();++ const liveIds = [...new Set((Array.isArray(body.streams) ? body.streams : []).filter((s) => s && s.is_live && s.user_id != null).map((s) => String(s.user_id)))];++ const subjects = liveIds.length++ ? new Set((await db.prepare(`SELECT subject_id FROM identity_legacy_map WHERE source_system = 'live' AND source_type = 'user' AND source_id = ANY(?)`).all(liveIds)).map((r) => r.subject_id))++ : new Set();++ cache = { at: t, value: subjects };++ return subjects;++ } catch {++ // A Live that does not answer leaves the last good set in place; stale for 30 s at most is no leak.++ return cache.value;++ }++ };++}++++/** Default DM peers: ask Chat which candidates share a conversation with the viewer, cached 30 s. */++function createDmPeers({ selfToken, config, fetchImpl, now }) {++ const cache = new Map();++ return async function dmPeers(viewer, candidates) {++ const key = `${viewer}|${candidates.slice().sort().join(',')}`;++ const hit = cache.get(key);++ if (hit && now() - hit.at < DM_TTL_MS) return hit.value;++ const value = new Set();++ try {++ const token = selfToken && selfToken('openvibe.chat', ['chat.dm.peers.read']);++ if (token) {++ const base = String((config.services && config.services.chat && config.services.chat.internalUrl) || 'http://127.0.0.1:4400').replace(/\/+$/, '');++ const res = await fetchImpl(`${base}/internal/chat/dm/peers`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` },++ body: JSON.stringify({ subject: viewer, candidates }),++ signal: AbortSignal.timeout(1500),++ });++ if (res.ok) {++ const body = await res.json().catch(() => ({}));++ for (const p of Array.isArray(body.peers) ? body.peers : []) if (candidates.includes(p)) value.add(p);++ }++ }++ } catch { /* Chat down: follows only, never a stranger */ }++ if (cache.size > 1000) cache.clear();++ cache.set(key, { at: now(), value });++ return value;++ };++}++++/**++ * The presence routers and its flusher.++ * me POST /heartbeat, PUT / (mount after requireAuth)++ * pub GET / (mount after requireAuth)++ * internal GET / (mount after guard('network.presence.read'))++ */++function createPresence({ db, requireAuth, guard, selfToken, config = {}, fetchImpl = globalThis.fetch, now = Date.now, log = console }) {++ const streamingSubjects = createLiveSource({ db, config, fetchImpl, now });++ const dmPeers = createDmPeers({ selfToken, config, fetchImpl, now });++++ async function flushDue(nowMs = now()) {++ const due = await db.prepare("SELECT * FROM person_presence WHERE pending_since IS NOT NULL AND pending_since <= ? ORDER BY pending_since LIMIT 200")++ .all(iso(nowMs - DEBOUNCE_MS));++ if (!due.length) return 0;++ const streaming = await streamingSubjects();++ let sent = 0;++ for (const candidate of due) {++ await db.tx(async () => {++ const row = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(candidate.subject_id);++ if (!row || !row.pending_since) return;++ const view = publicView(row, { nowMs, streaming: streaming.has(row.subject_id), self: false });++ if (await announce(db, row, view, nowMs)) sent++;++ });++ }++ return sent;++ }++++ function start({ intervalMs = 15_000 } = {}) {++ const timer = setInterval(() => flushDue().catch((err) => log.warn('[Presence] flush failed:', err.message)), intervalMs);++ if (timer.unref) timer.unref();++ return timer;++ }++++ const me = require('express').Router();++ me.post('/heartbeat', async (req, res) => {++ if (!req.user || !req.user.subject_id) return res.status(401).json({ error: 'token.missing', detail: 'a signed-in person is required' });++ const idle = !!(req.body && req.body.idle);++ try {++ res.set('Cache-Control', 'no-store');++ const streaming = (await streamingSubjects()).has(req.user.subject_id);++ return res.json(await heartbeat(db, req.user.subject_id, { idle, nowMs: now(), streaming }));++ } catch (err) {++ if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });++ throw err;++ }++ });++ me.put('/', async (req, res) => {++ if (!req.user || !req.user.subject_id) return res.status(401).json({ error: 'token.missing', detail: 'a signed-in person is required' });++ try {++ res.set('Cache-Control', 'no-store');++ const streaming = (await streamingSubjects()).has(req.user.subject_id);++ return res.json(await update(db, req.user.subject_id, req.body, { nowMs: now(), streaming }));++ } catch (err) {++ if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });++ throw err;++ }++ });++++ const pub = require('express').Router();++ pub.get('/', async (req, res) => {++ if (!req.user || !req.user.subject_id) return res.status(401).json({ error: 'token.missing', detail: 'a signed-in person is required' });++ try {++ const subjects = parseSubjects(req.query.subjects);++ res.set('Cache-Control', 'private, max-age=15');++ return res.json({ presence: await visiblePresence(db, req.user.subject_id, subjects, { nowMs: now(), streaming: await streamingSubjects(), dmPeers }) });++ } catch (err) {++ if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });++ throw err;++ }++ });++++ const internal = require('express').Router();++ internal.get('/', async (req, res) => {++ const viewer = String(req.query.for || '');++ if (!SUBJECT_RE.test(viewer)) return res.status(400).json({ error: 'presence.bad_for', detail: 'for must be a person subject' });++ try {++ const subjects = parseSubjects(req.query.subjects);++ res.set('Cache-Control', 'private, max-age=15');++ return res.json({ presence: await visiblePresence(db, viewer, subjects, { nowMs: now(), streaming: await streamingSubjects(), dmPeers }) });++ } catch (err) {++ if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });++ throw err;++ }++ });++++ return { me, pub, internal, flushDue, start, streamingSubjects, dmPeers, heartbeat, update, visiblePresence, parseSubjects, publicView };++}++++module.exports = {++ createPresence, PresenceError, publicView, parseSubjects, heartbeat, update, visiblePresence,++ SUBJECT_RE, HEARTBEAT_ONLINE_MS, OFFLINE_MS, AWAY_MS, DEBOUNCE_MS, MAX_SUBJECTS,++};@@@ -90,6 +90,9 @@ const server = http.createServer(app);await follows.setFollow(db, dana.subject_id, 'channel', x.subject_id, true, { emit: false });await follows.setFollow(db, x.subject_id, 'channel', dana.subject_id, true, { emit: false });await db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en')").run(dana.id);++ // Presence (plan X9) is part of the person's export and is erased with the account.++ await db.prepare(`INSERT INTO person_presence (subject_id, state, status_text, emoji, last_active_at, last_heartbeat_at, updated_at)++ VALUES (?, 'dnd', 'Writing', '✍️', ov_now_iso(), ov_now_iso(), ov_now_iso())`).run(dana.subject_id);// ── Export ──const seeded = await accountData.expectedServices(db, 'network.account.export.contribute');@@@ -159,6 +162,8 @@ const server = http.createServer(app);for (const secret of ['password_hash', 'sess-secret-dana', 'rt-secret-dana', hash]) assert.ok(!all.includes(secret), `no ${secret} in the archive`);assert.strictEqual(JSON.parse(files['network/opencoins.json']).balance, 40);assert.deepStrictEqual(JSON.parse(files['network/follows.json']).followers_count, 1, 'followers as a count, never their names');++ assert.deepStrictEqual(JSON.parse(files['network/presence.json'])[0].state, 'dnd', 'presence is in the export');++ assert.strictEqual(JSON.parse(files['network/presence.json'])[0].status_text, 'Writing');assert.ok(!all.includes('[email protected]'), "nobody else's data");r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(eve));assert.strictEqual(r.status, 404, 'another person cannot download it');@@@ -244,6 +249,7 @@ const server = http.createServer(app);assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id)).n, 0, `${t} erased`);}assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id)).n, 0);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM person_presence WHERE subject_id = ?').get(dana.subject_id)).n, 0, 'presence erased');assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? OR target_id = ?').get(dana.subject_id, dana.subject_id)).n, 0, 'follows both ways');assert.strictEqual(wallet.getBalance ? await wallet.getBalance(db, dana.id) : (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(dana.id)).balance, 0);assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM coin_transactions WHERE user_id = ? AND reason = 'account_deleted'").get(dana.id)).n, 1, 'one closing entry; the ledger stays');@@@ -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']]);++ 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']]);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,224 @@++'use strict';++// Presence (plan 2026-10-22 track X item 9; Contracts 0.318.0): Network owns a person's chosen online status.++// - who may see: themselves, people they follow, people they share a DM with; never a stranger;++// - invisible and the "Show my status" switch are never disclosed to anyone else;++// - online under 2 minutes after a heartbeat, away after 10 minutes without input, offline after 3++// minutes without a heartbeat (a fake clock, and an idle heartbeat leaves last_active_at alone);++// - a Live stream makes live true (Live's public list over loopback, cached);++// - network.presence.changed is a real change, debounced 30 s, public view only;++// - PUT validates state, status line, one emoji and show_status; GET /internal/presence needs++// network.presence.read and answers for one person.++// node test/presence.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const { validate, serviceAuth } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++++const T0 = Date.parse('2026-11-07T09:00:00.000Z');++const MIN = 60 * 1000;++const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';++const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';++const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';++const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';++++(async () => {++const log = console.log; console.log = () => {};++const db = getDb();++console.log = log;++await require('../server/identity/principals').ensureSchema(db);++await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES++ (1, 'ann', 'Ann', 'x', ?, 'user'), (2, 'bob', 'Bob', 'x', ?, 'streamer'), (3, 'cat', 'Cat', 'x', ?, 'user'), (4, 'dan', 'Dan', 'x', ?, 'user')`).run(ANN, BOB, CAT, DAN);++// Ann follows Bob (the follow rule is Network's own graph; Chat supplies the DM rule below).++await db.prepare(`INSERT INTO user_follows (follower_subject, target_type, target_id, active) VALUES (?, 'channel', ?, 1)`).run(ANN, BOB);++// Live user 2 is Bob (identity_legacy_map is what Network uses to map Live's stream rows to subjects).++await db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)++ VALUES ('live', 'user', '2', ?, ov_now_iso())`).run(BOB);++++// A fake first-party edge for Live's public list and Chat's DM peer read.++let liveRows = [{ user_id: 2, is_live: 1, username: 'bob' }];++const edge = express();++edge.use(express.json());++const dmCalls = [];++edge.get('/api/streams', (req, res) => res.json({ streams: liveRows }));++edge.post('/internal/chat/dm/peers', (req, res) => {++ if (String(req.headers.authorization || '') !== 'Bearer svc-token') return res.status(401).json({ error: 'token.missing' });++ const b = req.body || {};++ dmCalls.push(b);++ const peers = b.subject === ANN ? (Array.isArray(b.candidates) ? b.candidates : []).filter((s) => s === CAT) : [];++ res.json({ subject: b.subject, peers });++});++const edgeServer = http.createServer(edge);++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' }, services: { live: { internalUrl: '' }, chat: { internalUrl: '' } } };++const { signToken } = require('../server/auth/routes');++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);++const principals = require('../server/identity/principals');++const { createPresence } = require('../server/presence');++let NOW = T0;++const presence = createPresence({++ db, requireAuth, guard: principals.guard, selfToken: () => 'svc-token', config, now: () => NOW,++});++const app = express();++app.use(express.json());++Object.assign(app.locals, { db, config, publicKey: keys.publicKey, privateKey: keys.privateKey });++app.use('/api/v1/me/presence', requireAuth, presence.me);++app.use('/api/v1/presence', requireAuth, presence.pub);++app.use('/internal/presence', principals.guard('network.presence.read'), presence.internal);++const server = http.createServer(app);++++const events = async (subject) => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all())++ .map((r) => r.envelope).filter((e) => e.event_type === 'network.presence.changed' && (!subject || e.payload.subject === subject));++const serviceToken = (cap = 'network.presence.read') => {++ // The guard verifies against the real clock, not the fake presence clock.++ const t = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({++ iss: config.jwt.issuer, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.network'], cap: [cap],++ iat: t, exp: t + 300, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,++ }, keys.privateKey);++};++++(async () => {++ await new Promise((r) => edgeServer.listen(0, '127.0.0.1', r));++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const edgeBase = `http://127.0.0.1:${edgeServer.address().port}`;++ config.services.live.internalUrl = edgeBase;++ config.services.chat.internalUrl = edgeBase;++ const base = `http://127.0.0.1:${server.address().port}`;++ const tok = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);++ const as = async (id) => ({ authorization: `Bearer ${await tok(id)}` });++ const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })++ .then(async (r) => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));++ const heartbeat = async (id, body = {}) => call('POST', '/api/v1/me/presence/heartbeat', { body, headers: await as(id) });++ const get = async (id, subjects) => call('GET', `/api/v1/presence?subjects=${subjects.map(encodeURIComponent).join(',')}`, { headers: await as(id) });++ const bySubject = (body) => Object.fromEntries((body.presence || []).map((p) => [p.subject, p]));++ const validEvent = (e) => {++ const ev = validate('events.event-envelope@1', e);++ if (!ev.valid) console.error('envelope:', JSON.stringify(e), JSON.stringify(ev.errors));++ assert.ok(ev.valid, JSON.stringify(ev.errors));++ const pv = validate('network.presence.changed@1', e.payload);++ if (!pv.valid) console.error('payload:', JSON.stringify(e.payload), JSON.stringify(pv.errors));++ assert.ok(pv.valid, JSON.stringify(pv.errors));++ assert.deepStrictEqual([e.source, e.visibility, e.subject], ['network', 'internal', { type: 'user', id: e.payload.subject }]);++ assert.ok(!('invisible' === e.payload.state));++ return true;++ };++++ // ── The people who may see: self, a follow, a DM peer; a stranger never ──++ for (const id of [1, 2, 3, 4]) { const hb = await heartbeat(id); assert.strictEqual(hb.status, 200, `heartbeat ${id}: ${JSON.stringify(hb.body)}`); }++ let r = await get(1, [ANN, BOB, CAT, DAN]);++ assert.strictEqual(r.status, 200);++ let seen = bySubject(r.body);++ assert.deepStrictEqual(Object.keys(seen).sort(), [ANN, BOB, CAT].sort(), 'self, follow and DM peer; never the stranger');++ assert.strictEqual(seen[BOB].state, 'online');++ assert.strictEqual(seen[BOB].live, true, 'Live makes live true');++ assert.strictEqual(seen[CAT].state, 'online');++ assert.ok(r.cache.includes('private'), 'private cache');++ assert.ok((await events()).every(validEvent), 'every event validates');++ assert.strictEqual(dmCalls.at(-1).subject, ANN, 'the DM half is read as Ann');++ assert.ok(dmCalls.at(-1).candidates.includes(CAT), 'Chat is asked about the DM candidate');++ r = await get(4, [ANN, BOB, CAT]);++ assert.deepStrictEqual(r.body.presence, [], 'Dan follows and DMs nobody');++++ // ── Away after 10 minutes without input, offline after 3 minutes without a heartbeat ──++ NOW = T0 + 10 * MIN + 1000;++ assert.strictEqual((await heartbeat(2, { idle: true })).status, 200);++ const bobRow = await db.prepare('SELECT last_active_at FROM person_presence WHERE subject_id = ?').get(BOB);++ assert.strictEqual(bobRow.last_active_at, new Date(T0).toISOString(), 'an idle heartbeat leaves last_active_at alone');++ seen = bySubject((await get(1, [BOB])).body);++ assert.strictEqual(seen[BOB].state, 'away');++ NOW = T0 + 12 * MIN + 2000;++ seen = bySubject((await get(1, [BOB])).body);++ assert.strictEqual(seen[BOB].state, 'away', 'two minutes without a heartbeat is fading, not gone');++ NOW = T0 + 13 * MIN + 2000;++ seen = bySubject((await get(1, [BOB])).body);++ assert.strictEqual(seen[BOB].state, 'offline', 'three minutes without a heartbeat');++ NOW = T0 + 14 * MIN;++ await heartbeat(2);++ seen = bySubject((await get(1, [BOB])).body);++ assert.strictEqual(seen[BOB].state, 'online', 'a fresh heartbeat comes back');++++ // ── Invisible: absent for everyone else, visible as invisible to itself, event says offline ──++ NOW = T0 + 15 * MIN;++ await heartbeat(3);++ r = await call('PUT', '/api/v1/me/presence', { body: { state: 'invisible', status_text: 'Hidden', emoji: '🫥' }, headers: await as(3) });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.state, 'invisible', 'the owner still sees the chosen state');++ assert.strictEqual(r.body.status_text, 'Hidden');++ seen = bySubject((await get(1, [CAT])).body);++ assert.deepStrictEqual(seen, {}, 'invisible is not disclosed to a DM peer');++ seen = bySubject((await get(3, [CAT])).body);++ assert.strictEqual(seen[CAT].state, 'invisible');++ const catEvents = await events(CAT);++ const invisible = catEvents[catEvents.length - 1];++ assert.strictEqual(invisible.payload.state, 'offline');++ assert.strictEqual(invisible.payload.status_text, null);++ assert.strictEqual(invisible.payload.emoji, null);++ validEvent(invisible);++++ // ── show_status off reads as offline too, but invisible already hides the row ──++ await call('PUT', '/api/v1/me/presence', { body: { state: 'auto', show_status: false, status_text: 'Off' }, headers: await as(2) });++ seen = bySubject((await get(1, [BOB])).body);++ assert.deepStrictEqual(seen, {}, 'show_status off is not disclosed');++ await call('PUT', '/api/v1/me/presence', { body: { show_status: true }, headers: await as(2) });++++ // ── The event is debounced: a change inside 30 s waits, the flusher sends the standing view ──++ NOW = T0 + 15 * MIN;++ await heartbeat(1);++ const before = (await events(ANN)).length;++ r = await call('PUT', '/api/v1/me/presence', { body: { state: 'dnd', status_text: 'Heads down', emoji: '🔕' }, headers: await as(1) });++ assert.strictEqual(r.body.state, 'dnd');++ assert.strictEqual((await events(ANN)).length, before + 1, 'the first change outside the window is announced at once');++ const changedAt = NOW + 1000;++ NOW = changedAt;++ r = await call('PUT', '/api/v1/me/presence', { body: { status_text: 'Back to it' }, headers: await as(1) });++ assert.strictEqual(r.body.status_text, 'Back to it');++ assert.strictEqual((await events(ANN)).length, before + 1, 'inside the debounce window: no event yet');++ await presence.flushDue(changedAt + 29 * 1000);++ assert.strictEqual((await events(ANN)).length, before + 1, 'the window has not opened for this change yet');++ NOW = changedAt + 31 * 1000;++ await presence.flushDue(NOW);++ const annEvents = await events(ANN);++ assert.strictEqual(annEvents.length, before + 2, 'the flusher announces the change');++ assert.deepStrictEqual([annEvents.at(-1).payload.state, annEvents.at(-1).payload.status_text, annEvents.at(-1).payload.emoji], ['dnd', 'Back to it', '🔕']);++ validEvent(annEvents.at(-1));++ const afterFlush = (await events(ANN)).length;++ await presence.flushDue(NOW + 60 * 1000);++ assert.strictEqual((await events(ANN)).length, afterFlush, 'nothing pending is sent twice');++++ // ── Validation: the chosen state, one emoji, 80 characters, known fields ──++ const bad = [++ [{}, 'presence.empty'], [{ state: 'online' }, 'presence.bad_state'], [{ status_text: 'x'.repeat(81) }, 'presence.status_too_long'],++ [{ emoji: '😀😀' }, 'presence.one_emoji'], [{ show_status: 'yes' }, 'presence.bad_show_status'], [{ last_seen: 'x' }, 'presence.unknown_field'],++ ];++ for (const [body, code] of bad) {++ r = await call('PUT', '/api/v1/me/presence', { body, headers: await as(1) });++ assert.strictEqual(r.status, 400, JSON.stringify(body));++ assert.strictEqual(r.body.error, code);++ }++ r = await call('PUT', '/api/v1/me/presence', { body: { state: 'auto' } });++ assert.strictEqual(r.status, 401, 'signed in only');++++ // ── The internal read needs network.presence.read and answers for one person ──++ r = await call('GET', `/internal/presence?for=${ANN}&subjects=${BOB},${CAT},${DAN}`, { headers: { authorization: `Bearer ${serviceToken()}` } });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(Object.keys(bySubject(r.body)).sort(), [BOB].sort(), 'Chat gets only what Ann may see (Cat is invisible)');++ r = await call('GET', `/internal/presence?for=${ANN}&subjects=${BOB}`, { headers: { authorization: `Bearer ${serviceToken('network.blocks.read')}` } });++ assert.strictEqual(r.status, 403, 'another capability is refused');++ r = await call('GET', `/internal/presence?for=ann&subjects=${BOB}`, { headers: { authorization: `Bearer ${serviceToken()}` } });++ assert.strictEqual(r.status, 400);++++ // ── A malformed or oversized subject list is refused ──++ r = await get(1, ['ann']);++ assert.strictEqual(r.status, 400);++ r = await call('GET', `/api/v1/presence?subjects=${Array.from({ length: 101 }, (_, i) => BOB.replace(/.$/, String.fromCharCode(65 + (i % 26)))).join(',')}`, { headers: await as(1) });++ assert.strictEqual(r.status, 400);++++ console.log('presence: all checks passed');++})().finally(async () => {++ await new Promise((r) => server.close(r));++ await new Promise((r) => edgeServer.close(r));++}).catch((err) => { console.error(err); process.exit(1); });++})().catch((err) => { console.error(err); process.exit(1); });@@@ -140,6 +140,7 @@ const REVIEWED = {'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],'server/node/release.js fetch': [1, 'the Node\'s own release page: a fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Node/releases/latest), 8 s timeout, held 10 minutes; no request value ever names a host and it only ever reads the public release list'],'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],++ 'server/presence/index.js fetch': [2, "Live and Chat internal URLs from operator config (loopback defaults): Live's public /api/streams (fixed path, 2 s timeout, held 30 s) and Chat's POST /internal/chat/dm/peers (fixed path, 1.5 s, the subject list in the body); never a request value names a host"],'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],};@@@ -52,7 +52,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']]);++ 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']]);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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -28,6 +28,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -28,6 +28,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- **Public profiles** — `/@<username>`: one page per person with their picture, bio, when they joined, what they wear and the items they own in [OpenVibe.Inventory](https://inventory.openvibe.network) (plan T21). The same data is at `GET /api/v1/profiles/:username`, open to every origin. An old username answers 301 to the current one. A person can hide their profile from Edit Profile (`users.profile_public`); a hidden one shows only the name and picture and is not indexed. Items are read with the Network's own service token and kept 60 s per person (`server/profile/public.js`).
- **Public profiles** — `/@<username>`: one page per person with their picture, bio, when they joined, what they wear and the items they own in [OpenVibe.Inventory](https://inventory.openvibe.network) (plan T21). The same data is at `GET /api/v1/profiles/:username`, open to every origin. An old username answers 301 to the current one. A person can hide their profile from Edit Profile (`users.profile_public`); a hidden one shows only the name and picture and is not indexed. Items are read with the Network's own service token and kept 60 s per person (`server/profile/public.js`).
- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.
- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.
- **User modules** — versioned per-person preferences and summaries in namespaces owned by services (openvibe-contracts `manifests/namespaces`); see [User modules](#user-modules).
- **User modules** — versioned per-person preferences and summaries in namespaces owned by services (openvibe-contracts `manifests/namespaces`); see [User modules](#user-modules).
+- **Online status** — each person has a presence of online, away, do not disturb or invisible, with an optional status line and one emoji, carried across every site. `POST /api/v1/me/presence/heartbeat` keeps it fresh (the shared navbar sends it every 60 s while the page is visible), `PUT /api/v1/me/presence` sets the chosen state, and `GET /api/v1/presence?subjects=…` answers only for people the caller follows or has a DM with (and themselves), never for a stranger and never for an invisible person; last seen is never returned. `network.presence@1` and `network.presence.changed@1`, contracts 0.318.0, `server/presence/`. Chat reads it through `GET /internal/presence` (`network.presence.read`) and asks back for the DM half of the rule (`chat.dm.peers.read`). Live streaming makes `live: true`. Every row is in the person's export and erased with the account (ADR-033).
- **The Dock's state** — the little floating surface every OpenVibe site draws (your agent, your chats, your activity) follows the person across every domain, because `localStorage` is per site and the state lives here instead. `GET /api/v1/me/dock` and `PUT /api/v1/me/dock` (`network.dock-state@1`, `network.dock-result@1`, contracts 0.251.0; `server/dock/`). See [docs/dock.md](docs/dock.md).
- **The Dock's state** — the little floating surface every OpenVibe site draws (your agent, your chats, your activity) follows the person across every domain, because `localStorage` is per site and the state lives here instead. `GET /api/v1/me/dock` and `PUT /api/v1/me/dock` (`network.dock-state@1`, `network.dock-result@1`, contracts 0.251.0; `server/dock/`). See [docs/dock.md](docs/dock.md).
## Your account ID
## Your account ID
@@ -107,7 +108,7 @@ person's username and picture (a service token, `identity.subject.resolve`).
@@ -107,7 +108,7 @@ person's username and picture (a service token, `identity.subject.resolve`).
Implemented here (the service manifest's `capabilities`, audience `openvibe.network`):
Implemented here (the service manifest's `capabilities`, audience `openvibe.network`):
`identity.subject.resolve`, `mods.grant.manage`, `network.account.deletion.confirm`,
`identity.subject.resolve`, `mods.grant.manage`, `network.account.deletion.confirm`,
`network.account.export.contribute`, `network.analytics.creator.read`, `network.blocks.read`,
`network.account.export.contribute`, `network.analytics.creator.read`, `network.blocks.read`,
-`network.coins.credit|debit|transfer`, `network.follows.read|write`, `network.integration.github.read`,
+`network.coins.credit|debit|transfer`, `network.follows.read|write`, `network.presence.read`, `network.integration.github.read`,
`network.modules.read|write`, `network.notifications.push`, `network.operator.alert`,
`network.modules.read|write`, `network.notifications.push`, `network.operator.alert`,
`network.project.manage|read`, `network.secret.use`, `network.staff.read` and `network.status.incident`. The grants other
`network.project.manage|read`, `network.secret.use`, `network.staff.read` and `network.status.incident`. The grants other
services hold are seeded in [server/identity/principals.js](server/identity/principals.js).
services hold are seeded in [server/identity/principals.js](server/identity/principals.js).
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+-- phase: expand
+-- Presence (plan 2026-10-22 track X item 9): one person's chosen online status, the heartbeat that says
+-- they are around, and the public view last announced to OpenVibe.Events. Network owns it; Chat and the
+-- navbar read it through GET /api/v1/presence (or GET /internal/presence with network.presence.read).
+--
+-- The derived state is not stored: online while last_heartbeat_at is under 2 minutes old, away after 10
+-- minutes without input (last_active_at; an idle heartbeat leaves it alone), offline after 3 minutes
+-- without a heartbeat. state is the person's chosen state; invisible and show_status = 0 read as offline.
+-- announced_* is the public view in the last network.presence.changed event, so a change inside the 30 s
+-- debounce sets pending_since and the flusher sends the view that stands when the window opens.
+CREATE TABLE IF NOT EXISTS person_presence (
+ subject_id text COLLATE "C" PRIMARY KEY REFERENCES users(subject_id) ON DELETE CASCADE,
+ state text COLLATE "C" NOT NULL DEFAULT 'auto' CHECK (state IN ('auto', 'dnd', 'invisible')),
+ status_text text COLLATE "C",
+ emoji text COLLATE "C",
+ show_status bigint NOT NULL DEFAULT 1 CHECK (show_status IN (0, 1)),
+ last_active_at text COLLATE "C",
+ last_heartbeat_at text COLLATE "C",
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ announced_state text COLLATE "C",
+ announced_status_text text COLLATE "C",
+ announced_emoji text COLLATE "C",
+ announced_live bigint,
+ announced_at text COLLATE "C",
+ pending_since text COLLATE "C"
+);
+
+CREATE INDEX IF NOT EXISTS person_presence_pending ON person_presence(pending_since) WHERE pending_since IS NOT NULL;
@@ -204,6 +204,9 @@ async function networkPart(db, userId) {
@@ -204,6 +204,9 @@ 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);
+ // 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);
add('developer_projects.json', {
add('developer_projects.json', {
owned: (await rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id])).rows,
owned: (await rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id])).rows,
memberships: (await rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] })).rows,
memberships: (await rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] })).rows,
@@ -351,6 +354,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -351,6 +354,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
if (await tableExists(db, 'analytics_events')) await db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);
if (await tableExists(db, 'analytics_events')) await db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);
await del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');
await del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');
+ await del('person_presence', `subject_id IN ${inS}`, subjects, 'presence');
// The consent registry (L24): the account's active choices are revoked (and its aliases' too), but the rows
// The consent registry (L24): the account's active choices are revoked (and its aliases' too), but the rows
// stay as proof of what was allowed and when; they hold no content. The events go out after this commit.
// stay as proof of what was allowed and when; they hold no content. The events go out after this commit.
consentEvents = await require('../consent').revokeForDeletion(db, subjects, { at });
consentEvents = await require('../consent').revokeForDeletion(db, subjects, { at });
@@ -82,6 +82,9 @@ const DEFAULT_GRANTS = [
@@ -82,6 +82,9 @@ const DEFAULT_GRANTS = [
['live', 'network.modules.write', SELF_AUDIENCE, ['live.profile', 'live.stats', 'live.loyalty']],
['live', 'network.modules.write', SELF_AUDIENCE, ['live.profile', 'live.stats', 'live.loyalty']],
['chat', 'network.modules.read', SELF_AUDIENCE, CHAT_NAMESPACES],
['chat', 'network.modules.read', SELF_AUDIENCE, CHAT_NAMESPACES],
['chat', 'network.modules.write', SELF_AUDIENCE, CHAT_NAMESPACES],
['chat', 'network.modules.write', SELF_AUDIENCE, CHAT_NAMESPACES],
+ // Presence (plan 2026-10-22 track X item 9): Chat draws the dot in its DMs and member lists. The API enforces
+ // the follows-or-DM rule for the person it is asked about; Chat never sees a stranger's row.
+ ['chat', 'network.presence.read', SELF_AUDIENCE, []],
// Chat's chat-app bots (plan A4) resolve a Telegram/Discord/Slack account to its linked OpenVibe
// Chat's chat-app bots (plan A4) resolve a Telegram/Discord/Slack account to its linked OpenVibe
// subject, and register/confirm/unlink the one-time links. Only svc:chat may reach those routes;
// subject, and register/confirm/unlink the one-time links. Only svc:chat may reach those routes;
// the grant is what its service token is minted with.
// the grant is what its service token is minted with.
@@ -620,6 +620,18 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
@@ -620,6 +620,18 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.
// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.
require('./identity/follows').setNotifier(require('./notifications/follow-notify').followNotifier(notificationService));
require('./identity/follows').setNotifier(require('./notifications/follow-notify').followNotifier(notificationService));
}
}
+// Presence (plan 2026-10-22 track X item 9; Contracts 0.318.0): the person's chosen online status, the
+// heartbeat from any open tab and the public view their followers and DM peers may read. Chat and the
+// navbar read /api/v1/presence; Chat may read /internal/presence with network.presence.read.
+{
+ const presence = require('./presence').createPresence({
+ db, requireAuth, guard: require('./identity/principals').guard, selfToken, config,
+ });
+ app.use('/api/v1/me/presence', rateLimit({ windowMs: 60_000, max: 240 }), requireAuth, presence.me);
+ app.use('/api/v1/presence', rateLimit({ windowMs: 60_000, max: 600 }), requireAuth, presence.pub);
+ app.use('/internal/presence', require('./identity/principals').guard('network.presence.read'), presence.internal);
+ presence.start();
+}
// Realtime tickets (WS-E task 3, WS-F task 1; ADR-005 amendment 2): the notification badge on any site opens
// Realtime tickets (WS-E task 3, WS-F task 1; ADR-005 amendment 2): the notification badge on any site opens
// OpenVibe.Events' /realtime/stream as the signed-in person with a two-minute, single-use ticket
// OpenVibe.Events' /realtime/stream as the signed-in person with a two-minute, single-use ticket
// (server/auth/realtime-ticket.js). REALTIME_TICKETS=off answers 503 and every badge stays on polling.
// (server/auth/realtime-ticket.js). REALTIME_TICKETS=off answers 503 and every badge stays on polling.
new file mode 100644
new file mode 100644
@@ -0,0 +1,409 @@
@@ -0,0 +1,409 @@
+'use strict';
+/**
+ * A person's presence (plan 2026-10-22 track X item 9; Contracts 0.318.0 network.presence@1,
+ * network.presence-update-request@1 and network.presence.changed@1).
+ *
+ * OpenVibe.Network owns the chosen online status. A heartbeat from any open tab keeps it fresh:
+ *
+ * POST /api/v1/me/presence/heartbeat { idle?: true } any signed-in person, at most once a minute
+ * PUT /api/v1/me/presence network.presence-update-request@1
+ * GET /api/v1/presence?subjects=… the people the caller follows or has a DM with, and themselves
+ * GET /internal/presence?for=usr_…&subjects=… the same for a first-party service
+ * (network.presence.read)
+ *
+ * Derived state: online while the newest heartbeat is under 2 minutes old; away after 10 minutes without
+ * input (the client's idle heartbeat leaves last_active_at alone); offline after 3 minutes without a
+ * heartbeat. invisible, or the "Show my status" switch off, reads as offline to everyone; an invisible
+ * person is left out of another person's answer entirely. Last seen is never returned.
+ *
+ * live is true while the person is streaming on OpenVibe.Live. The live list is Live's public
+ * GET /api/streams over loopback, cached 30 s, and mapped to Network subjects through
+ * identity_legacy_map (source_system 'live'). Live outranks the chosen state; it is false while hidden.
+ *
+ * network.presence.changed is written into network_event_outbox in the same transaction as the row, only
+ * for a real change of the public view, at most once per person per 30 s. A change inside the window sets
+ * pending_since; flushDue() sends the view that stands when the window opens, so a tab that closes right
+ * after a change still announces it. The payload is always the public view: invisible is offline with no
+ * text or emoji.
+ *
+ * The DM part of the visibility rule is read from OpenVibe.Chat (POST /internal/chat/dm/peers,
+ * chat.dm.peers.read), cached here for 30 s; when Chat does not answer, only follows count. The follow
+ * part is Network's own user_follows ('channel' targets are the owner's subject).
+ *
+ * Tests: test/presence.test.js.
+ */
+const { ids, validate } = require('openvibe-contracts');
+const eventRelay = require('../developer/event-relay');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const HEARTBEAT_ONLINE_MS = 2 * 60 * 1000;
+const OFFLINE_MS = 3 * 60 * 1000;
+const AWAY_MS = 10 * 60 * 1000;
+const DEBOUNCE_MS = 30 * 1000;
+const LIVE_TTL_MS = 30 * 1000;
+const DM_TTL_MS = 30 * 1000;
+const MAX_SUBJECTS = 100;
+const MAX_STATUS = 80;
+const MAX_EMOJI = 16;
+
+class PresenceError extends Error {
+ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
+}
+
+const iso = (ms) => new Date(ms).toISOString();
+const msOf = (t) => { const n = Date.parse(String(t || '')); return Number.isFinite(n) ? n : null; };
+
+/** Plain single-line text: control characters and angle brackets never reach a status line. */
+function cleanText(value) {
+ if (value == null) return null;
+ if (typeof value !== 'string') throw new PresenceError(400, 'presence.bad_status', 'status_text must be text or null');
+ const s = value.replace(/[\u0000-\u001f\u007f<>]/g, ' ').replace(/\s+/g, ' ').trim();
+ if (!s) return null;
+ if ([...s].length > MAX_STATUS) throw new PresenceError(400, 'presence.status_too_long', `a status line is at most ${MAX_STATUS} characters`);
+ return s;
+}
+
+/** One emoji: one grapheme cluster, at most 16 code points (a family or a flag is one cluster). */
+function cleanEmoji(value) {
+ if (value == null) return null;
+ if (typeof value !== 'string') throw new PresenceError(400, 'presence.bad_emoji', 'emoji must be text or null');
+ const s = value.trim();
+ if (!s) return null;
+ if ([...s].length > MAX_EMOJI) throw new PresenceError(400, 'presence.emoji_too_long', 'emoji must be one emoji');
+ const count = [...new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(s)].length;
+ if (count !== 1) throw new PresenceError(400, 'presence.one_emoji', 'emoji must be one emoji');
+ return s;
+}
+
+/** The public view of one row, or null when there is no row. `self` keeps the chosen hidden state for its owner. */
+function publicView(row, { nowMs, streaming = false, self = false } = {}) {
+ if (!row) return null;
+ const hidden = !Number(row.show_status) || row.state === 'invisible';
+ const lastBeat = msOf(row.last_heartbeat_at);
+ const lastActive = msOf(row.last_active_at) ?? lastBeat;
+ let state;
+ // The owner's chosen DND is theirs whatever the heartbeat says (Chat reads it to mute their pings);
+ // everyone else sees DND only while the heartbeat is fresh, and offline after it.
+ if (self && !hidden && row.state === 'dnd') state = 'dnd';
+ else if (hidden) state = self ? (row.state === 'invisible' ? 'invisible' : 'offline') : 'offline';
+ else if (lastBeat == null || nowMs - lastBeat > OFFLINE_MS || lastBeat - nowMs > 60_000) state = 'offline';
+ else if (row.state === 'dnd') state = 'dnd';
+ // A heartbeat two minutes old still counts, but the connection is fading: away until the three-minute
+ // cutoff makes it offline, or the next heartbeat makes it online again.
+ else if (nowMs - lastBeat > HEARTBEAT_ONLINE_MS) state = 'away';
+ else if (lastActive == null || nowMs - lastActive > AWAY_MS) state = 'away';
+ else state = 'online';
+ const visible = !hidden && state !== 'offline';
+ return {
+ subject: row.subject_id,
+ state,
+ status_text: visible || self ? (row.status_text || null) : null,
+ emoji: visible || self ? (row.emoji || null) : null,
+ live: !!(visible && streaming),
+ updated_at: row.updated_at,
+ };
+}
+
+/** Compare the public view with the last announced one (announced_at is not part of the view). */
+function sameView(row, view) {
+ return row.announced_state === view.state
+ && (row.announced_status_text || null) === view.status_text
+ && (row.announced_emoji || null) === view.emoji
+ && Boolean(Number(row.announced_live)) === view.live;
+}
+
+function buildEnvelope(view, nowMs) {
+ const env = {
+ event_id: ids.newId('event', nowMs), event_type: 'network.presence.changed', version: 1, source: 'network',
+ actor: { type: 'user', id: view.subject }, timestamp: iso(nowMs), visibility: 'internal',
+ subject: { type: 'user', id: view.subject }, payload: view,
+ };
+ const v = validate('events.event-envelope@1', env);
+ const pv = validate('network.presence.changed@1', view);
+ if (!v.valid || !pv.valid) throw new Error(`presence: bad event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
+ return env;
+}
+
+/** The announced fields for a public view. */
+const announcedParams = (view) => [view.state, view.status_text, view.emoji, view.live ? 1 : 0];
+
+/**
+ * Record the public view, and announce it if it is a real change outside the debounce window.
+ * Runs inside the caller's transaction. `row` is the row after the write.
+ */
+async function announce(tx, row, view, nowMs) {
+ const at = iso(nowMs);
+ const hadAnnouncement = row.announced_state != null;
+ if (hadAnnouncement && sameView(row, view)) {
+ if (row.pending_since) await tx.prepare('UPDATE person_presence SET pending_since = NULL WHERE subject_id = ?').run(row.subject_id);
+ return false;
+ }
+ // The first write of someone who is already offline is not a change anyone can see; remember it quietly
+ // so their first online/away/dnd transition announces at once.
+ if (!hadAnnouncement && view.state === 'offline') {
+ await tx.prepare(`UPDATE person_presence SET announced_state = ?, announced_status_text = ?, announced_emoji = ?, announced_live = ?, pending_since = NULL WHERE subject_id = ?`)
+ .run(...announcedParams(view), row.subject_id);
+ return false;
+ }
+ const lastAt = msOf(row.announced_at);
+ if (hadAnnouncement && lastAt != null && nowMs - lastAt < DEBOUNCE_MS) {
+ await tx.prepare('UPDATE person_presence SET pending_since = COALESCE(pending_since, ?) WHERE subject_id = ?').run(at, row.subject_id);
+ return false;
+ }
+ await eventRelay.writerFor(tx).enqueue(buildEnvelope(view, nowMs));
+ await tx.prepare(`UPDATE person_presence SET announced_state = ?, announced_status_text = ?, announced_emoji = ?, announced_live = ?, announced_at = ?, pending_since = NULL WHERE subject_id = ?`)
+ .run(...announcedParams(view), at, row.subject_id);
+ return true;
+}
+
+/** Read one row and its public view. */
+async function viewOf(db, subject, { nowMs, streaming = false, self = false } = {}) {
+ const row = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);
+ return { row, view: publicView(row, { nowMs, streaming, self }) };
+}
+
+/** A heartbeat: fresh last_heartbeat_at; last_active_at moves only when the client is not idle. */
+async function heartbeat(db, subject, { idle = false, nowMs = Date.now(), streaming = false } = {}) {
+ if (!SUBJECT_RE.test(String(subject || ''))) throw new PresenceError(400, 'presence.bad_subject', 'not a person subject');
+ const at = iso(nowMs);
+ let view = null;
+ await db.tx(async () => {
+ const before = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);
+ if (!before) {
+ await db.prepare(`INSERT INTO person_presence (subject_id, state, show_status, last_active_at, last_heartbeat_at, updated_at)
+ VALUES (?, 'auto', 1, ?, ?, ?)`).run(subject, at, at, at);
+ } else {
+ await db.prepare(`UPDATE person_presence SET last_heartbeat_at = ?, last_active_at = CASE WHEN ? THEN last_active_at ELSE ? END, updated_at = ? WHERE subject_id = ?`)
+ .run(at, idle ? 1 : 0, at, at, subject);
+ }
+ const row = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);
+ view = publicView(row, { nowMs, self: false, streaming });
+ await announce(db, row, view, nowMs);
+ view = publicView(row, { nowMs, self: true, streaming });
+ });
+ return view;
+}
+
+/** PUT /api/v1/me/presence: only the fields sent change. The row is created on a first write. */
+async function update(db, subject, body, { nowMs = Date.now(), streaming = false } = {}) {
+ if (!SUBJECT_RE.test(String(subject || ''))) throw new PresenceError(401, 'token.missing', 'a signed-in person is required');
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new PresenceError(400, 'presence.bad_body', 'a JSON object is required');
+ const allowed = new Set(['state', 'status_text', 'emoji', 'show_status']);
+ for (const k of Object.keys(body)) if (!allowed.has(k)) throw new PresenceError(400, 'presence.unknown_field', `unknown field ${k}`);
+ if (!Object.keys(body).length) throw new PresenceError(400, 'presence.empty', 'send at least one field');
+ if (body.state !== undefined && !['auto', 'dnd', 'invisible'].includes(body.state)) throw new PresenceError(400, 'presence.bad_state', 'state must be auto, dnd or invisible');
+ if (body.status_text !== undefined) cleanText(body.status_text);
+ if (body.emoji !== undefined) cleanEmoji(body.emoji);
+ if (body.show_status !== undefined && typeof body.show_status !== 'boolean') throw new PresenceError(400, 'presence.bad_show_status', 'show_status must be true or false');
+
+ const at = iso(nowMs);
+ let view = null;
+ await db.tx(async () => {
+ const before = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);
+ if (!before) {
+ await db.prepare(`INSERT INTO person_presence (subject_id, state, status_text, emoji, show_status, last_active_at, last_heartbeat_at, updated_at)
+ VALUES (?, 'auto', NULL, NULL, 1, ?, NULL, ?)`).run(subject, at, at);
+ }
+ const row = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);
+ const next = {
+ state: body.state !== undefined ? body.state : row.state,
+ status_text: body.status_text !== undefined ? cleanText(body.status_text) : row.status_text,
+ emoji: body.emoji !== undefined ? cleanEmoji(body.emoji) : row.emoji,
+ show_status: body.show_status !== undefined ? (body.show_status ? 1 : 0) : Number(row.show_status),
+ };
+ await db.prepare('UPDATE person_presence SET state = ?, status_text = ?, emoji = ?, show_status = ?, updated_at = ? WHERE subject_id = ?')
+ .run(next.state, next.status_text, next.emoji, next.show_status, at, subject);
+ const after = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(subject);
+ await announce(db, after, publicView(after, { nowMs, self: false, streaming }), nowMs);
+ view = publicView(after, { nowMs, self: true, streaming });
+ });
+ return view;
+}
+
+/** The public view of `subjects` for `viewer`: themselves, people they follow, and DM peers only. */
+async function visiblePresence(db, viewer, subjects, { nowMs = Date.now(), streaming = new Set(), dmPeers } = {}) {
+ const self = subjects.includes(viewer);
+ const others = subjects.filter((s) => s !== viewer);
+ if (!others.length) {
+ const own = await viewOf(db, viewer, { nowMs, streaming: streaming.has(viewer), self: true });
+ return own.view ? [own.view] : [];
+ }
+ const followed = new Set();
+ const rows = await db.prepare(`SELECT target_id FROM user_follows WHERE follower_subject = ? AND target_type = 'channel' AND active = 1 AND target_id = ANY(?)`)
+ .all(viewer, others);
+ for (const r of rows) followed.add(r.target_id);
+ // Only the people a follow does not already cover need Chat's DM answer.
+ const dmCandidates = others.filter((s) => !followed.has(s));
+ const peers = dmPeers && dmCandidates.length ? await dmPeers(viewer, dmCandidates) : new Set();
+ const allowed = others.filter((s) => followed.has(s) || peers.has(s));
+ const out = [];
+ if (self) {
+ const own = await viewOf(db, viewer, { nowMs, streaming: streaming.has(viewer), self: true });
+ if (own.view) out.push(own.view);
+ }
+ if (allowed.length) {
+ const found = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ANY(?)').all(allowed);
+ for (const row of found) {
+ // An invisible person (or one with show_status off) is never disclosed, not even as offline.
+ if (!Number(row.show_status) || row.state === 'invisible') continue;
+ out.push(publicView(row, { nowMs, streaming: streaming.has(row.subject_id), self: false }));
+ }
+ }
+ return out;
+}
+
+/** "usr_…,usr_…" or repeated ?subjects=; at most 100, deduplicated. */
+function parseSubjects(input) {
+ const raw = Array.isArray(input) ? input.join(',') : String(input == null ? '' : input);
+ const list = raw.split(',').map((s) => s.trim()).filter(Boolean);
+ if (list.length > MAX_SUBJECTS) throw new PresenceError(400, 'presence.too_many', `at most ${MAX_SUBJECTS} subjects`);
+ for (const s of list) if (!SUBJECT_RE.test(s)) throw new PresenceError(400, 'presence.bad_subject', `${s.slice(0, 40)} is not a person subject`);
+ return [...new Set(list)];
+}
+
+/** Default live set: Live's public list over loopback, mapped to Network subjects, cached 30 s. */
+function createLiveSource({ db, config, fetchImpl, now }) {
+ let cache = { at: 0, value: new Set() };
+ return async function streamingSubjects() {
+ const t = now();
+ if (t - cache.at < LIVE_TTL_MS) return cache.value;
+ const base = String((config.services && config.services.live && config.services.live.internalUrl) || 'http://127.0.0.1:3000').replace(/\/+$/, '');
+ try {
+ const res = await fetchImpl(`${base}/api/streams`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(2000) });
+ if (!res.ok) return cache.value;
+ const body = await res.json();
+ const liveIds = [...new Set((Array.isArray(body.streams) ? body.streams : []).filter((s) => s && s.is_live && s.user_id != null).map((s) => String(s.user_id)))];
+ const subjects = liveIds.length
+ ? new Set((await db.prepare(`SELECT subject_id FROM identity_legacy_map WHERE source_system = 'live' AND source_type = 'user' AND source_id = ANY(?)`).all(liveIds)).map((r) => r.subject_id))
+ : new Set();
+ cache = { at: t, value: subjects };
+ return subjects;
+ } catch {
+ // A Live that does not answer leaves the last good set in place; stale for 30 s at most is no leak.
+ return cache.value;
+ }
+ };
+}
+
+/** Default DM peers: ask Chat which candidates share a conversation with the viewer, cached 30 s. */
+function createDmPeers({ selfToken, config, fetchImpl, now }) {
+ const cache = new Map();
+ return async function dmPeers(viewer, candidates) {
+ const key = `${viewer}|${candidates.slice().sort().join(',')}`;
+ const hit = cache.get(key);
+ if (hit && now() - hit.at < DM_TTL_MS) return hit.value;
+ const value = new Set();
+ try {
+ const token = selfToken && selfToken('openvibe.chat', ['chat.dm.peers.read']);
+ if (token) {
+ const base = String((config.services && config.services.chat && config.services.chat.internalUrl) || 'http://127.0.0.1:4400').replace(/\/+$/, '');
+ const res = await fetchImpl(`${base}/internal/chat/dm/peers`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ subject: viewer, candidates }),
+ signal: AbortSignal.timeout(1500),
+ });
+ if (res.ok) {
+ const body = await res.json().catch(() => ({}));
+ for (const p of Array.isArray(body.peers) ? body.peers : []) if (candidates.includes(p)) value.add(p);
+ }
+ }
+ } catch { /* Chat down: follows only, never a stranger */ }
+ if (cache.size > 1000) cache.clear();
+ cache.set(key, { at: now(), value });
+ return value;
+ };
+}
+
+/**
+ * The presence routers and its flusher.
+ * me POST /heartbeat, PUT / (mount after requireAuth)
+ * pub GET / (mount after requireAuth)
+ * internal GET / (mount after guard('network.presence.read'))
+ */
+function createPresence({ db, requireAuth, guard, selfToken, config = {}, fetchImpl = globalThis.fetch, now = Date.now, log = console }) {
+ const streamingSubjects = createLiveSource({ db, config, fetchImpl, now });
+ const dmPeers = createDmPeers({ selfToken, config, fetchImpl, now });
+
+ async function flushDue(nowMs = now()) {
+ const due = await db.prepare("SELECT * FROM person_presence WHERE pending_since IS NOT NULL AND pending_since <= ? ORDER BY pending_since LIMIT 200")
+ .all(iso(nowMs - DEBOUNCE_MS));
+ if (!due.length) return 0;
+ const streaming = await streamingSubjects();
+ let sent = 0;
+ for (const candidate of due) {
+ await db.tx(async () => {
+ const row = await db.prepare('SELECT * FROM person_presence WHERE subject_id = ?').get(candidate.subject_id);
+ if (!row || !row.pending_since) return;
+ const view = publicView(row, { nowMs, streaming: streaming.has(row.subject_id), self: false });
+ if (await announce(db, row, view, nowMs)) sent++;
+ });
+ }
+ return sent;
+ }
+
+ function start({ intervalMs = 15_000 } = {}) {
+ const timer = setInterval(() => flushDue().catch((err) => log.warn('[Presence] flush failed:', err.message)), intervalMs);
+ if (timer.unref) timer.unref();
+ return timer;
+ }
+
+ const me = require('express').Router();
+ me.post('/heartbeat', async (req, res) => {
+ if (!req.user || !req.user.subject_id) return res.status(401).json({ error: 'token.missing', detail: 'a signed-in person is required' });
+ const idle = !!(req.body && req.body.idle);
+ try {
+ res.set('Cache-Control', 'no-store');
+ const streaming = (await streamingSubjects()).has(req.user.subject_id);
+ return res.json(await heartbeat(db, req.user.subject_id, { idle, nowMs: now(), streaming }));
+ } catch (err) {
+ if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });
+ throw err;
+ }
+ });
+ me.put('/', async (req, res) => {
+ if (!req.user || !req.user.subject_id) return res.status(401).json({ error: 'token.missing', detail: 'a signed-in person is required' });
+ try {
+ res.set('Cache-Control', 'no-store');
+ const streaming = (await streamingSubjects()).has(req.user.subject_id);
+ return res.json(await update(db, req.user.subject_id, req.body, { nowMs: now(), streaming }));
+ } catch (err) {
+ if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });
+ throw err;
+ }
+ });
+
+ const pub = require('express').Router();
+ pub.get('/', async (req, res) => {
+ if (!req.user || !req.user.subject_id) return res.status(401).json({ error: 'token.missing', detail: 'a signed-in person is required' });
+ try {
+ const subjects = parseSubjects(req.query.subjects);
+ res.set('Cache-Control', 'private, max-age=15');
+ return res.json({ presence: await visiblePresence(db, req.user.subject_id, subjects, { nowMs: now(), streaming: await streamingSubjects(), dmPeers }) });
+ } catch (err) {
+ if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });
+ throw err;
+ }
+ });
+
+ const internal = require('express').Router();
+ internal.get('/', async (req, res) => {
+ const viewer = String(req.query.for || '');
+ if (!SUBJECT_RE.test(viewer)) return res.status(400).json({ error: 'presence.bad_for', detail: 'for must be a person subject' });
+ try {
+ const subjects = parseSubjects(req.query.subjects);
+ res.set('Cache-Control', 'private, max-age=15');
+ return res.json({ presence: await visiblePresence(db, viewer, subjects, { nowMs: now(), streaming: await streamingSubjects(), dmPeers }) });
+ } catch (err) {
+ if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });
+ throw err;
+ }
+ });
+
+ return { me, pub, internal, flushDue, start, streamingSubjects, dmPeers, heartbeat, update, visiblePresence, parseSubjects, publicView };
+}
+
+module.exports = {
+ createPresence, PresenceError, publicView, parseSubjects, heartbeat, update, visiblePresence,
+ SUBJECT_RE, HEARTBEAT_ONLINE_MS, OFFLINE_MS, AWAY_MS, DEBOUNCE_MS, MAX_SUBJECTS,
+};
@@ -90,6 +90,9 @@ const server = http.createServer(app);
@@ -90,6 +90,9 @@ const server = http.createServer(app);
await follows.setFollow(db, dana.subject_id, 'channel', x.subject_id, true, { emit: false });
await follows.setFollow(db, dana.subject_id, 'channel', x.subject_id, true, { emit: false });
await follows.setFollow(db, x.subject_id, 'channel', dana.subject_id, true, { emit: false });
await follows.setFollow(db, x.subject_id, 'channel', dana.subject_id, true, { emit: false });
await db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en')").run(dana.id);
await db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en')").run(dana.id);
+ // Presence (plan X9) is part of the person's export and is erased with the account.
+ await db.prepare(`INSERT INTO person_presence (subject_id, state, status_text, emoji, last_active_at, last_heartbeat_at, updated_at)
+ VALUES (?, 'dnd', 'Writing', '✍️', ov_now_iso(), ov_now_iso(), ov_now_iso())`).run(dana.subject_id);
// ── Export ──
// ── Export ──
const seeded = await accountData.expectedServices(db, 'network.account.export.contribute');
const seeded = await accountData.expectedServices(db, 'network.account.export.contribute');
@@ -159,6 +162,8 @@ const server = http.createServer(app);
@@ -159,6 +162,8 @@ const server = http.createServer(app);
for (const secret of ['password_hash', 'sess-secret-dana', 'rt-secret-dana', hash]) assert.ok(!all.includes(secret), `no ${secret} in the archive`);
for (const secret of ['password_hash', 'sess-secret-dana', 'rt-secret-dana', hash]) assert.ok(!all.includes(secret), `no ${secret} in the archive`);
assert.strictEqual(JSON.parse(files['network/opencoins.json']).balance, 40);
assert.strictEqual(JSON.parse(files['network/opencoins.json']).balance, 40);
assert.deepStrictEqual(JSON.parse(files['network/follows.json']).followers_count, 1, 'followers as a count, never their names');
assert.deepStrictEqual(JSON.parse(files['network/follows.json']).followers_count, 1, 'followers as a count, never their names');
+ assert.deepStrictEqual(JSON.parse(files['network/presence.json'])[0].state, 'dnd', 'presence is in the export');
+ assert.strictEqual(JSON.parse(files['network/presence.json'])[0].status_text, 'Writing');
assert.ok(!all.includes('[email protected]'), "nobody else's data");
assert.ok(!all.includes('[email protected]'), "nobody else's data");
r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(eve));
r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(eve));
assert.strictEqual(r.status, 404, 'another person cannot download it');
assert.strictEqual(r.status, 404, 'another person cannot download it');
@@ -244,6 +249,7 @@ const server = http.createServer(app);
@@ -244,6 +249,7 @@ const server = http.createServer(app);
assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id)).n, 0, `${t} erased`);
assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id)).n, 0, `${t} erased`);
}
}
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id)).n, 0);
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id)).n, 0);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM person_presence WHERE subject_id = ?').get(dana.subject_id)).n, 0, 'presence erased');
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? OR target_id = ?').get(dana.subject_id, dana.subject_id)).n, 0, 'follows both ways');
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? OR target_id = ?').get(dana.subject_id, dana.subject_id)).n, 0, 'follows both ways');
assert.strictEqual(wallet.getBalance ? await wallet.getBalance(db, dana.id) : (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(dana.id)).balance, 0);
assert.strictEqual(wallet.getBalance ? await wallet.getBalance(db, dana.id) : (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(dana.id)).balance, 0);
assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM coin_transactions WHERE user_id = ? AND reason = 'account_deleted'").get(dana.id)).n, 1, 'one closing entry; the ledger stays');
assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM coin_transactions WHERE user_id = ? AND reason = 'account_deleted'").get(dana.id)).n, 1, 'one closing entry; the ledger stays');
@@ -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']]);
+ 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']]);
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,224 @@
@@ -0,0 +1,224 @@
+'use strict';
+// Presence (plan 2026-10-22 track X item 9; Contracts 0.318.0): Network owns a person's chosen online status.
+// - who may see: themselves, people they follow, people they share a DM with; never a stranger;
+// - invisible and the "Show my status" switch are never disclosed to anyone else;
+// - online under 2 minutes after a heartbeat, away after 10 minutes without input, offline after 3
+// minutes without a heartbeat (a fake clock, and an idle heartbeat leaves last_active_at alone);
+// - a Live stream makes live true (Live's public list over loopback, cached);
+// - network.presence.changed is a real change, debounced 30 s, public view only;
+// - PUT validates state, status line, one emoji and show_status; GET /internal/presence needs
+// network.presence.read and answers for one person.
+// node test/presence.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const { validate, serviceAuth } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+
+const T0 = Date.parse('2026-11-07T09:00:00.000Z');
+const MIN = 60 * 1000;
+const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
+const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
+const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
+const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
+
+(async () => {
+const log = console.log; console.log = () => {};
+const db = getDb();
+console.log = log;
+await require('../server/identity/principals').ensureSchema(db);
+await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES
+ (1, 'ann', 'Ann', 'x', ?, 'user'), (2, 'bob', 'Bob', 'x', ?, 'streamer'), (3, 'cat', 'Cat', 'x', ?, 'user'), (4, 'dan', 'Dan', 'x', ?, 'user')`).run(ANN, BOB, CAT, DAN);
+// Ann follows Bob (the follow rule is Network's own graph; Chat supplies the DM rule below).
+await db.prepare(`INSERT INTO user_follows (follower_subject, target_type, target_id, active) VALUES (?, 'channel', ?, 1)`).run(ANN, BOB);
+// Live user 2 is Bob (identity_legacy_map is what Network uses to map Live's stream rows to subjects).
+await db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)
+ VALUES ('live', 'user', '2', ?, ov_now_iso())`).run(BOB);
+
+// A fake first-party edge for Live's public list and Chat's DM peer read.
+let liveRows = [{ user_id: 2, is_live: 1, username: 'bob' }];
+const edge = express();
+edge.use(express.json());
+const dmCalls = [];
+edge.get('/api/streams', (req, res) => res.json({ streams: liveRows }));
+edge.post('/internal/chat/dm/peers', (req, res) => {
+ if (String(req.headers.authorization || '') !== 'Bearer svc-token') return res.status(401).json({ error: 'token.missing' });
+ const b = req.body || {};
+ dmCalls.push(b);
+ const peers = b.subject === ANN ? (Array.isArray(b.candidates) ? b.candidates : []).filter((s) => s === CAT) : [];
+ res.json({ subject: b.subject, peers });
+});
+const edgeServer = http.createServer(edge);
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' }, services: { live: { internalUrl: '' }, chat: { internalUrl: '' } } };
+const { signToken } = require('../server/auth/routes');
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
+const principals = require('../server/identity/principals');
+const { createPresence } = require('../server/presence');
+let NOW = T0;
+const presence = createPresence({
+ db, requireAuth, guard: principals.guard, selfToken: () => 'svc-token', config, now: () => NOW,
+});
+const app = express();
+app.use(express.json());
+Object.assign(app.locals, { db, config, publicKey: keys.publicKey, privateKey: keys.privateKey });
+app.use('/api/v1/me/presence', requireAuth, presence.me);
+app.use('/api/v1/presence', requireAuth, presence.pub);
+app.use('/internal/presence', principals.guard('network.presence.read'), presence.internal);
+const server = http.createServer(app);
+
+const events = async (subject) => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all())
+ .map((r) => r.envelope).filter((e) => e.event_type === 'network.presence.changed' && (!subject || e.payload.subject === subject));
+const serviceToken = (cap = 'network.presence.read') => {
+ // The guard verifies against the real clock, not the fake presence clock.
+ const t = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({
+ iss: config.jwt.issuer, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.network'], cap: [cap],
+ iat: t, exp: t + 300, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
+ }, keys.privateKey);
+};
+
+(async () => {
+ await new Promise((r) => edgeServer.listen(0, '127.0.0.1', r));
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const edgeBase = `http://127.0.0.1:${edgeServer.address().port}`;
+ config.services.live.internalUrl = edgeBase;
+ config.services.chat.internalUrl = edgeBase;
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tok = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
+ const as = async (id) => ({ authorization: `Bearer ${await tok(id)}` });
+ const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
+ .then(async (r) => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));
+ const heartbeat = async (id, body = {}) => call('POST', '/api/v1/me/presence/heartbeat', { body, headers: await as(id) });
+ const get = async (id, subjects) => call('GET', `/api/v1/presence?subjects=${subjects.map(encodeURIComponent).join(',')}`, { headers: await as(id) });
+ const bySubject = (body) => Object.fromEntries((body.presence || []).map((p) => [p.subject, p]));
+ const validEvent = (e) => {
+ const ev = validate('events.event-envelope@1', e);
+ if (!ev.valid) console.error('envelope:', JSON.stringify(e), JSON.stringify(ev.errors));
+ assert.ok(ev.valid, JSON.stringify(ev.errors));
+ const pv = validate('network.presence.changed@1', e.payload);
+ if (!pv.valid) console.error('payload:', JSON.stringify(e.payload), JSON.stringify(pv.errors));
+ assert.ok(pv.valid, JSON.stringify(pv.errors));
+ assert.deepStrictEqual([e.source, e.visibility, e.subject], ['network', 'internal', { type: 'user', id: e.payload.subject }]);
+ assert.ok(!('invisible' === e.payload.state));
+ return true;
+ };
+
+ // ── The people who may see: self, a follow, a DM peer; a stranger never ──
+ for (const id of [1, 2, 3, 4]) { const hb = await heartbeat(id); assert.strictEqual(hb.status, 200, `heartbeat ${id}: ${JSON.stringify(hb.body)}`); }
+ let r = await get(1, [ANN, BOB, CAT, DAN]);
+ assert.strictEqual(r.status, 200);
+ let seen = bySubject(r.body);
+ assert.deepStrictEqual(Object.keys(seen).sort(), [ANN, BOB, CAT].sort(), 'self, follow and DM peer; never the stranger');
+ assert.strictEqual(seen[BOB].state, 'online');
+ assert.strictEqual(seen[BOB].live, true, 'Live makes live true');
+ assert.strictEqual(seen[CAT].state, 'online');
+ assert.ok(r.cache.includes('private'), 'private cache');
+ assert.ok((await events()).every(validEvent), 'every event validates');
+ assert.strictEqual(dmCalls.at(-1).subject, ANN, 'the DM half is read as Ann');
+ assert.ok(dmCalls.at(-1).candidates.includes(CAT), 'Chat is asked about the DM candidate');
+ r = await get(4, [ANN, BOB, CAT]);
+ assert.deepStrictEqual(r.body.presence, [], 'Dan follows and DMs nobody');
+
+ // ── Away after 10 minutes without input, offline after 3 minutes without a heartbeat ──
+ NOW = T0 + 10 * MIN + 1000;
+ assert.strictEqual((await heartbeat(2, { idle: true })).status, 200);
+ const bobRow = await db.prepare('SELECT last_active_at FROM person_presence WHERE subject_id = ?').get(BOB);
+ assert.strictEqual(bobRow.last_active_at, new Date(T0).toISOString(), 'an idle heartbeat leaves last_active_at alone');
+ seen = bySubject((await get(1, [BOB])).body);
+ assert.strictEqual(seen[BOB].state, 'away');
+ NOW = T0 + 12 * MIN + 2000;
+ seen = bySubject((await get(1, [BOB])).body);
+ assert.strictEqual(seen[BOB].state, 'away', 'two minutes without a heartbeat is fading, not gone');
+ NOW = T0 + 13 * MIN + 2000;
+ seen = bySubject((await get(1, [BOB])).body);
+ assert.strictEqual(seen[BOB].state, 'offline', 'three minutes without a heartbeat');
+ NOW = T0 + 14 * MIN;
+ await heartbeat(2);
+ seen = bySubject((await get(1, [BOB])).body);
+ assert.strictEqual(seen[BOB].state, 'online', 'a fresh heartbeat comes back');
+
+ // ── Invisible: absent for everyone else, visible as invisible to itself, event says offline ──
+ NOW = T0 + 15 * MIN;
+ await heartbeat(3);
+ r = await call('PUT', '/api/v1/me/presence', { body: { state: 'invisible', status_text: 'Hidden', emoji: '🫥' }, headers: await as(3) });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.state, 'invisible', 'the owner still sees the chosen state');
+ assert.strictEqual(r.body.status_text, 'Hidden');
+ seen = bySubject((await get(1, [CAT])).body);
+ assert.deepStrictEqual(seen, {}, 'invisible is not disclosed to a DM peer');
+ seen = bySubject((await get(3, [CAT])).body);
+ assert.strictEqual(seen[CAT].state, 'invisible');
+ const catEvents = await events(CAT);
+ const invisible = catEvents[catEvents.length - 1];
+ assert.strictEqual(invisible.payload.state, 'offline');
+ assert.strictEqual(invisible.payload.status_text, null);
+ assert.strictEqual(invisible.payload.emoji, null);
+ validEvent(invisible);
+
+ // ── show_status off reads as offline too, but invisible already hides the row ──
+ await call('PUT', '/api/v1/me/presence', { body: { state: 'auto', show_status: false, status_text: 'Off' }, headers: await as(2) });
+ seen = bySubject((await get(1, [BOB])).body);
+ assert.deepStrictEqual(seen, {}, 'show_status off is not disclosed');
+ await call('PUT', '/api/v1/me/presence', { body: { show_status: true }, headers: await as(2) });
+
+ // ── The event is debounced: a change inside 30 s waits, the flusher sends the standing view ──
+ NOW = T0 + 15 * MIN;
+ await heartbeat(1);
+ const before = (await events(ANN)).length;
+ r = await call('PUT', '/api/v1/me/presence', { body: { state: 'dnd', status_text: 'Heads down', emoji: '🔕' }, headers: await as(1) });
+ assert.strictEqual(r.body.state, 'dnd');
+ assert.strictEqual((await events(ANN)).length, before + 1, 'the first change outside the window is announced at once');
+ const changedAt = NOW + 1000;
+ NOW = changedAt;
+ r = await call('PUT', '/api/v1/me/presence', { body: { status_text: 'Back to it' }, headers: await as(1) });
+ assert.strictEqual(r.body.status_text, 'Back to it');
+ assert.strictEqual((await events(ANN)).length, before + 1, 'inside the debounce window: no event yet');
+ await presence.flushDue(changedAt + 29 * 1000);
+ assert.strictEqual((await events(ANN)).length, before + 1, 'the window has not opened for this change yet');
+ NOW = changedAt + 31 * 1000;
+ await presence.flushDue(NOW);
+ const annEvents = await events(ANN);
+ assert.strictEqual(annEvents.length, before + 2, 'the flusher announces the change');
+ assert.deepStrictEqual([annEvents.at(-1).payload.state, annEvents.at(-1).payload.status_text, annEvents.at(-1).payload.emoji], ['dnd', 'Back to it', '🔕']);
+ validEvent(annEvents.at(-1));
+ const afterFlush = (await events(ANN)).length;
+ await presence.flushDue(NOW + 60 * 1000);
+ assert.strictEqual((await events(ANN)).length, afterFlush, 'nothing pending is sent twice');
+
+ // ── Validation: the chosen state, one emoji, 80 characters, known fields ──
+ const bad = [
+ [{}, 'presence.empty'], [{ state: 'online' }, 'presence.bad_state'], [{ status_text: 'x'.repeat(81) }, 'presence.status_too_long'],
+ [{ emoji: '😀😀' }, 'presence.one_emoji'], [{ show_status: 'yes' }, 'presence.bad_show_status'], [{ last_seen: 'x' }, 'presence.unknown_field'],
+ ];
+ for (const [body, code] of bad) {
+ r = await call('PUT', '/api/v1/me/presence', { body, headers: await as(1) });
+ assert.strictEqual(r.status, 400, JSON.stringify(body));
+ assert.strictEqual(r.body.error, code);
+ }
+ r = await call('PUT', '/api/v1/me/presence', { body: { state: 'auto' } });
+ assert.strictEqual(r.status, 401, 'signed in only');
+
+ // ── The internal read needs network.presence.read and answers for one person ──
+ r = await call('GET', `/internal/presence?for=${ANN}&subjects=${BOB},${CAT},${DAN}`, { headers: { authorization: `Bearer ${serviceToken()}` } });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(Object.keys(bySubject(r.body)).sort(), [BOB].sort(), 'Chat gets only what Ann may see (Cat is invisible)');
+ r = await call('GET', `/internal/presence?for=${ANN}&subjects=${BOB}`, { headers: { authorization: `Bearer ${serviceToken('network.blocks.read')}` } });
+ assert.strictEqual(r.status, 403, 'another capability is refused');
+ r = await call('GET', `/internal/presence?for=ann&subjects=${BOB}`, { headers: { authorization: `Bearer ${serviceToken()}` } });
+ assert.strictEqual(r.status, 400);
+
+ // ── A malformed or oversized subject list is refused ──
+ r = await get(1, ['ann']);
+ assert.strictEqual(r.status, 400);
+ r = await call('GET', `/api/v1/presence?subjects=${Array.from({ length: 101 }, (_, i) => BOB.replace(/.$/, String.fromCharCode(65 + (i % 26)))).join(',')}`, { headers: await as(1) });
+ assert.strictEqual(r.status, 400);
+
+ console.log('presence: all checks passed');
+})().finally(async () => {
+ await new Promise((r) => server.close(r));
+ await new Promise((r) => edgeServer.close(r));
+}).catch((err) => { console.error(err); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -140,6 +140,7 @@ const REVIEWED = {
@@ -140,6 +140,7 @@ const REVIEWED = {
'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],
'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],
'server/node/release.js fetch': [1, 'the Node\'s own release page: a fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Node/releases/latest), 8 s timeout, held 10 minutes; no request value ever names a host and it only ever reads the public release list'],
'server/node/release.js fetch': [1, 'the Node\'s own release page: a fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Node/releases/latest), 8 s timeout, held 10 minutes; no request value ever names a host and it only ever reads the public release list'],
'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],
'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],
+ 'server/presence/index.js fetch': [2, "Live and Chat internal URLs from operator config (loopback defaults): Live's public /api/streams (fixed path, 2 s timeout, held 30 s) and Chat's POST /internal/chat/dm/peers (fixed path, 1.5 s, the subject list in the body); never a request value names a host"],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],
};
};
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,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']]);
+ 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']]);
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
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');