openvibers/openvibe.network Commits

The Dock's state: GET and PUT /api/v1/me/dock (#437)

OP OpenVibers committed 14 days ago · 7d11add186 · 13 files, +592 −10

The Dock (plan 2026-10-30, K-D1; Shared 4.51.0) is drawn on every OpenVibe
site and localStorage is per site, so its state lives here: one row per
person (migration 0031_dock_states, expand), network.dock-state@1 as JSON.

- GET answers network.dock-result@1: the stored state or the default, the
  counts the pill is drawn from (running and waiting from the Action
  Center's aggregator and its 10 s cache, unread from the notification
  service) and sources_partial when one of them did not answer.
- PUT merges a partial body (place per device class), validates the result
  against the contract (422 with its problem shape), stores it; 60 writes a
  minute per person.
- The subject comes from the token only. The CORS allow-list and CSRF rules
  are the other /api/v1/me/* routes'.
- The row is exported and erased with the person; a merge keeps the
  survivor's.
- Contracts 0.238.0 -> 0.251.0 for the two schemas.

Changes

ddiff --git a/README.md b/README.md
iindex f15fa606..0acbd6bf 100644
---- a/README.md
++++ b/README.md
@@@ -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 Setup
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:
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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex c8870dac..166593e4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/docs/dock.md b/docs/dock.md
nnew file mode 100644
iindex 00000000..72c3449d
---- /dev/null
++++ b/docs/dock.md
@@@ -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.
ddiff --git a/migrations/0031_dock_states.sql b/migrations/0031_dock_states.sql
nnew file mode 100644
iindex 00000000..313ee510
---- /dev/null
++++ b/migrations/0031_dock_states.sql
@@@ -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()
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex f969b827..7aa2de41 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 488d378b..417534b6 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/dock/index.js b/server/dock/index.js
nnew file mode 100644
iindex 00000000..a7cab2ae
---- /dev/null
++++ b/server/dock/index.js
@@@ -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 };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 0f09e618..2c51047c 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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.
ddiff --git a/server/identity/account-merge.js b/server/identity/account-merge.js
iindex 12eaa799..cf212366 100644
---- a/server/identity/account-merge.js
++++ b/server/identity/account-merge.js
@@@ -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']));
ddiff --git a/server/index.js b/server/index.js
iindex 9c214253..7963121b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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)
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex ff14b906..60e1ad28 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand']]);
++ 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.
ddiff --git a/test/dock.test.js b/test/dock.test.js
nnew file mode 100644
iindex 00000000..6c8c6af1
---- /dev/null
++++ b/test/dock.test.js
@@@ -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); });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex ec67d2c2..16d410da 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand']]);
++ 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 twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');