openvibers/openvibe.network Commits

Presence: Network owns the chosen online status, heartbeats and the public read (#626)

OP OpenVibers committed 6 days ago · b2ec13a4b1 · 11 files, +691 −3

* Presence: Network owns the chosen online status, heartbeats and the public read

person_presence (migration 0047) keeps the chosen state, status line, one emoji,
show_status, the last input and the last heartbeat, plus the public view last
announced. POST /api/v1/me/presence/heartbeat and PUT /api/v1/me/presence write
it; GET /api/v1/presence (and /internal/presence with network.presence.read for
Chat) answers only for the person themselves, people they follow and Chat DM
peers, never a stranger and never an invisible person. Live's public stream list
over loopback makes live true (mapped through identity_legacy_map, cached 30 s).
network.presence.changed is debounced 30 s per person and carries the public view
only. Presence is in the account export and erased with the account (ADR-033).
Contracts 0.318.0; Chat reads presence and answers the DM half through
chat.dm.peers.read.

* Merge main: Contracts 0.319.0

* Presence: ask Chat only about people a follow does not already cover

Changes

ddiff --git a/README.md b/README.md
iindex e480ed6b..f750a777 100644
---- a/README.md
++++ b/README.md
@@@ -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 other
services hold are seeded in [server/identity/principals.js](server/identity/principals.js).
ddiff --git a/migrations/0047_presence.sql b/migrations/0047_presence.sql
nnew file mode 100644
iindex 00000000..98f02e24
---- /dev/null
++++ b/migrations/0047_presence.sql
@@@ -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;
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex da06a837..02130d03 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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 });
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 095dffc3..0ab82af8 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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.
ddiff --git a/server/index.js b/server/index.js
iindex c05c56a6..45f7eb81 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
ddiff --git a/server/presence/index.js b/server/presence/index.js
nnew file mode 100644
iindex 00000000..9e67cb53
---- /dev/null
++++ b/server/presence/index.js
@@@ -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,
++};
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 68cecba5..68e604c9 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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');
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 43a7cf93..ec953271 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand']]);
++ 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.
ddiff --git a/test/presence.test.js b/test/presence.test.js
nnew file mode 100644
iindex 00000000..d71a8b91
---- /dev/null
++++ b/test/presence.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex b2f9d087..ce31276e 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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)'],
};
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 27b9b70d..defca1bd 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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 twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');