openvibers/openvibe.network Commits
The Dock's state: GET and PUT /api/v1/me/dock (#437)
Changes
@@@ -26,6 +26,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).++- **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).---@@@ -391,6 +392,31 @@ Quiet hours and per-category digests are built (migration `0027_notification_qui---++## The Dock's state++++The Dock (plan 2026-10-30, K-D1) is one small floating surface on every OpenVibe site: your agent, your chats and++your activity. It is drawn on many domains and `localStorage` is per site, so the state it follows you with lives in++the Network (`server/dock/`, migration `0031_dock_states.sql`, contracts 0.251.0).++++- `GET /api/v1/me/dock` answers `network.dock-result@1`: the person's `network.dock-state@1` (the stored one, or the++ default — closed, on the activity tab, not pinned, bottom-right, the site's own size), the `counts` the pill's++ status dot is drawn from (`running` and `waiting` from the Action Center's aggregator, `unread` from the++ notification service) and `sources_partial`, true when an authority behind those counts did not answer.++- `PUT /api/v1/me/dock` takes a partial body — any subset of the state's fields — merges it into the stored state,++ validates the result against `network.dock-state@1` (an invalid body is `422` with the contract's problem shape)++ and answers the new `network.dock-result@1`. `place` merges per device class, so a body naming only `place.phone`++ leaves the desktop where it was.++- The subject comes from the caller's token and from nowhere else, so one person never reads or writes another's++ state. The caller's own session only: the `ov_token` cookie on openvibe.network, a Bearer user token elsewhere,++ with the CORS allow-list and the CSRF rules the other `/api/v1/me/*` routes use.++- A write is limited to 60 a minute per person (`429 dock.rate_limited` past that); the page debounces anyway.++- The row is the person's own: it goes into their account export and is erased with them (ADR-033), and a merge++ (ADR-029) leaves it with the surviving account.++++The full reference is [docs/dock.md](docs/dock.md).++++---++## Email SetupEmail is optional — the notification system works without it. Email is only sent for **CRITICAL** priority notifications.@@@ -783,7 +809,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:MIT (`license` in [package.json](package.json)).<!-- versions:start -->--- openvibe-contracts: v0.238.0++- openvibe-contracts: v0.251.0- openvibe-sdk: v0.74.0- openvibe-shared: v4.47.2<!-- versions:end -->@@@ -6,7 +6,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4000,"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",-- "contracts": "openvibe-contracts v0.238.0",++ "contracts": "openvibe-contracts v0.251.0","sdk": "openvibe-sdk v0.74.0","shared": "openvibe-shared v4.47.2","capabilities": [@@@ -46,7 +46,8 @@"operator alerts relay target and admin panel","serves /shared/* from its pinned openvibe-shared","per-actor limits (openvibe-sdk/limits)",-- "universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)"++ "universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)",++ "the Dock's state per person: GET and PUT /api/v1/me/dock (network.dock-state@1, network.dock-result@1)"],"updated": "2026-10-30"}nnew file mode 100644@@@ -0,0 +1,93 @@++# The Dock's state++++The Dock (plan 2026-10-30, K-D1) is one small floating surface on every OpenVibe site: your agent, your chats and++your activity. It is drawn on many domains and `localStorage` is per site, so the state it follows you with lives in++the Network. This note is the API reference for the two routes that read and write it.++++Contracts 0.251.0: `network.dock-state@1` (the state) and `network.dock-result@1` (what the routes answer).++Implementation: `server/dock/index.js`, migration `0031_dock_states.sql`.++++## The two routes++++Both are the person's own: the subject comes from the caller's token and from nowhere else, so one person never++reads or writes another's state. They take the person's own session — the `ov_token` cookie on openvibe.network, a++Bearer user token anywhere else — and the CORS allow-list and the CSRF rules are exactly the ones the other++`/api/v1/me/*` routes use (the first-party origin allow-list in `server/index.js`; a foreign origin is refused++server-side).++++| Route | Answer | Notes |++|---|---|---|++| `GET /api/v1/me/dock` | `network.dock-result@1` | `Cache-Control: private, no-store` |++| `PUT /api/v1/me/dock` | `network.dock-result@1` | a partial body, merged into the stored state |++++### `GET /api/v1/me/dock`++++```json++{++ "state": {++ "open": false,++ "tab": "activity",++ "pinned": false,++ "agent": null,++ "chat": null,++ "place": {++ "phone": { "corner": "bottom-right" },++ "desktop": { "corner": "bottom-right", "width": null, "height": null }++ },++ "updated_at": "2026-10-30T12:00:00Z"++ },++ "counts": { "running": 2, "waiting": 1, "unread": 4 },++ "sources_partial": false++}++```++++`state` is the stored state, or the default (closed, on the activity tab, not pinned, bottom-right, the site's own++size) when the person has never opened the Dock.++++`counts` is what the pill's status dot is drawn from: `running` and `waiting` come from the Action Center's++aggregator (`server/actions/index.js` `forPerson`, which keeps its answer for 10 s, so a page view never fans out++more than that), `unread` from the notification service's unread count. `sources_partial` is true when an authority++behind those counts did not answer, so the counts are lower than the truth — the page shows the dot anyway.++++### `PUT /api/v1/me/dock`++++The body is a **partial** state: any subset of the state's fields. The rest is left as it was.++++```json++{ "open": true, "tab": "chats", "place": { "desktop": { "corner": "top-left" } } }++```++++| Field | How it merges |++|---|---|++| `open`, `tab`, `pinned`, `chat` | replaced. `chat: null` closes the conversation. |++| `agent` | replaced whole; `null` means the Agent tab follows none. |++| `place` | merged per device class, so a body naming only `place.phone` leaves the desktop where it was. Inside a class, each field is replaced on its own (`{ "place": { "desktop": { "width": 500 } } }` keeps the corner and the height). |++| `updated_at` | the server's own; a value in the body is ignored. |++++A body that is not a valid state is refused with `422` and the contract's problem shape++(`errors.problem@1`: `type`, `title`, `status`, `code`, `detail` and the `errors` array with the path and the++message of each fault). A key the contract does not know is refused like any other fault, not quietly dropped.++++A write is limited to **60 a minute per person**; past that the answer is `429` with the code `dock.rate_limited`.++The page debounces anyway, and a runaway tab must not fill the table.++++## The state++++| Field | Meaning |++|---|---|++| `open` | open (a sheet on a phone, a floating card on a desktop) or collapsed to its pill |++| `tab` | `agent`, `chats` or `activity` |++| `pinned` | keep the pill on screen even when nothing runs and nothing waits |++| `agent` | `{ service: 'actor' \| 'codes' \| 'run' \| 'desks', id }` — the agent the Agent tab follows — or `null` |++| `chat` | the conversation the Chats tab has open, or `null` |++| `place.phone.corner` | `bottom-right`, `bottom-left`, `top-right` or `top-left` |++| `place.desktop` | the same `corner`, plus `width` (320..720) and `height` (360..900), each `null` for the site's own default size |++| `updated_at` | when the state was last written |++++Every object refuses extra keys, so a page cannot smuggle a field into the state another page reads.++++## Account data++++The row is the person's own, so it goes into their account export (`network/dock.json` in Network's part of the++zip) and is erased with them (ADR-033). A merge (ADR-029) leaves it with the surviving account: the survivor's row++wins, and a folded-in row moves over only when the survivor has none.nnew file mode 100644@@@ -0,0 +1,15 @@++-- phase: expand++-- The Dock's state per person (the Dock plan 2026-10-30, K-D1): the little floating surface every OpenVibe site++-- draws — your agent, your chats, your activity. It is drawn on many domains and localStorage is per site, so the++-- state it follows you with lives here, in the Network.++--++-- One row per person: the state is network.dock-state@1 (Contracts 0.251.0) as a JSON value. The row is the++-- person's own, so it goes into their account export and is erased with them (ADR-033), and a merge (ADR-029)++-- leaves it with the surviving account.++--++-- Expand only: an older release never reads this table, and nothing here changes an existing one.++CREATE TABLE IF NOT EXISTS dock_states (++ subject text COLLATE "C" PRIMARY KEY,++ state jsonb NOT NULL,++ updated_at timestamptz NOT NULL DEFAULT now()++);@@@ -20,7 +20,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.251.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.47.2","pg": "^8.23.0",@@@ -1595,9 +1595,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.238.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",-- "integrity": "sha512-BQOsqor9ew75s2ElWK6g6pWk0gr5nbwzgfzMKJPBPIjnDnIkUQfNmsYw8AeexW5qeotCmfTu4lSPdqy6ZQaq5g==",++ "version": "0.251.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.251.0",++ "integrity": "sha512-aDTGrk/0FujqQT1IeL0hmhNnK4azNj579nn1LCm2cdt38OWSdNYyvazb/+rAHM7erCXcJrxEzBdlHd37HK+vJw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.251.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.47.2","pg": "^8.23.0",nnew file mode 100644@@@ -0,0 +1,193 @@++'use strict';++/**++ * The Dock's state (the Dock plan 2026-10-30, K-D1; Contracts 0.251.0): the little floating surface every OpenVibe++ * site draws — your agent, your chats, your activity. It is drawn on many domains and localStorage is per site, so++ * the state it follows you with lives here, in the Network.++ *++ * GET /api/v1/me/dock network.dock-result@1: the state, the counts, and whether those counts are complete++ * PUT /api/v1/me/dock network.dock-result@1: a partial body (any subset of the state's fields) merged into it++ *++ * The subject comes from the caller's token and from nowhere else, so one person never reads or writes another's++ * state. The counts come from the Action Center's aggregator (server/actions/index.js forPerson, which keeps its++ * answer for 10 s, so a page view never fans out more than that) and from the notification service's unread count.++ *++ * A write is limited to 60 a minute per person: the page debounces anyway, and a runaway tab must not fill the++ * table. Every write is validated against network.dock-state@1 before it is stored, so a page can never leave a++ * half-formed state behind for the next page to read.++ */++const express = require('express');++const { validate, http } = require('openvibe-contracts');++const store = require('../developer/store');++++const CONTRACT = 'network.dock-state@1';++const WRITE_LIMIT = 60;++const WRITE_WINDOW_MS = 60_000;++// The state a person has before they ever open the Dock: closed, on the activity tab, not pinned, bottom-right,++// and the site's own size.++const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];++++const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);++++/** The default state, for a person who has never opened the Dock. */++function defaultState(at) {++ return {++ open: false, tab: 'activity', pinned: false, agent: null, chat: null,++ place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } },++ updated_at: new Date(at).toISOString(),++ };++}++++/**++ * Merge a partial body into the current state.++ *++ * `open`, `tab`, `pinned` and `chat` are replaced; `agent` is replaced whole (null clears it); `place` is merged++ * per device class, so a body that names only `place.phone` leaves the desktop where it was. A key the contract++ * does not know is carried into the result rather than dropped, so validation refuses the body instead of quietly++ * accepting it.++ */++function mergeState(current, patch) {++ const out = { ...current };++ for (const [key, value] of Object.entries(patch)) {++ if (key === 'place' && isObject(value)) {++ const place = { ...(isObject(current.place) ? current.place : {}) };++ for (const [cls, v] of Object.entries(value)) {++ place[cls] = (cls === 'phone' || cls === 'desktop') && isObject(v)++ ? { ...(isObject(place[cls]) ? place[cls] : {}), ...v }++ : v;++ }++ out.place = place;++ } else {++ out[key] = value;++ }++ }++ return out;++}++++/**++ * The Dock for one person.++ * db the Network database++ * actions the Action Center's aggregator ({ forPerson(actor) }) — the running and waiting counts++ * notifications the notification service ({ getUnreadCount(userId) }) — the unread count++ */++function createDock({ db, actions = null, notifications = null, now = () => Date.now(), writeLimit = WRITE_LIMIT, writeWindowMs = WRITE_WINDOW_MS, log = console } = {}) {++ if (!db) throw new TypeError('createDock: db is required');++ const writes = new Map();++++ /** The person's stored state, or the default. */++ async function stateOf(actor, at) {++ const row = await db.prepare('SELECT state FROM dock_states WHERE subject = ?').get(actor.subject);++ if (!row) return defaultState(at);++ let state = row.state;++ if (typeof state === 'string') { try { state = JSON.parse(state); } catch { state = null; } }++ // A row written by an older release (or by hand) is still shown, but only if it is a state the contract++ // accepts: a broken row falls back to the default rather than breaking the page that reads it.++ return state && validate(CONTRACT, state).valid ? state : defaultState(at);++ }++++ /** The counts the pill's status dot is drawn from. */++ async function countsOf(actor) {++ let running = 0;++ let waiting = 0;++ let partial = false;++ if (actions && typeof actions.forPerson === 'function') {++ try {++ const doc = await actions.forPerson(actor);++ running = Number(doc && doc.counts && doc.counts.running) || 0;++ waiting = Number(doc && doc.counts && doc.counts.waiting) || 0;++ partial = !!(doc && doc.partial);++ } catch (err) {++ partial = true;++ log.warn('[Dock] the Action Center did not answer:', err.message);++ }++ } else {++ partial = true;++ }++ let unread = 0;++ if (notifications && typeof notifications.getUnreadCount === 'function') {++ try {++ unread = Number(await notifications.getUnreadCount(actor.user.id)) || 0;++ } catch (err) {++ partial = true;++ log.warn('[Dock] the unread count did not answer:', err.message);++ }++ } else {++ partial = true;++ }++ return { counts: { running, waiting, unread }, sources_partial: partial };++ }++++ /** network.dock-result@1 for one person. */++ async function read(actor) {++ const at = now();++ const state = await stateOf(actor, at);++ const { counts, sources_partial: partial } = await countsOf(actor);++ return { state, counts, sources_partial: partial };++ }++++ /** 60 writes a minute, per person. */++ function mayWrite(subject, at) {++ const kept = (writes.get(subject) || []).filter((t) => at - t < writeWindowMs);++ if (kept.length >= writeLimit) { writes.set(subject, kept); return false; }++ kept.push(at);++ writes.set(subject, kept);++ if (writes.size > 5000) writes.delete(writes.keys().next().value);++ return true;++ }++++ /** Merge a partial body into the person's state and store it. → network.dock-result@1 */++ async function write(actor, body, { ctx = null } = {}) {++ const at = now();++ if (!mayWrite(actor.subject, at)) {++ throw Object.assign(new Error('too many writes; wait a minute'), { status: 429, code: 'dock.rate_limited' });++ }++ if (!isObject(body)) {++ throw Object.assign(new Error('the body must be the state to change'), { status: 422, code: 'dock.invalid' });++ }++ const current = await stateOf(actor, at);++ const merged = { ...mergeState(current, body), updated_at: new Date(at).toISOString() };++ const check = validate(CONTRACT, merged);++ if (!check.valid) {++ throw Object.assign(new Error('that is not a Dock state'), { status: 422, code: 'dock.invalid', errors: check.errors });++ }++ await db.prepare(`INSERT INTO dock_states (subject, state, updated_at) VALUES (?, ?, now())++ ON CONFLICT (subject) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at`)++ .run(actor.subject, JSON.stringify(merged));++ const { counts, sources_partial: partial } = await countsOf(actor);++ return { state: merged, counts, sources_partial: partial };++ }++++ return { read, write, stateOf, defaultState, _writes: writes };++}++++/** The routes, behind Network's session check (the cookie on openvibe.network, a Bearer token elsewhere), like the bell's. */++function router({ dock, requireAuth }) {++ const r = express.Router();++ r.use(http.middleware());++ r.use(express.json({ limit: '8kb' }));++ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });++ r.get('/', requireAuth, async (req, res) => {++ try {++ const actor = await store.actorOf(req.app.locals.db, req.user);++ res.json(await dock.read(actor));++ } catch (err) {++ if (err && err.status) return http.sendProblem(res, err.status, err.code || 'dock.error', { detail: err.message, ctx: req.ov });++ console.error('[Dock] read:', err);++ http.sendProblem(res, 500, 'dock.error', { detail: 'could not read your Dock', ctx: req.ov });++ }++ });++ r.put('/', requireAuth, async (req, res) => {++ try {++ const actor = await store.actorOf(req.app.locals.db, req.user);++ res.json(await dock.write(actor, req.body, { ctx: req.ov }));++ } catch (err) {++ if (err && err.status) {++ return http.sendProblem(res, err.status, err.code || 'dock.error', { detail: err.message, errors: err.errors, ctx: req.ov });++ }++ console.error('[Dock] write:', err);++ http.sendProblem(res, 500, 'dock.error', { detail: 'could not save your Dock', ctx: req.ov });++ }++ });++ return r;++}++++module.exports = { createDock, router, defaultState, mergeState, CONTRACT, WRITE_LIMIT, WRITE_WINDOW_MS, CORNERS };@@@ -187,6 +187,9 @@ async function networkPart(db, userId) {theme_site_overrides: (await rowsOf(db, 'user_theme_site_overrides', 'user_id = ?', [u.id])).rows,});await byUser('history.json', 'user_history');++ // The Dock's state (the Dock plan 2026-10-30, K-D1): the little floating surface every OpenVibe site draws.++ add('dock.json', await tableExists(db, 'dock_states')++ ? await db.prepare('SELECT subject, state, updated_at FROM dock_states WHERE subject = ?').all(u.subject_id) : []);add('username_history.json', (await rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] })).rows);await byUser('profile_changes.json', 'user_profile_changes');await byUser('sign_in_log.json', 'ip_log');@@@ -344,6 +347,8 @@ 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');++ // The Dock's state (the Dock plan 2026-10-30, K-D1): the person's own row, gone with them.++ await del('dock_states', `subject IN ${inS}`, subjects, 'dock_states');await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');// The person's agents first (WS-Z2), then the agents of the projects archived below.@@@ -174,6 +174,13 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj}}}++ // The Dock's state (the Dock plan 2026-10-30, K-D1): the survivor's row wins. A folded-in row moves over++ // only when the survivor has none; when both have one, the survivor's is the one that stays.++ if (await tableExists(db, 'dock_states')) {++ const mine = await db.prepare('SELECT 1 FROM dock_states WHERE subject = ?').get(into);++ if (mine) await db.prepare('DELETE FROM dock_states WHERE subject = ?').run(from);++ else await db.prepare('UPDATE dock_states SET subject = ? WHERE subject = ?').run(into, from);++ }await moveByUserId(db, 'notifications', 'user_id', fromUser.id, intoUser.id);await moveByUserId(db, 'push_subscriptions', 'user_id', fromUser.id, intoUser.id);note('preferences', await moveByUserId(db, 'notification_preferences', 'user_id', fromUser.id, intoUser.id, ['category']));@@@ -638,6 +638,11 @@ require('./developer/confirmations').start(db);domains: process.env.OV_DOMAINS_INTERNAL_URL || 'http://127.0.0.1:5070',} });app.use('/api/v1/me/actions', rateLimit({ windowMs: 60_000, max: 120 }), require('./actions').router({ actions, requireAuth }));++ // The Dock's state (the Dock plan 2026-10-30, K-D1, server/dock): one row per person, so the little floating++ // surface every OpenVibe site draws follows them across every domain (localStorage is per site). Its counts are++ // the Action Center's own, so a page view never fans out further than the aggregator's 10 s cache already does.++ const dock = require('./dock').createDock({ db, actions, notifications: notificationService });++ app.use('/api/v1/me/dock', require('./dock').router({ dock, requireAuth }));}// Notification API (authenticated users)@@@ -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']]);++ 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']]);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,237 @@++'use strict';++// The Dock's state (server/dock, the Dock plan 2026-10-30, K-D1): GET and PUT /api/v1/me/dock answer network.dock-result@1.++// The Dock is the little floating surface every OpenVibe site draws, and localStorage is per site, so the state it++// follows the person with lives in the Network.++//++// What this proves:++// - the default state for a person who has never opened the Dock, and the counts beside it;++// - a partial PUT merges into the stored state (place merges per device class), and an invalid body is refused++// with the contract's problem shape (an unknown tab, a width of 2000, a key the contract does not know);++// - the counts come from the Action Center's aggregator and the notification service, and sources_partial says++// when an authority did not answer;++// - one person never reads or writes another's state: the subject comes from the token and nowhere else;++// - CORS: an OpenVibe site may ask, another origin is refused (and a cross-origin write never runs the route);++// - 60 writes a minute per person;++// - the row goes into the person's export and is erased with them, and a merge leaves it with the survivor;++// - the migration is expand-only.++// node --import ./test/helpers/pg-preload.mjs test/dock.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const http = require('http');++const path = require('path');++const express = require('express');++const { ids, validate } = require('openvibe-contracts');++const { uncovered } = require('openvibe-sdk/account-data');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const dockMod = require('../server/dock');++const accountData = require('../server/identity/account-data');++const accountMerge = require('../server/identity/account-merge');++const publicCors = require('../server/public-cors');++const authRoutes = require('../server/auth/routes');++++(async () => {++const db = getDb();++const log = console.log; console.log = () => {};++++// Two people, each with a canonical subject: the Dock's row belongs to one of them, never to both.++// A jsonb column comes back already parsed from some drivers and as text from others.++const asJson = (v) => (typeof v === 'string' ? JSON.parse(v) : v);++const mk = async (name) => {++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?) RETURNING id').run(name, `${name}@example.test`, 'x')).lastInsertRowid;++ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);++};++const ana = await mk('dockana');++const bob = await mk('dockbob');++++// The Action Center's aggregator and the notification service, faked: the Dock asks them and shows what they say.++const seen = [];++const aggregator = { forPerson: async (actor) => { seen.push(actor.subject); return { counts: { running: 2, waiting: 1, scheduled: 0, done: 0, failed: 0 }, partial: false }; } };++const notifications = { getUnreadCount: async (userId) => (userId === ana.id ? 4 : 7) };++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);++const tokenOf = (u) => authRoutes.signToken(u, keys.privateKey, config);++++const dock = dockMod.createDock({ db, actions: aggregator, notifications });++const app = express();++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++// The CORS the whole app runs behind (server/index.js): the first-party allow-list, and a foreign origin refused.++const allowed = new Set(['https://openvibe.blog', 'https://openvibe.network']);++const corsGuard = publicCors.originGuard((origin) => allowed.has(origin), { log: { warn() {} } });++app.use(publicCors.gate(publicCors.restrictedCors(corsGuard.origin)));++app.use(corsGuard.denied);++app.use('/api/v1/me/dock', dockMod.router({ dock, requireAuth }));++const server = http.createServer(app);++await new Promise((r) => server.listen(0, '127.0.0.1', r));++const api = `http://127.0.0.1:${server.address().port}/api/v1/me/dock`;++const call = (method, body, { as = ana, origin = null } = {}) => fetch(api, {++ method,++ headers: { authorization: `Bearer ${tokenOf(as)}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(origin ? { origin } : {}) },++ ...(body === undefined ? {} : { body: JSON.stringify(body) }),++}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})), headers: r.headers }));++++try {++ // The default state: closed, on the activity tab, not pinned, bottom-right, the site's own size.++ let r = await call('GET');++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.ok(validate('network.dock-result@1', r.body).valid, JSON.stringify(validate('network.dock-result@1', r.body).errors));++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.deepStrictEqual(r.body.state, {++ open: false, tab: 'activity', pinned: false, agent: null, chat: null,++ place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } },++ updated_at: r.body.state.updated_at,++ });++ assert.ok(r.body.state.updated_at, 'the default state carries a time');++ assert.deepStrictEqual(r.body.counts, { running: 2, waiting: 1, unread: 4 }, 'the counts come from the aggregator and the notifications');++ assert.strictEqual(r.body.sources_partial, false);++++ // A session is needed; another person's token reads their own state, never this one's.++ assert.strictEqual((await fetch(api, { method: 'GET' })).status, 401, 'a session is needed');++ r = await call('GET', undefined, { as: bob });++ assert.deepStrictEqual(r.body.state.place, { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, "bob sees his own default");++ assert.deepStrictEqual(r.body.counts, { running: 2, waiting: 1, unread: 7 }, 'the unread count is the caller\'s own');++++ // A partial write merges into the stored state.++ r = await call('PUT', { open: true, tab: 'chats' });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.ok(validate('network.dock-result@1', r.body).valid);++ assert.deepStrictEqual([r.body.state.open, r.body.state.tab, r.body.state.pinned], [true, 'chats', false], 'pinned was not in the body and stayed');++ r = await call('GET');++ assert.deepStrictEqual([r.body.state.open, r.body.state.tab], [true, 'chats'], 'the state is stored');++++ // place merges per device class, and inside a class per field.++ r = await call('PUT', { place: { desktop: { width: 500 } } });++ assert.deepStrictEqual(r.body.state.place, { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: 500, height: null } }, 'the corner and the height stayed');++ r = await call('PUT', { place: { phone: { corner: 'top-left' } } });++ assert.deepStrictEqual(r.body.state.place, { phone: { corner: 'top-left' }, desktop: { corner: 'bottom-right', width: 500, height: null } }, 'the desktop stayed');++ r = await call('PUT', { place: { desktop: { corner: 'top-right', height: 700 } } });++ assert.deepStrictEqual(r.body.state.place, { phone: { corner: 'top-left' }, desktop: { corner: 'top-right', width: 500, height: 700 } });++++ // The agent and the chat: replaced whole, and null clears them.++ r = await call('PUT', { agent: { service: 'actor', id: 'tsk_1' }, chat: 'cnv_1' });++ assert.deepStrictEqual(r.body.state.agent, { service: 'actor', id: 'tsk_1' });++ assert.strictEqual(r.body.state.chat, 'cnv_1');++ r = await call('PUT', { agent: null });++ assert.strictEqual(r.body.state.agent, null, 'null clears the agent');++ assert.strictEqual(r.body.state.chat, 'cnv_1', 'the chat was not in the body and stayed');++++ // An invalid body is refused with the contract's problem shape.++ for (const [body, why] of [++ [{ tab: 'profile' }, 'an unknown tab'],++ [{ place: { desktop: { width: 2000 } } }, 'a width of 2000'],++ [{ place: { desktop: { height: 10 } } }, 'a height under 360'],++ [{ theme: 'neon-tokyo' }, 'a key the contract does not know'],++ [{ place: { tablet: { corner: 'top-left' } } }, 'a device class the contract does not know'],++ [{ chat: '' }, 'an empty chat id'],++ ]) {++ const before = (await call('GET')).body.state;++ r = await call('PUT', body);++ assert.strictEqual(r.status, 422, `${why}: ${JSON.stringify(r.body)}`);++ assert.strictEqual(r.body.code, 'dock.invalid', why);++ assert.strictEqual(r.body.status, 422);++ assert.ok(r.body.type && r.body.title, 'the problem carries a type and a title');++ assert.ok(Array.isArray(r.body.errors) && r.body.errors.length, `${why}: the faults are listed`);++ assert.ok(r.body.errors.every((e) => typeof e.message === 'string'), 'each fault has a message');++ assert.deepStrictEqual((await call('GET')).body.state, before, `${why}: nothing was stored`);++ }++++ // One person never reads or writes another's state: the subject comes from the token and nowhere else.++ const anaRow = await db.prepare('SELECT subject, state FROM dock_states WHERE subject = ?').get(ana.subject_id);++ assert.ok(anaRow, "ana's row is his own");++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states').get()).n, 1, "bob's write made no row of his own");++ r = await call('PUT', { tab: 'agent' }, { as: bob });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.state.tab, 'agent', "bob's own state");++ assert.strictEqual(r.body.state.pinned, false, "bob's state is his own default plus his own write");++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states').get()).n, 2, 'one row each');++ const anaAgain = await db.prepare('SELECT state FROM dock_states WHERE subject = ?').get(ana.subject_id);++ assert.deepStrictEqual(asJson(anaAgain.state), asJson(anaRow.state), "bob's write left ana's state alone");++ assert.ok(seen.every((s) => s === ana.subject_id || s === bob.subject_id), 'the aggregator was asked about the caller only');++++ // CORS: an OpenVibe site may ask; another origin is refused, and its write never runs the route.++ let cors = await fetch(api, { headers: { origin: 'https://openvibe.blog', authorization: `Bearer ${tokenOf(ana)}` } });++ assert.strictEqual(cors.status, 200, 'an OpenVibe site may read the Dock');++ assert.strictEqual(cors.headers.get('access-control-allow-origin'), 'https://openvibe.blog', 'the answer names the origin');++ cors = await fetch(api, { method: 'OPTIONS', headers: { origin: 'https://openvibe.blog', 'access-control-request-method': 'PUT' } });++ assert.ok(cors.status === 204 || cors.status === 200, `the preflight is answered: ${cors.status}`);++ cors = await fetch(api, { headers: { origin: 'https://evil.example', authorization: `Bearer ${tokenOf(ana)}` } });++ assert.strictEqual(cors.status, 403, 'another origin is refused');++ assert.strictEqual((await cors.json()).code, 'cors.origin_denied');++ cors = await fetch(api, { method: 'PUT', headers: { origin: 'https://evil.example', authorization: `Bearer ${tokenOf(ana)}`, 'content-type': 'application/json' }, body: JSON.stringify({ tab: 'chats' }) });++ assert.strictEqual(cors.status, 403, 'a cross-origin write never runs the route');++ assert.deepStrictEqual(asJson((await db.prepare('SELECT state FROM dock_states WHERE subject = ?').get(ana.subject_id)).state), asJson(anaAgain.state), "the refused write changed nothing");++++ // 60 writes a minute, per person.++ const limiter = dockMod.createDock({ db, actions: aggregator, notifications });++ const limitApp = express();++ Object.assign(limitApp.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++ limitApp.use('/api/v1/me/dock', dockMod.router({ dock: limiter, requireAuth }));++ const limitServer = http.createServer(limitApp);++ await new Promise((r) => limitServer.listen(0, '127.0.0.1', r));++ const limitApi = `http://127.0.0.1:${limitServer.address().port}/api/v1/me/dock`;++ const limited = (body) => fetch(limitApi, { method: 'PUT', headers: { authorization: `Bearer ${tokenOf(ana)}`, 'content-type': 'application/json' }, body: JSON.stringify(body) });++ for (let i = 0; i < dockMod.WRITE_LIMIT; i++) {++ const w = await limited({ pinned: true });++ assert.strictEqual(w.status, 200, `write ${i + 1} of ${dockMod.WRITE_LIMIT}`);++ }++ const over = await limited({ pinned: false });++ assert.strictEqual(over.status, 429, 'the 61st write in a minute is refused');++ assert.strictEqual((await over.json()).code, 'dock.rate_limited');++ limitServer.close();++++ // The counts say so when an authority does not answer.++ const brokenDock = dockMod.createDock({ db, actions: { forPerson: async () => { throw new Error('the aggregator is down'); } }, notifications: { getUnreadCount: async () => { throw new Error('the notifications are down'); } }, log: { warn() {} } });++ const brokenApp = express();++ Object.assign(brokenApp.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++ brokenApp.use('/api/v1/me/dock', dockMod.router({ dock: brokenDock, requireAuth }));++ const brokenServer = http.createServer(brokenApp);++ await new Promise((r) => brokenServer.listen(0, '127.0.0.1', r));++ r = await fetch(`http://127.0.0.1:${brokenServer.address().port}/api/v1/me/dock`, { headers: { authorization: `Bearer ${tokenOf(ana)}` } }).then((x) => x.json());++ assert.strictEqual(r.sources_partial, true, 'the counts are lower than the truth, and the answer says so');++ assert.deepStrictEqual(r.counts, { running: 0, waiting: 0, unread: 0 }, 'and it still answers');++ brokenServer.close();++++ // The row is the person's own: it goes into their export and is erased with them (ADR-033).++ // The SDK's uncovered() guard (0.74.0) sees every table with a person-shaped column a service's account-data++ // module does not cover. Network is the hub and keeps its own rows in server/identity/account-data.js, so the++ // guard here is that dock_states is one of the tables Network must answer for.++ const personTables = await uncovered(db, {});++ assert.ok(personTables.includes('dock_states'), 'the guard sees dock_states: it names a person');++ const part = await accountData.networkPart(db, ana.id);++ const dockFile = part.files.find((f) => f.name === 'dock.json');++ assert.ok(dockFile, "ana's export carries his Dock state");++ const exported = dockFile.content;++ assert.strictEqual(exported.length, 1);++ assert.strictEqual(exported[0].subject, ana.subject_id, 'the export carries his own row');++ assert.strictEqual(asJson(exported[0].state).tab, 'chats', 'the exported state is the one ana stored');++ const bobPart = await accountData.networkPart(db, bob.id);++ assert.deepStrictEqual(bobPart.files.find((f) => f.name === 'dock.json').content.map((x) => x.subject), [bob.subject_id], "bob's export carries only his own row");++++ // The merge (ADR-029) leaves the Dock with the surviving account.++ const survivor = await db.prepare('SELECT * FROM users WHERE id = ?').get(ana.id);++ await accountMerge.merge(db, bob, survivor, { initiatedBy: 'person' });++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states WHERE subject = ?').get(bob.subject_id)).n, 0, "the folded-in account's row is gone");++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states WHERE subject = ?').get(ana.subject_id)).n, 1, "the survivor keeps the row");++++ // And the row is erased with the person.++ const delId = `del_${ids.ulid()}`;++ await db.prepare(`INSERT INTO account_deletions (id, user_id, subject, status, requested_at, delete_after) VALUES (?, ?, ?, 'scheduled', ?, ?)`)++ .run(delId, ana.id, ana.subject_id, '2026-10-30T12:00:00.000Z', '2026-11-29T12:00:00.000Z');++ const deletion = await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(delId);++ await accountData.erase(db, deletion, { now: Date.parse('2026-11-29T12:00:00Z') });++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states WHERE subject = ?').get(ana.subject_id)).n, 0, 'the Dock state is erased with the person');++++ // The migration is expand-only.++ const migration = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0031_dock_states.sql'), 'utf8');++ assert.match(migration, /^-- phase: expand$/m, 'the migration is expand-only');++} finally {++ server.close();++ console.log = log;++}++console.log('dock: ok');++process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -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']]);++ 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']]);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');
@@ -26,6 +26,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -26,6 +26,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).
+- **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).
---
---
@@ -391,6 +392,31 @@ Quiet hours and per-category digests are built (migration `0027_notification_qui
@@ -391,6 +392,31 @@ Quiet hours and per-category digests are built (migration `0027_notification_qui
---
---
+## The Dock's state
+
+The Dock (plan 2026-10-30, K-D1) is one small floating surface on every OpenVibe site: your agent, your chats and
+your activity. It is drawn on many domains and `localStorage` is per site, so the state it follows you with lives in
+the Network (`server/dock/`, migration `0031_dock_states.sql`, contracts 0.251.0).
+
+- `GET /api/v1/me/dock` answers `network.dock-result@1`: the person's `network.dock-state@1` (the stored one, or the
+ default — closed, on the activity tab, not pinned, bottom-right, the site's own size), the `counts` the pill's
+ status dot is drawn from (`running` and `waiting` from the Action Center's aggregator, `unread` from the
+ notification service) and `sources_partial`, true when an authority behind those counts did not answer.
+- `PUT /api/v1/me/dock` takes a partial body — any subset of the state's fields — merges it into the stored state,
+ validates the result against `network.dock-state@1` (an invalid body is `422` with the contract's problem shape)
+ and answers the new `network.dock-result@1`. `place` merges per device class, so a body naming only `place.phone`
+ leaves the desktop where it was.
+- The subject comes from the caller's token and from nowhere else, so one person never reads or writes another's
+ state. The caller's own session only: the `ov_token` cookie on openvibe.network, a Bearer user token elsewhere,
+ with the CORS allow-list and the CSRF rules the other `/api/v1/me/*` routes use.
+- A write is limited to 60 a minute per person (`429 dock.rate_limited` past that); the page debounces anyway.
+- The row is the person's own: it goes into their account export and is erased with them (ADR-033), and a merge
+ (ADR-029) leaves it with the surviving account.
+
+The full reference is [docs/dock.md](docs/dock.md).
+
+---
+
## Email Setup
## Email Setup
Email is optional — the notification system works without it. Email is only sent for **CRITICAL** priority notifications.
Email is optional — the notification system works without it. Email is only sent for **CRITICAL** priority notifications.
@@ -783,7 +809,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -783,7 +809,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
MIT (`license` in [package.json](package.json)).
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.238.0
+- openvibe-contracts: v0.251.0
- openvibe-sdk: v0.74.0
- openvibe-sdk: v0.74.0
- openvibe-shared: v4.47.2
- openvibe-shared: v4.47.2
<!-- versions:end -->
<!-- versions:end -->
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
- "contracts": "openvibe-contracts v0.238.0",
+ "contracts": "openvibe-contracts v0.251.0",
"sdk": "openvibe-sdk v0.74.0",
"sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.47.2",
"shared": "openvibe-shared v4.47.2",
"capabilities": [
"capabilities": [
@@ -46,7 +46,8 @@
@@ -46,7 +46,8 @@
"operator alerts relay target and admin panel",
"operator alerts relay target and admin panel",
"serves /shared/* from its pinned openvibe-shared",
"serves /shared/* from its pinned openvibe-shared",
"per-actor limits (openvibe-sdk/limits)",
"per-actor limits (openvibe-sdk/limits)",
- "universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)"
+ "universal telemetry: per-route aggregated platform.telemetry-sample@1 plus per-flush HTTP autoscaling gauges (openvibe-sdk/telemetry)",
+ "the Dock's state per person: GET and PUT /api/v1/me/dock (network.dock-state@1, network.dock-result@1)"
],
],
"updated": "2026-10-30"
"updated": "2026-10-30"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+# The Dock's state
+
+The Dock (plan 2026-10-30, K-D1) is one small floating surface on every OpenVibe site: your agent, your chats and
+your activity. It is drawn on many domains and `localStorage` is per site, so the state it follows you with lives in
+the Network. This note is the API reference for the two routes that read and write it.
+
+Contracts 0.251.0: `network.dock-state@1` (the state) and `network.dock-result@1` (what the routes answer).
+Implementation: `server/dock/index.js`, migration `0031_dock_states.sql`.
+
+## The two routes
+
+Both are the person's own: the subject comes from the caller's token and from nowhere else, so one person never
+reads or writes another's state. They take the person's own session — the `ov_token` cookie on openvibe.network, a
+Bearer user token anywhere else — and the CORS allow-list and the CSRF rules are exactly the ones the other
+`/api/v1/me/*` routes use (the first-party origin allow-list in `server/index.js`; a foreign origin is refused
+server-side).
+
+| Route | Answer | Notes |
+|---|---|---|
+| `GET /api/v1/me/dock` | `network.dock-result@1` | `Cache-Control: private, no-store` |
+| `PUT /api/v1/me/dock` | `network.dock-result@1` | a partial body, merged into the stored state |
+
+### `GET /api/v1/me/dock`
+
+```json
+{
+ "state": {
+ "open": false,
+ "tab": "activity",
+ "pinned": false,
+ "agent": null,
+ "chat": null,
+ "place": {
+ "phone": { "corner": "bottom-right" },
+ "desktop": { "corner": "bottom-right", "width": null, "height": null }
+ },
+ "updated_at": "2026-10-30T12:00:00Z"
+ },
+ "counts": { "running": 2, "waiting": 1, "unread": 4 },
+ "sources_partial": false
+}
+```
+
+`state` is the stored state, or the default (closed, on the activity tab, not pinned, bottom-right, the site's own
+size) when the person has never opened the Dock.
+
+`counts` is what the pill's status dot is drawn from: `running` and `waiting` come from the Action Center's
+aggregator (`server/actions/index.js` `forPerson`, which keeps its answer for 10 s, so a page view never fans out
+more than that), `unread` from the notification service's unread count. `sources_partial` is true when an authority
+behind those counts did not answer, so the counts are lower than the truth — the page shows the dot anyway.
+
+### `PUT /api/v1/me/dock`
+
+The body is a **partial** state: any subset of the state's fields. The rest is left as it was.
+
+```json
+{ "open": true, "tab": "chats", "place": { "desktop": { "corner": "top-left" } } }
+```
+
+| Field | How it merges |
+|---|---|
+| `open`, `tab`, `pinned`, `chat` | replaced. `chat: null` closes the conversation. |
+| `agent` | replaced whole; `null` means the Agent tab follows none. |
+| `place` | merged per device class, so a body naming only `place.phone` leaves the desktop where it was. Inside a class, each field is replaced on its own (`{ "place": { "desktop": { "width": 500 } } }` keeps the corner and the height). |
+| `updated_at` | the server's own; a value in the body is ignored. |
+
+A body that is not a valid state is refused with `422` and the contract's problem shape
+(`errors.problem@1`: `type`, `title`, `status`, `code`, `detail` and the `errors` array with the path and the
+message of each fault). A key the contract does not know is refused like any other fault, not quietly dropped.
+
+A write is limited to **60 a minute per person**; past that the answer is `429` with the code `dock.rate_limited`.
+The page debounces anyway, and a runaway tab must not fill the table.
+
+## The state
+
+| Field | Meaning |
+|---|---|
+| `open` | open (a sheet on a phone, a floating card on a desktop) or collapsed to its pill |
+| `tab` | `agent`, `chats` or `activity` |
+| `pinned` | keep the pill on screen even when nothing runs and nothing waits |
+| `agent` | `{ service: 'actor' \| 'codes' \| 'run' \| 'desks', id }` — the agent the Agent tab follows — or `null` |
+| `chat` | the conversation the Chats tab has open, or `null` |
+| `place.phone.corner` | `bottom-right`, `bottom-left`, `top-right` or `top-left` |
+| `place.desktop` | the same `corner`, plus `width` (320..720) and `height` (360..900), each `null` for the site's own default size |
+| `updated_at` | when the state was last written |
+
+Every object refuses extra keys, so a page cannot smuggle a field into the state another page reads.
+
+## Account data
+
+The row is the person's own, so it goes into their account export (`network/dock.json` in Network's part of the
+zip) and is erased with them (ADR-033). A merge (ADR-029) leaves it with the surviving account: the survivor's row
+wins, and a folded-in row moves over only when the survivor has none.
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+-- phase: expand
+-- The Dock's state per person (the Dock plan 2026-10-30, K-D1): the little floating surface every OpenVibe site
+-- draws — your agent, your chats, your activity. It is drawn on many domains and localStorage is per site, so the
+-- state it follows you with lives here, in the Network.
+--
+-- One row per person: the state is network.dock-state@1 (Contracts 0.251.0) as a JSON value. The row is the
+-- person's own, so it goes into their account export and is erased with them (ADR-033), and a merge (ADR-029)
+-- leaves it with the surviving account.
+--
+-- Expand only: an older release never reads this table, and nothing here changes an existing one.
+CREATE TABLE IF NOT EXISTS dock_states (
+ subject text COLLATE "C" PRIMARY KEY,
+ state jsonb NOT NULL,
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.251.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.47.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.47.2",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1595,9 +1595,9 @@
@@ -1595,9 +1595,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.238.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
- "integrity": "sha512-BQOsqor9ew75s2ElWK6g6pWk0gr5nbwzgfzMKJPBPIjnDnIkUQfNmsYw8AeexW5qeotCmfTu4lSPdqy6ZQaq5g==",
+ "version": "0.251.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.251.0",
+ "integrity": "sha512-aDTGrk/0FujqQT1IeL0hmhNnK4azNj579nn1LCm2cdt38OWSdNYyvazb/+rAHM7erCXcJrxEzBdlHd37HK+vJw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.251.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.47.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.47.2",
"pg": "^8.23.0",
"pg": "^8.23.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -0,0 +1,193 @@
+'use strict';
+/**
+ * The Dock's state (the Dock plan 2026-10-30, K-D1; Contracts 0.251.0): the little floating surface every OpenVibe
+ * site draws — your agent, your chats, your activity. It is drawn on many domains and localStorage is per site, so
+ * the state it follows you with lives here, in the Network.
+ *
+ * GET /api/v1/me/dock network.dock-result@1: the state, the counts, and whether those counts are complete
+ * PUT /api/v1/me/dock network.dock-result@1: a partial body (any subset of the state's fields) merged into it
+ *
+ * The subject comes from the caller's token and from nowhere else, so one person never reads or writes another's
+ * state. The counts come from the Action Center's aggregator (server/actions/index.js forPerson, which keeps its
+ * answer for 10 s, so a page view never fans out more than that) and from the notification service's unread count.
+ *
+ * A write is limited to 60 a minute per person: the page debounces anyway, and a runaway tab must not fill the
+ * table. Every write is validated against network.dock-state@1 before it is stored, so a page can never leave a
+ * half-formed state behind for the next page to read.
+ */
+const express = require('express');
+const { validate, http } = require('openvibe-contracts');
+const store = require('../developer/store');
+
+const CONTRACT = 'network.dock-state@1';
+const WRITE_LIMIT = 60;
+const WRITE_WINDOW_MS = 60_000;
+// The state a person has before they ever open the Dock: closed, on the activity tab, not pinned, bottom-right,
+// and the site's own size.
+const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
+
+const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+
+/** The default state, for a person who has never opened the Dock. */
+function defaultState(at) {
+ return {
+ open: false, tab: 'activity', pinned: false, agent: null, chat: null,
+ place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } },
+ updated_at: new Date(at).toISOString(),
+ };
+}
+
+/**
+ * Merge a partial body into the current state.
+ *
+ * `open`, `tab`, `pinned` and `chat` are replaced; `agent` is replaced whole (null clears it); `place` is merged
+ * per device class, so a body that names only `place.phone` leaves the desktop where it was. A key the contract
+ * does not know is carried into the result rather than dropped, so validation refuses the body instead of quietly
+ * accepting it.
+ */
+function mergeState(current, patch) {
+ const out = { ...current };
+ for (const [key, value] of Object.entries(patch)) {
+ if (key === 'place' && isObject(value)) {
+ const place = { ...(isObject(current.place) ? current.place : {}) };
+ for (const [cls, v] of Object.entries(value)) {
+ place[cls] = (cls === 'phone' || cls === 'desktop') && isObject(v)
+ ? { ...(isObject(place[cls]) ? place[cls] : {}), ...v }
+ : v;
+ }
+ out.place = place;
+ } else {
+ out[key] = value;
+ }
+ }
+ return out;
+}
+
+/**
+ * The Dock for one person.
+ * db the Network database
+ * actions the Action Center's aggregator ({ forPerson(actor) }) — the running and waiting counts
+ * notifications the notification service ({ getUnreadCount(userId) }) — the unread count
+ */
+function createDock({ db, actions = null, notifications = null, now = () => Date.now(), writeLimit = WRITE_LIMIT, writeWindowMs = WRITE_WINDOW_MS, log = console } = {}) {
+ if (!db) throw new TypeError('createDock: db is required');
+ const writes = new Map();
+
+ /** The person's stored state, or the default. */
+ async function stateOf(actor, at) {
+ const row = await db.prepare('SELECT state FROM dock_states WHERE subject = ?').get(actor.subject);
+ if (!row) return defaultState(at);
+ let state = row.state;
+ if (typeof state === 'string') { try { state = JSON.parse(state); } catch { state = null; } }
+ // A row written by an older release (or by hand) is still shown, but only if it is a state the contract
+ // accepts: a broken row falls back to the default rather than breaking the page that reads it.
+ return state && validate(CONTRACT, state).valid ? state : defaultState(at);
+ }
+
+ /** The counts the pill's status dot is drawn from. */
+ async function countsOf(actor) {
+ let running = 0;
+ let waiting = 0;
+ let partial = false;
+ if (actions && typeof actions.forPerson === 'function') {
+ try {
+ const doc = await actions.forPerson(actor);
+ running = Number(doc && doc.counts && doc.counts.running) || 0;
+ waiting = Number(doc && doc.counts && doc.counts.waiting) || 0;
+ partial = !!(doc && doc.partial);
+ } catch (err) {
+ partial = true;
+ log.warn('[Dock] the Action Center did not answer:', err.message);
+ }
+ } else {
+ partial = true;
+ }
+ let unread = 0;
+ if (notifications && typeof notifications.getUnreadCount === 'function') {
+ try {
+ unread = Number(await notifications.getUnreadCount(actor.user.id)) || 0;
+ } catch (err) {
+ partial = true;
+ log.warn('[Dock] the unread count did not answer:', err.message);
+ }
+ } else {
+ partial = true;
+ }
+ return { counts: { running, waiting, unread }, sources_partial: partial };
+ }
+
+ /** network.dock-result@1 for one person. */
+ async function read(actor) {
+ const at = now();
+ const state = await stateOf(actor, at);
+ const { counts, sources_partial: partial } = await countsOf(actor);
+ return { state, counts, sources_partial: partial };
+ }
+
+ /** 60 writes a minute, per person. */
+ function mayWrite(subject, at) {
+ const kept = (writes.get(subject) || []).filter((t) => at - t < writeWindowMs);
+ if (kept.length >= writeLimit) { writes.set(subject, kept); return false; }
+ kept.push(at);
+ writes.set(subject, kept);
+ if (writes.size > 5000) writes.delete(writes.keys().next().value);
+ return true;
+ }
+
+ /** Merge a partial body into the person's state and store it. → network.dock-result@1 */
+ async function write(actor, body, { ctx = null } = {}) {
+ const at = now();
+ if (!mayWrite(actor.subject, at)) {
+ throw Object.assign(new Error('too many writes; wait a minute'), { status: 429, code: 'dock.rate_limited' });
+ }
+ if (!isObject(body)) {
+ throw Object.assign(new Error('the body must be the state to change'), { status: 422, code: 'dock.invalid' });
+ }
+ const current = await stateOf(actor, at);
+ const merged = { ...mergeState(current, body), updated_at: new Date(at).toISOString() };
+ const check = validate(CONTRACT, merged);
+ if (!check.valid) {
+ throw Object.assign(new Error('that is not a Dock state'), { status: 422, code: 'dock.invalid', errors: check.errors });
+ }
+ await db.prepare(`INSERT INTO dock_states (subject, state, updated_at) VALUES (?, ?, now())
+ ON CONFLICT (subject) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at`)
+ .run(actor.subject, JSON.stringify(merged));
+ const { counts, sources_partial: partial } = await countsOf(actor);
+ return { state: merged, counts, sources_partial: partial };
+ }
+
+ return { read, write, stateOf, defaultState, _writes: writes };
+}
+
+/** The routes, behind Network's session check (the cookie on openvibe.network, a Bearer token elsewhere), like the bell's. */
+function router({ dock, requireAuth }) {
+ const r = express.Router();
+ r.use(http.middleware());
+ r.use(express.json({ limit: '8kb' }));
+ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
+ r.get('/', requireAuth, async (req, res) => {
+ try {
+ const actor = await store.actorOf(req.app.locals.db, req.user);
+ res.json(await dock.read(actor));
+ } catch (err) {
+ if (err && err.status) return http.sendProblem(res, err.status, err.code || 'dock.error', { detail: err.message, ctx: req.ov });
+ console.error('[Dock] read:', err);
+ http.sendProblem(res, 500, 'dock.error', { detail: 'could not read your Dock', ctx: req.ov });
+ }
+ });
+ r.put('/', requireAuth, async (req, res) => {
+ try {
+ const actor = await store.actorOf(req.app.locals.db, req.user);
+ res.json(await dock.write(actor, req.body, { ctx: req.ov }));
+ } catch (err) {
+ if (err && err.status) {
+ return http.sendProblem(res, err.status, err.code || 'dock.error', { detail: err.message, errors: err.errors, ctx: req.ov });
+ }
+ console.error('[Dock] write:', err);
+ http.sendProblem(res, 500, 'dock.error', { detail: 'could not save your Dock', ctx: req.ov });
+ }
+ });
+ return r;
+}
+
+module.exports = { createDock, router, defaultState, mergeState, CONTRACT, WRITE_LIMIT, WRITE_WINDOW_MS, CORNERS };
@@ -187,6 +187,9 @@ async function networkPart(db, userId) {
@@ -187,6 +187,9 @@ async function networkPart(db, userId) {
theme_site_overrides: (await rowsOf(db, 'user_theme_site_overrides', 'user_id = ?', [u.id])).rows,
theme_site_overrides: (await rowsOf(db, 'user_theme_site_overrides', 'user_id = ?', [u.id])).rows,
});
});
await byUser('history.json', 'user_history');
await byUser('history.json', 'user_history');
+ // The Dock's state (the Dock plan 2026-10-30, K-D1): the little floating surface every OpenVibe site draws.
+ add('dock.json', await tableExists(db, 'dock_states')
+ ? await db.prepare('SELECT subject, state, updated_at FROM dock_states WHERE subject = ?').all(u.subject_id) : []);
add('username_history.json', (await rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] })).rows);
add('username_history.json', (await rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] })).rows);
await byUser('profile_changes.json', 'user_profile_changes');
await byUser('profile_changes.json', 'user_profile_changes');
await byUser('sign_in_log.json', 'ip_log');
await byUser('sign_in_log.json', 'ip_log');
@@ -344,6 +347,8 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -344,6 +347,8 @@ 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');
+ // The Dock's state (the Dock plan 2026-10-30, K-D1): the person's own row, gone with them.
+ await del('dock_states', `subject IN ${inS}`, subjects, 'dock_states');
await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
// The person's agents first (WS-Z2), then the agents of the projects archived below.
// The person's agents first (WS-Z2), then the agents of the projects archived below.
@@ -174,6 +174,13 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
@@ -174,6 +174,13 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
}
}
}
}
}
}
+ // The Dock's state (the Dock plan 2026-10-30, K-D1): the survivor's row wins. A folded-in row moves over
+ // only when the survivor has none; when both have one, the survivor's is the one that stays.
+ if (await tableExists(db, 'dock_states')) {
+ const mine = await db.prepare('SELECT 1 FROM dock_states WHERE subject = ?').get(into);
+ if (mine) await db.prepare('DELETE FROM dock_states WHERE subject = ?').run(from);
+ else await db.prepare('UPDATE dock_states SET subject = ? WHERE subject = ?').run(into, from);
+ }
await moveByUserId(db, 'notifications', 'user_id', fromUser.id, intoUser.id);
await moveByUserId(db, 'notifications', 'user_id', fromUser.id, intoUser.id);
await moveByUserId(db, 'push_subscriptions', 'user_id', fromUser.id, intoUser.id);
await moveByUserId(db, 'push_subscriptions', 'user_id', fromUser.id, intoUser.id);
note('preferences', await moveByUserId(db, 'notification_preferences', 'user_id', fromUser.id, intoUser.id, ['category']));
note('preferences', await moveByUserId(db, 'notification_preferences', 'user_id', fromUser.id, intoUser.id, ['category']));
@@ -638,6 +638,11 @@ require('./developer/confirmations').start(db);
@@ -638,6 +638,11 @@ require('./developer/confirmations').start(db);
domains: process.env.OV_DOMAINS_INTERNAL_URL || 'http://127.0.0.1:5070',
domains: process.env.OV_DOMAINS_INTERNAL_URL || 'http://127.0.0.1:5070',
} });
} });
app.use('/api/v1/me/actions', rateLimit({ windowMs: 60_000, max: 120 }), require('./actions').router({ actions, requireAuth }));
app.use('/api/v1/me/actions', rateLimit({ windowMs: 60_000, max: 120 }), require('./actions').router({ actions, requireAuth }));
+ // The Dock's state (the Dock plan 2026-10-30, K-D1, server/dock): one row per person, so the little floating
+ // surface every OpenVibe site draws follows them across every domain (localStorage is per site). Its counts are
+ // the Action Center's own, so a page view never fans out further than the aggregator's 10 s cache already does.
+ const dock = require('./dock').createDock({ db, actions, notifications: notificationService });
+ app.use('/api/v1/me/dock', require('./dock').router({ dock, requireAuth }));
}
}
// Notification API (authenticated users)
// Notification API (authenticated users)
@@ -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']]);
+ 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']]);
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,237 @@
@@ -0,0 +1,237 @@
+'use strict';
+// The Dock's state (server/dock, the Dock plan 2026-10-30, K-D1): GET and PUT /api/v1/me/dock answer network.dock-result@1.
+// The Dock is the little floating surface every OpenVibe site draws, and localStorage is per site, so the state it
+// follows the person with lives in the Network.
+//
+// What this proves:
+// - the default state for a person who has never opened the Dock, and the counts beside it;
+// - a partial PUT merges into the stored state (place merges per device class), and an invalid body is refused
+// with the contract's problem shape (an unknown tab, a width of 2000, a key the contract does not know);
+// - the counts come from the Action Center's aggregator and the notification service, and sources_partial says
+// when an authority did not answer;
+// - one person never reads or writes another's state: the subject comes from the token and nowhere else;
+// - CORS: an OpenVibe site may ask, another origin is refused (and a cross-origin write never runs the route);
+// - 60 writes a minute per person;
+// - the row goes into the person's export and is erased with them, and a merge leaves it with the survivor;
+// - the migration is expand-only.
+// node --import ./test/helpers/pg-preload.mjs test/dock.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const express = require('express');
+const { ids, validate } = require('openvibe-contracts');
+const { uncovered } = require('openvibe-sdk/account-data');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const dockMod = require('../server/dock');
+const accountData = require('../server/identity/account-data');
+const accountMerge = require('../server/identity/account-merge');
+const publicCors = require('../server/public-cors');
+const authRoutes = require('../server/auth/routes');
+
+(async () => {
+const db = getDb();
+const log = console.log; console.log = () => {};
+
+// Two people, each with a canonical subject: the Dock's row belongs to one of them, never to both.
+// A jsonb column comes back already parsed from some drivers and as text from others.
+const asJson = (v) => (typeof v === 'string' ? JSON.parse(v) : v);
+const mk = async (name) => {
+ const id = (await db.prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?) RETURNING id').run(name, `${name}@example.test`, 'x')).lastInsertRowid;
+ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+};
+const ana = await mk('dockana');
+const bob = await mk('dockbob');
+
+// The Action Center's aggregator and the notification service, faked: the Dock asks them and shows what they say.
+const seen = [];
+const aggregator = { forPerson: async (actor) => { seen.push(actor.subject); return { counts: { running: 2, waiting: 1, scheduled: 0, done: 0, failed: 0 }, partial: false }; } };
+const notifications = { getUnreadCount: async (userId) => (userId === ana.id ? 4 : 7) };
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
+const tokenOf = (u) => authRoutes.signToken(u, keys.privateKey, config);
+
+const dock = dockMod.createDock({ db, actions: aggregator, notifications });
+const app = express();
+Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+// The CORS the whole app runs behind (server/index.js): the first-party allow-list, and a foreign origin refused.
+const allowed = new Set(['https://openvibe.blog', 'https://openvibe.network']);
+const corsGuard = publicCors.originGuard((origin) => allowed.has(origin), { log: { warn() {} } });
+app.use(publicCors.gate(publicCors.restrictedCors(corsGuard.origin)));
+app.use(corsGuard.denied);
+app.use('/api/v1/me/dock', dockMod.router({ dock, requireAuth }));
+const server = http.createServer(app);
+await new Promise((r) => server.listen(0, '127.0.0.1', r));
+const api = `http://127.0.0.1:${server.address().port}/api/v1/me/dock`;
+const call = (method, body, { as = ana, origin = null } = {}) => fetch(api, {
+ method,
+ headers: { authorization: `Bearer ${tokenOf(as)}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(origin ? { origin } : {}) },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})), headers: r.headers }));
+
+try {
+ // The default state: closed, on the activity tab, not pinned, bottom-right, the site's own size.
+ let r = await call('GET');
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.ok(validate('network.dock-result@1', r.body).valid, JSON.stringify(validate('network.dock-result@1', r.body).errors));
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.deepStrictEqual(r.body.state, {
+ open: false, tab: 'activity', pinned: false, agent: null, chat: null,
+ place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } },
+ updated_at: r.body.state.updated_at,
+ });
+ assert.ok(r.body.state.updated_at, 'the default state carries a time');
+ assert.deepStrictEqual(r.body.counts, { running: 2, waiting: 1, unread: 4 }, 'the counts come from the aggregator and the notifications');
+ assert.strictEqual(r.body.sources_partial, false);
+
+ // A session is needed; another person's token reads their own state, never this one's.
+ assert.strictEqual((await fetch(api, { method: 'GET' })).status, 401, 'a session is needed');
+ r = await call('GET', undefined, { as: bob });
+ assert.deepStrictEqual(r.body.state.place, { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, "bob sees his own default");
+ assert.deepStrictEqual(r.body.counts, { running: 2, waiting: 1, unread: 7 }, 'the unread count is the caller\'s own');
+
+ // A partial write merges into the stored state.
+ r = await call('PUT', { open: true, tab: 'chats' });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.ok(validate('network.dock-result@1', r.body).valid);
+ assert.deepStrictEqual([r.body.state.open, r.body.state.tab, r.body.state.pinned], [true, 'chats', false], 'pinned was not in the body and stayed');
+ r = await call('GET');
+ assert.deepStrictEqual([r.body.state.open, r.body.state.tab], [true, 'chats'], 'the state is stored');
+
+ // place merges per device class, and inside a class per field.
+ r = await call('PUT', { place: { desktop: { width: 500 } } });
+ assert.deepStrictEqual(r.body.state.place, { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: 500, height: null } }, 'the corner and the height stayed');
+ r = await call('PUT', { place: { phone: { corner: 'top-left' } } });
+ assert.deepStrictEqual(r.body.state.place, { phone: { corner: 'top-left' }, desktop: { corner: 'bottom-right', width: 500, height: null } }, 'the desktop stayed');
+ r = await call('PUT', { place: { desktop: { corner: 'top-right', height: 700 } } });
+ assert.deepStrictEqual(r.body.state.place, { phone: { corner: 'top-left' }, desktop: { corner: 'top-right', width: 500, height: 700 } });
+
+ // The agent and the chat: replaced whole, and null clears them.
+ r = await call('PUT', { agent: { service: 'actor', id: 'tsk_1' }, chat: 'cnv_1' });
+ assert.deepStrictEqual(r.body.state.agent, { service: 'actor', id: 'tsk_1' });
+ assert.strictEqual(r.body.state.chat, 'cnv_1');
+ r = await call('PUT', { agent: null });
+ assert.strictEqual(r.body.state.agent, null, 'null clears the agent');
+ assert.strictEqual(r.body.state.chat, 'cnv_1', 'the chat was not in the body and stayed');
+
+ // An invalid body is refused with the contract's problem shape.
+ for (const [body, why] of [
+ [{ tab: 'profile' }, 'an unknown tab'],
+ [{ place: { desktop: { width: 2000 } } }, 'a width of 2000'],
+ [{ place: { desktop: { height: 10 } } }, 'a height under 360'],
+ [{ theme: 'neon-tokyo' }, 'a key the contract does not know'],
+ [{ place: { tablet: { corner: 'top-left' } } }, 'a device class the contract does not know'],
+ [{ chat: '' }, 'an empty chat id'],
+ ]) {
+ const before = (await call('GET')).body.state;
+ r = await call('PUT', body);
+ assert.strictEqual(r.status, 422, `${why}: ${JSON.stringify(r.body)}`);
+ assert.strictEqual(r.body.code, 'dock.invalid', why);
+ assert.strictEqual(r.body.status, 422);
+ assert.ok(r.body.type && r.body.title, 'the problem carries a type and a title');
+ assert.ok(Array.isArray(r.body.errors) && r.body.errors.length, `${why}: the faults are listed`);
+ assert.ok(r.body.errors.every((e) => typeof e.message === 'string'), 'each fault has a message');
+ assert.deepStrictEqual((await call('GET')).body.state, before, `${why}: nothing was stored`);
+ }
+
+ // One person never reads or writes another's state: the subject comes from the token and nowhere else.
+ const anaRow = await db.prepare('SELECT subject, state FROM dock_states WHERE subject = ?').get(ana.subject_id);
+ assert.ok(anaRow, "ana's row is his own");
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states').get()).n, 1, "bob's write made no row of his own");
+ r = await call('PUT', { tab: 'agent' }, { as: bob });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.state.tab, 'agent', "bob's own state");
+ assert.strictEqual(r.body.state.pinned, false, "bob's state is his own default plus his own write");
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states').get()).n, 2, 'one row each');
+ const anaAgain = await db.prepare('SELECT state FROM dock_states WHERE subject = ?').get(ana.subject_id);
+ assert.deepStrictEqual(asJson(anaAgain.state), asJson(anaRow.state), "bob's write left ana's state alone");
+ assert.ok(seen.every((s) => s === ana.subject_id || s === bob.subject_id), 'the aggregator was asked about the caller only');
+
+ // CORS: an OpenVibe site may ask; another origin is refused, and its write never runs the route.
+ let cors = await fetch(api, { headers: { origin: 'https://openvibe.blog', authorization: `Bearer ${tokenOf(ana)}` } });
+ assert.strictEqual(cors.status, 200, 'an OpenVibe site may read the Dock');
+ assert.strictEqual(cors.headers.get('access-control-allow-origin'), 'https://openvibe.blog', 'the answer names the origin');
+ cors = await fetch(api, { method: 'OPTIONS', headers: { origin: 'https://openvibe.blog', 'access-control-request-method': 'PUT' } });
+ assert.ok(cors.status === 204 || cors.status === 200, `the preflight is answered: ${cors.status}`);
+ cors = await fetch(api, { headers: { origin: 'https://evil.example', authorization: `Bearer ${tokenOf(ana)}` } });
+ assert.strictEqual(cors.status, 403, 'another origin is refused');
+ assert.strictEqual((await cors.json()).code, 'cors.origin_denied');
+ cors = await fetch(api, { method: 'PUT', headers: { origin: 'https://evil.example', authorization: `Bearer ${tokenOf(ana)}`, 'content-type': 'application/json' }, body: JSON.stringify({ tab: 'chats' }) });
+ assert.strictEqual(cors.status, 403, 'a cross-origin write never runs the route');
+ assert.deepStrictEqual(asJson((await db.prepare('SELECT state FROM dock_states WHERE subject = ?').get(ana.subject_id)).state), asJson(anaAgain.state), "the refused write changed nothing");
+
+ // 60 writes a minute, per person.
+ const limiter = dockMod.createDock({ db, actions: aggregator, notifications });
+ const limitApp = express();
+ Object.assign(limitApp.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+ limitApp.use('/api/v1/me/dock', dockMod.router({ dock: limiter, requireAuth }));
+ const limitServer = http.createServer(limitApp);
+ await new Promise((r) => limitServer.listen(0, '127.0.0.1', r));
+ const limitApi = `http://127.0.0.1:${limitServer.address().port}/api/v1/me/dock`;
+ const limited = (body) => fetch(limitApi, { method: 'PUT', headers: { authorization: `Bearer ${tokenOf(ana)}`, 'content-type': 'application/json' }, body: JSON.stringify(body) });
+ for (let i = 0; i < dockMod.WRITE_LIMIT; i++) {
+ const w = await limited({ pinned: true });
+ assert.strictEqual(w.status, 200, `write ${i + 1} of ${dockMod.WRITE_LIMIT}`);
+ }
+ const over = await limited({ pinned: false });
+ assert.strictEqual(over.status, 429, 'the 61st write in a minute is refused');
+ assert.strictEqual((await over.json()).code, 'dock.rate_limited');
+ limitServer.close();
+
+ // The counts say so when an authority does not answer.
+ const brokenDock = dockMod.createDock({ db, actions: { forPerson: async () => { throw new Error('the aggregator is down'); } }, notifications: { getUnreadCount: async () => { throw new Error('the notifications are down'); } }, log: { warn() {} } });
+ const brokenApp = express();
+ Object.assign(brokenApp.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+ brokenApp.use('/api/v1/me/dock', dockMod.router({ dock: brokenDock, requireAuth }));
+ const brokenServer = http.createServer(brokenApp);
+ await new Promise((r) => brokenServer.listen(0, '127.0.0.1', r));
+ r = await fetch(`http://127.0.0.1:${brokenServer.address().port}/api/v1/me/dock`, { headers: { authorization: `Bearer ${tokenOf(ana)}` } }).then((x) => x.json());
+ assert.strictEqual(r.sources_partial, true, 'the counts are lower than the truth, and the answer says so');
+ assert.deepStrictEqual(r.counts, { running: 0, waiting: 0, unread: 0 }, 'and it still answers');
+ brokenServer.close();
+
+ // The row is the person's own: it goes into their export and is erased with them (ADR-033).
+ // The SDK's uncovered() guard (0.74.0) sees every table with a person-shaped column a service's account-data
+ // module does not cover. Network is the hub and keeps its own rows in server/identity/account-data.js, so the
+ // guard here is that dock_states is one of the tables Network must answer for.
+ const personTables = await uncovered(db, {});
+ assert.ok(personTables.includes('dock_states'), 'the guard sees dock_states: it names a person');
+ const part = await accountData.networkPart(db, ana.id);
+ const dockFile = part.files.find((f) => f.name === 'dock.json');
+ assert.ok(dockFile, "ana's export carries his Dock state");
+ const exported = dockFile.content;
+ assert.strictEqual(exported.length, 1);
+ assert.strictEqual(exported[0].subject, ana.subject_id, 'the export carries his own row');
+ assert.strictEqual(asJson(exported[0].state).tab, 'chats', 'the exported state is the one ana stored');
+ const bobPart = await accountData.networkPart(db, bob.id);
+ assert.deepStrictEqual(bobPart.files.find((f) => f.name === 'dock.json').content.map((x) => x.subject), [bob.subject_id], "bob's export carries only his own row");
+
+ // The merge (ADR-029) leaves the Dock with the surviving account.
+ const survivor = await db.prepare('SELECT * FROM users WHERE id = ?').get(ana.id);
+ await accountMerge.merge(db, bob, survivor, { initiatedBy: 'person' });
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states WHERE subject = ?').get(bob.subject_id)).n, 0, "the folded-in account's row is gone");
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states WHERE subject = ?').get(ana.subject_id)).n, 1, "the survivor keeps the row");
+
+ // And the row is erased with the person.
+ const delId = `del_${ids.ulid()}`;
+ await db.prepare(`INSERT INTO account_deletions (id, user_id, subject, status, requested_at, delete_after) VALUES (?, ?, ?, 'scheduled', ?, ?)`)
+ .run(delId, ana.id, ana.subject_id, '2026-10-30T12:00:00.000Z', '2026-11-29T12:00:00.000Z');
+ const deletion = await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(delId);
+ await accountData.erase(db, deletion, { now: Date.parse('2026-11-29T12:00:00Z') });
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states WHERE subject = ?').get(ana.subject_id)).n, 0, 'the Dock state is erased with the person');
+
+ // The migration is expand-only.
+ const migration = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0031_dock_states.sql'), 'utf8');
+ assert.match(migration, /^-- phase: expand$/m, 'the migration is expand-only');
+} finally {
+ server.close();
+ console.log = log;
+}
+console.log('dock: ok');
+process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -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']]);
+ 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']]);
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');