openvibers/openvibe.network Commits

The Dock's timer, for a first-party service: Vibe can start a person's focus timer (#486)

OP OpenVibers committed 12 days ago · 75143d0327 · 6 files, +395 −8

* The Dock's timer, for a first-party service: Vibe can start a person's focus timer

Plan L48 R2: "Vibe, start a 25-minute focus timer" starts the timer in the
Dock's pill on every OpenVibe site, the way Vibe already sets reminders.

PUT /api/v1/people/:subject/dock/timer takes network.dock-timer-request@1
(the minutes, or clear: true) and answers network.dock-result@1. The route
needs a service token holding network.dock.timer.write (granted to Chat
alone) and names the person in the path; a person's own token never opens
it. The service writes the timer and nothing else: the rest of the Dock
state is left as it was, and the whole state is validated against
network.dock-state@1 before it is stored.

The timer it writes is the shape the Dock reads (openvibe-shared
dock-timer.js start()), so a focus timer alternates its rounds by itself.

Contracts 0.272.0 (the capability, the request contract and the Network
service manifest).

* The timer's service route counts every decision, like the other service routes

The guard's refusals and its allows are written to principal_usage, so the
network's own audit trail says who asked to set a person's Dock timer and
whether it was allowed.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 99e97850..6849b79f 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.271.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.272.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.77.0",
"pg": "^8.23.0",
@@@ -1595,9 +1595,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.271.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.271.0",
-- "integrity": "sha512-pkkF191c+cxm6v90eNXej8qywVcRMK/avsfDKxlGuH/GrYICjceGxuPsCBQZeQ79by0/DqgXaG8tTWYnEs+oFw==",
++ "version": "0.272.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.272.0",
++ "integrity": "sha512-qZc5WDgPcEQinxHPi1/pe4vvI3hoSgrq5rSio9bjeTKj9BTFIIgy33KzOEL9vsW7baGnAlxusnaGuHQUnh9xeg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 487ebc77..8aac8ff8 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.271.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.272.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.77.0",
"pg": "^8.23.0",
ddiff --git a/server/dock/index.js b/server/dock/index.js
iindex a7cab2ae..fef448e1 100644
---- a/server/dock/index.js
++++ b/server/dock/index.js
@@@ -14,14 +14,31 @@
* 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.
++ *
++ * The timer (plan L48 R2; Contracts 0.271.0 for the field, 0.272.0 for the capability) has a second door, for a
++ * first-party service and not for a person:
++ *
++ * PUT /api/v1/people/:subject/dock/timer network.dock.timer.write: set or clear ONE person's timer
++ *
++ * OpenVibe.Chat's Vibe starts the person's focus timer where they are ("Vibe, start a 25-minute focus timer"), and
++ * it counts down in the Dock's pill on every OpenVibe site. The service names the person and writes the timer and
++ * nothing else: every other field of the state is left as it was. The service token must hold
++ * network.dock.timer.write (granted to Chat alone), and a person's own token never opens this route.
*/
const express = require('express');
--const { validate, http } = require('openvibe-contracts');
++const { validate, http, serviceAuth, capabilities } = 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 capability the timer route asks for (Contracts 0.272.0), the body it takes, and the audience the service
++// token is for.
++const SERVICE_CAPABILITY = 'network.dock.timer.write';
++const REQUEST_CONTRACT = 'network.dock-timer-request@1';
++const SELF_AUDIENCE = 'openvibe.network';
++// A person's canonical subject, the only thing the timer route accepts as a person.
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
// 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'];
@@@ -63,6 +80,46 @@ function mergeState(current, patch) {
return out;
}
++/**
++ * The timer a service asks for (network.dock-timer-request@1, Contracts 0.272.0): how long it runs, or that it stops.
++ *
++ * The body is checked against that contract: { minutes } (1 to 180) starts a timer, { clear: true } stops it, and
++ * `kind`, `label` and `rounds` say what kind of timer it is. A key the contract does not know is refused, not
++ * ignored. The timer that comes out of it is checked again against network.dock-state@1, with the rest of the
++ * person's state, before anything is stored.
++ *
++ * → { error, errors } when the body is not one, or null when it is.
++ */
++function badTimer(body) {
++ if (!isObject(body)) {
++ return { error: 'the body must say how long the timer runs, or that it stops', errors: [] };
++ }
++ const check = validate(REQUEST_CONTRACT, body);
++ if (check.valid) return null;
++ const first = (check.errors || [])[0];
++ const field = String((first && first.instancePath) || '').replace(/^\//, '').replace(/\//g, '.');
++ return {
++ error: field ? `${field}: ${first.message}` : String((first && first.message) || 'that is not a timer'),
++ errors: check.errors,
++ };
++}
++
++/**
++ * The timer object, exactly the one the Dock itself writes (openvibe-shared/dock-timer.js start()): the Dock reads
++ * what Vibe writes, so the shape is the same down to the field names. A plain timer carries its kind, its start and
++ * its end; a focus block also carries the round it is in, how many rounds there are, and the length of a round and
++ * of the break after it (so it alternates by itself).
++ */
++function timerOf(body, at) {
++ if (body.clear === true) return null;
++ const minutes = Number(body.minutes);
++ const kind = body.kind === 'focus' ? 'focus' : 'timer';
++ const timer = { kind, started_at: new Date(at).toISOString(), ends_at: new Date(at + minutes * 60000).toISOString() };
++ if (body.label) timer.label = String(body.label).slice(0, 60);
++ if (kind === 'focus') Object.assign(timer, { round: 1, rounds: Number(body.rounds) || 4, focus_min: minutes, break_min: 5 });
++ return timer;
++}
++
/**
* The Dock for one person.
* db the Network database
@@@ -156,7 +213,32 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
return { state: merged, counts, sources_partial: partial };
}
-- return { read, write, stateOf, defaultState, _writes: writes };
++ /**
++ * Set or clear the person's Dock timer. Only the timer changes: every other field of the state stays as it was,
++ * and the whole state is checked against network.dock-state@1 before it is stored, like any other write.
++ * → network.dock-result@1
++ */
++ async function writeTimer(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' });
++ }
++ const bad = badTimer(body);
++ if (bad) throw Object.assign(new Error(bad.error), { status: 422, code: 'dock.invalid', errors: bad.errors });
++ const current = await stateOf(actor, at);
++ const merged = { ...current, timer: timerOf(body, at), 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, writeTimer, stateOf, defaultState, _writes: writes };
}
/** The routes, behind Network's session check (the cookie on openvibe.network, a Bearer token elsewhere), like the bell's. */
@@@ -190,4 +272,83 @@ function router({ dock, requireAuth }) {
return r;
}
--module.exports = { createDock, router, defaultState, mergeState, CONTRACT, WRITE_LIMIT, WRITE_WINDOW_MS, CORNERS };
++/**
++ * The guard for the timer's service route: a first-party service token (Network's own issuer signed it) holding
++ * network.dock.timer.write.
++ *
++ * A person's own token never opens this route: the token is checked as a service token, and a session token is not
++ * one. A service without the grant is refused, and so is an app, an agent or a module: only a first-party service
++ * (svc:chat) may act for a person here, which is why the grant is given to Chat alone.
++ *
++ * The capability is checked against the contracts' registry (network.dock.timer.write, Contracts 0.272.0), so a
++ * retired or unknown id is refused like everywhere else on the network. Every decision is counted in
++ * principal_usage, as it is for the other service routes.
++ */
++function serviceGuard() {
++ return async function dockServiceGuard(req, res, next) {
++ const auth = String(req.headers.authorization || '');
++ if (!auth.startsWith('Bearer ')) {
++ return http.sendProblem(res, 401, 'token.required', { detail: 'a service token is needed here, not a person\'s', ctx: req.ov });
++ }
++ const publicKey = req.app.locals.publicKey;
++ const issuer = req.app.locals.config && req.app.locals.config.jwt && req.app.locals.config.jwt.issuer;
++ const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer, audience: SELF_AUDIENCE });
++ if (!r.ok) return http.sendProblem(res, 401, r.code || 'token.invalid', { detail: r.reason, ctx: req.ov });
++ const sub = String(r.claims.sub || '');
++ const record = require('../identity/principals').recordDecision(req.app.locals.db);
++ if (r.claims.actor_type !== 'service' || !/^svc:[a-z0-9-]{1,39}$/.test(sub)) {
++ await record({ req, capability: SERVICE_CAPABILITY, principal: { sub }, allowed: false, code: 'capability.owner_denied' }).catch(() => {});
++ return http.sendProblem(res, 403, 'capability.owner_denied', { detail: 'only a first-party service may set a person\'s Dock timer', ctx: req.ov });
++ }
++ const c = capabilities.check(r.claims, SERVICE_CAPABILITY);
++ if (!c.allowed) {
++ await record({ req, capability: SERVICE_CAPABILITY, principal: { sub }, allowed: false, code: c.code || 'capability.denied' }).catch(() => {});
++ return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx: req.ov });
++ }
++ await record({ req, capability: SERVICE_CAPABILITY, principal: { sub }, allowed: true, code: null }).catch(() => {});
++ req.principal = { sub, cap: r.claims.cap, jti: r.claims.jti };
++ return next();
++ };
++}
++
++/**
++ * The timer's service route: PUT /api/v1/people/:subject/dock/timer. The router is mounted at /api/v1/people (the
++ * person is in the path, so the route carries it itself: a mount path's parameters are not passed on to a router).
++ *
++ * The person is the one in the path, and the service names them: when the request carries X-OV-Subject as well, it
++ * must be the same person. The service writes the timer and nothing else.
++ */
++function serviceRouter({ dock }) {
++ const r = express.Router();
++ r.use(http.middleware());
++ r.use(express.json({ limit: '4kb' }));
++ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
++ r.put('/:subject/dock/timer', serviceGuard(), async (req, res) => {
++ try {
++ const subject = String(req.params.subject || '');
++ if (!SUBJECT_RE.test(subject)) {
++ return http.sendProblem(res, 400, 'dock.subject_required', { detail: 'name the person: usr_…', ctx: req.ov });
++ }
++ const named = String(req.headers['x-ov-subject'] || '');
++ if (named && named !== subject) {
++ return http.sendProblem(res, 403, 'dock.subject_mismatch', { detail: 'X-OV-Subject names another person', ctx: req.ov });
++ }
++ const db = req.app.locals.db;
++ const user = await db.prepare('SELECT id, subject_id FROM users WHERE subject_id = ?').get(subject);
++ if (!user) {
++ return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no such person', ctx: req.ov });
++ }
++ const actor = { subject, user: { id: user.id } };
++ res.json(await dock.writeTimer(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] timer:', err);
++ http.sendProblem(res, 500, 'dock.error', { detail: 'could not set the timer', ctx: req.ov });
++ }
++ });
++ return r;
++}
++
++module.exports = { createDock, router, serviceRouter, serviceGuard, defaultState, mergeState, badTimer, timerOf, CONTRACT, WRITE_LIMIT, WRITE_WINDOW_MS, CORNERS, SERVICE_CAPABILITY };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex fe5daac1..f0eb931a 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -136,6 +136,9 @@ const DEFAULT_GRANTS = [
['chat', 'actor.task.create', 'openvibe.actor', []],
// Vibe's "remind me…" (plan L48): Chat sets a person's own reminder on Today, naming them with X-OV-Subject.
['chat', 'today.reminder.write', 'openvibe.today', []],
++ // Vibe's "start a 25-minute focus timer" (plan L48 R2): Chat sets that person's Dock timer in the Network,
++ // naming them in the path, and writes the timer and nothing else (server/dock).
++ ['chat', 'network.dock.timer.write', SELF_AUDIENCE, []],
['chat', 'actor.task.read', 'openvibe.actor', []],
// Vibe sets up recurring work for the person it is talking with, in their own chat and after they agreed (Chat's
// set_up_agent and my_agents tools): one of their OpenVibe Agents, made and listed as them (X-OV-Subject).
ddiff --git a/server/index.js b/server/index.js
iindex 1114f197..cfc18863 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -664,6 +664,10 @@ require('./developer/confirmations').start(db);
// 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: requireDock('dock') }));
++ // The Dock's timer, for a first-party service and not for a person (plan L48 R2): Vibe starts the focus timer of
++ // the person it is talking with, and it counts down in the Dock's pill on every OpenVibe site. The service names
++ // the person and writes the timer and nothing else (network.dock.timer.write, granted to Chat alone).
++ app.use('/api/v1/people', require('./dock').serviceRouter({ dock }));
}
// The consent registry (L24, 2026-10-31; contracts 0.256.0): every use of a person's content or likeness for AI or
ddiff --git a/test/dock-timer-service.test.js b/test/dock-timer-service.test.js
nnew file mode 100644
iindex 00000000..a7ef4f47
---- /dev/null
++++ b/test/dock-timer-service.test.js
@@@ -0,0 +1,219 @@
++'use strict';
++// The Dock's timer for a first-party service (plan L48 R2; server/dock): PUT /api/v1/people/:subject/dock/timer
++// answers network.dock-result@1 for a service that holds network.dock.timer.write. OpenVibe.Chat's Vibe starts the
++// person's focus timer where they are ("Vibe, start a 25-minute focus timer"), and it counts down in the Dock's pill
++// on every OpenVibe site.
++//
++// What this proves:
++// - a person's own token never opens the service route, and a service without the grant is refused;
++// - the service writes the timer and nothing else: every other field of the person's Dock state stays as it was;
++// - the timer it writes is the shape the Dock reads (openvibe-shared/dock-timer.js start());
++// - a bad timer is 422, an unknown person is 404, and X-OV-Subject may not name another person;
++// - the write limit is the person's own: 60 a minute, the person route and the service route together.
++// node --import ./test/helpers/pg-preload.mjs test/dock-timer-service.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { validate, serviceAuth } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const dockMod = require('../server/dock');
++const authRoutes = require('../server/auth/routes');
++
++(async () => {
++const db = getDb();
++const log = console.log; console.log = () => {};
++
++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('timerana');
++const bob = await mk('timerbob');
++
++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);
++
++// A service token, the way Network's /oauth/token mints one: svc:<client> and the capabilities it holds.
++const serviceToken = (client, cap) => {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({
++ iss: config.jwt.issuer, sub: `svc:${client}`, actor_type: 'service', aud: ['openvibe.network'],
++ cap, ns: [], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ }, keys.privateKey);
++};
++const CHAT = serviceToken('chat', ['network.dock.timer.write']);
++
++const dock = dockMod.createDock({ db, actions: { forPerson: async () => ({ counts: { running: 1, waiting: 0 }, partial: false }) }, notifications: { getUnreadCount: async () => 2 } });
++const app = express();
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++app.use(express.json({ limit: '64kb' }));
++app.use('/api/v1/me/dock', dockMod.router({ dock, requireAuth }));
++app.use('/api/v1/people', dockMod.serviceRouter({ dock }));
++const server = http.createServer(app);
++await new Promise((r) => server.listen(0, '127.0.0.1', r));
++const base = `http://127.0.0.1:${server.address().port}`;
++
++const put = (subject, body, { token = CHAT, subjectHeader = null } = {}) => fetch(`${base}/api/v1/people/${subject}/dock/timer`, {
++ method: 'PUT',
++ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...(subjectHeader ? { 'x-ov-subject': subjectHeader } : {}) },
++ body: JSON.stringify(body),
++}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})), headers: r.headers }));
++const putMine = (body) => fetch(`${base}/api/v1/me/dock`, {
++ method: 'PUT', headers: { authorization: `Bearer ${tokenOf(ana)}`, 'content-type': 'application/json' }, body: JSON.stringify(body),
++}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
++const mine = () => fetch(`${base}/api/v1/me/dock`, { headers: { authorization: `Bearer ${tokenOf(ana)}` } })
++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
++
++try {
++ // The grant is Chat's alone, and it is the grant the seeded principal holds.
++ const principals = require('../server/identity/principals');
++ await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('chat', 'x', 'Chat', '[]', 1) ON CONFLICT DO NOTHING").run();
++ await principals.ensureSchema(db);
++ assert.deepStrictEqual(principals.DEFAULT_GRANTS.filter(([, cap]) => cap === 'network.dock.timer.write'),
++ [['chat', 'network.dock.timer.write', 'openvibe.network', []]], 'Chat holds the grant, and nobody else is given it');
++ assert.ok((await principals.grantsFor(db, 'chat', 'openvibe.network')).some((g) => g.capability === 'network.dock.timer.write'), 'the grant is seeded');
++ assert.ok(!(await principals.grantsFor(db, 'live', 'openvibe.network')).some((g) => g.capability === 'network.dock.timer.write'), 'no other service holds it');
++
++ // A person's own token never opens the service route: this is the service's door, not theirs.
++ let r = await put(ana.subject_id, { minutes: 25 }, { token: tokenOf(ana) });
++ assert.strictEqual(r.status, 401, `a person\'s token is refused: ${JSON.stringify(r.body)}`);
++ assert.strictEqual((await put(ana.subject_id, { minutes: 25 }, { token: 'not-a-token' })).status, 401, 'a bad token is refused');
++
++ // Another service without the grant is refused; so is a service that holds something else.
++ r = await put(ana.subject_id, { minutes: 25 }, { token: serviceToken('live', ['network.modules.read']) });
++ assert.strictEqual(r.status, 403, 'a service without the grant is refused');
++ assert.strictEqual(r.body.code, 'capability.denied');
++ r = await put(ana.subject_id, { minutes: 25 }, { token: serviceToken('live', ['network.*']) });
++ assert.strictEqual(r.status, 200, 'a family grant covers it, as it does everywhere else on the network');
++ // An app or an agent is not a first-party service, whatever it holds.
++ const now = Math.floor(Date.now() / 1000);
++ const appToken = serviceAuth.signServiceToken({
++ iss: config.jwt.issuer, sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.network'],
++ cap: ['network.dock.timer.write'], ns: [], project_id: 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0', env: 'production',
++ iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ }, keys.privateKey);
++ r = await put(ana.subject_id, { minutes: 25 }, { token: appToken });
++ assert.strictEqual(r.status, 403, 'an app token is refused');
++ assert.strictEqual(r.body.code, 'capability.owner_denied');
++
++ // The granted service sets the timer, and the answer is a Dock state.
++ r = await put(ana.subject_id, { minutes: 25, kind: 'focus', label: 'Write the post', rounds: 4 });
++ 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));
++ const timer = r.body.state.timer;
++ assert.strictEqual(timer.kind, 'focus');
++ assert.strictEqual(timer.label, 'Write the post');
++ assert.strictEqual(timer.round, 1);
++ assert.strictEqual(timer.rounds, 4);
++ assert.strictEqual(timer.focus_min, 25);
++ assert.strictEqual(timer.break_min, 5);
++ // The Dock reads this: the shape openvibe-shared/dock-timer.js start() writes, and the length it says.
++ assert.deepStrictEqual(Object.keys(timer).sort(), ['break_min', 'ends_at', 'focus_min', 'kind', 'label', 'round', 'rounds', 'started_at']);
++ assert.strictEqual(Date.parse(timer.ends_at) - Date.parse(timer.started_at), 25 * 60000, 'it ends 25 minutes after it starts');
++ assert.ok(validate('network.dock-state@1', r.body.state).valid, 'the state is a Dock state');
++
++ // A plain timer: no rounds, no break.
++ r = await put(ana.subject_id, { minutes: 10 });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(Object.keys(r.body.state.timer).sort(), ['ends_at', 'kind', 'started_at']);
++ assert.strictEqual(r.body.state.timer.kind, 'timer');
++
++ // The service writes the timer and nothing else: the person's own Dock state is left as it was.
++ await putMine({ open: true, tab: 'chats', pinned: true, chat: 'cnv_1', place: { desktop: { width: 500 } } });
++ const before = (await mine()).body.state;
++ r = await put(ana.subject_id, { minutes: 25, label: 'Write' });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.state.open, true, 'the Dock stayed open');
++ assert.strictEqual(r.body.state.tab, 'chats', 'the tab stayed');
++ assert.strictEqual(r.body.state.pinned, true, 'the pill stayed pinned');
++ assert.strictEqual(r.body.state.chat, 'cnv_1', 'the chat stayed');
++ assert.deepStrictEqual(r.body.state.place, before.place, 'where the Dock sits stayed');
++ assert.strictEqual(r.body.state.timer.label, 'Write', 'the timer is the new part');
++ assert.deepStrictEqual((await mine()).body.state, r.body.state, 'the stored state is the one the service was answered with');
++
++ // A timer the contract does not accept is refused, and nothing is stored.
++ for (const [body, why] of [
++ [{ minutes: 0 }, 'no minutes'],
++ [{ minutes: 200 }, 'too many minutes'],
++ [{ minutes: 25.5 }, 'a fraction of a minute'],
++ [{ minutes: 25, kind: 'nap' }, 'a kind the Dock does not know'],
++ [{ minutes: 25, rounds: 0 }, 'no rounds'],
++ [{ minutes: 25, rounds: 13 }, 'too many rounds'],
++ [{ minutes: 25, label: 'x'.repeat(61) }, 'a label over 60 characters'],
++ [{ minutes: 25, clear: true }, 'both at once'],
++ [{ clear: false }, 'clear is false'],
++ [{}, 'nothing at all'],
++ [{ minutes: 25, colour: 'blue' }, 'a field the timer does not have'],
++ ]) {
++ const state = (await mine()).body.state;
++ r = await put(ana.subject_id, body);
++ assert.strictEqual(r.status, 422, `${why}: ${JSON.stringify(r.body)}`);
++ assert.strictEqual(r.body.code, 'dock.invalid', why);
++ assert.deepStrictEqual((await mine()).body.state, state, `${why}: nothing was stored`);
++ }
++
++ // The service stops the timer, and the rest of the state stays.
++ r = await put(ana.subject_id, { clear: true });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.state.timer, null, 'the timer is gone');
++ assert.strictEqual(r.body.state.tab, 'chats', 'the tab stayed');
++
++ // Only a person the Network knows has a Dock.
++ r = await put('usr_01JABCDEFGHJKMNPQRSTVWXYZ0', { minutes: 25 });
++ assert.strictEqual(r.status, 404, 'an unknown person is 404');
++ r = await put('not-a-subject', { minutes: 25 });
++ assert.strictEqual(r.status, 400, 'a malformed subject is 400');
++
++ // The service names the person: X-OV-Subject may not name another one.
++ r = await put(ana.subject_id, { minutes: 25 }, { subjectHeader: bob.subject_id });
++ assert.strictEqual(r.status, 403, 'another person in the header is refused');
++ assert.strictEqual(r.body.code, 'dock.subject_mismatch');
++ r = await put(ana.subject_id, { minutes: 25 }, { subjectHeader: ana.subject_id });
++ assert.strictEqual(r.status, 200, 'the same person in the header is fine');
++
++ // One person never touches another's state: the service writes the timer of the person it names, and of nobody else.
++ const bobState = await fetch(`${base}/api/v1/me/dock`, { headers: { authorization: `Bearer ${tokenOf(bob)}` } }).then((x) => x.json());
++ assert.strictEqual(bobState.state.timer, undefined, "bob's Dock has no timer");
++ await put(ana.subject_id, { minutes: 25 });
++ const bobAgain = await fetch(`${base}/api/v1/me/dock`, { headers: { authorization: `Bearer ${tokenOf(bob)}` } }).then((x) => x.json());
++ assert.strictEqual(bobAgain.state.timer, undefined, "bob's Dock still has no timer");
++
++ // Every decision is counted, like the other service routes: who called, and whether they were let in.
++ const usage = await db.prepare('SELECT principal, route, capability, auth, allowed FROM principal_usage WHERE capability = ?').all('network.dock.timer.write');
++ assert.ok(usage.length, 'the decisions are counted');
++ assert.ok(usage.some((u) => u.allowed === 1), 'an allowed call is counted');
++ assert.ok(usage.some((u) => u.allowed === 0), 'a refused call is counted');
++ assert.ok(usage.every((u) => u.route.includes('dock/timer')), `the route is named: ${usage.map((u) => u.route).join(', ')}`);
++ assert.ok(usage.every((u) => u.auth === 'service-token'), 'the caller was a service token');
++
++ // The write limit is the person's own: 60 a minute, the person route and the service route together.
++ const limiter = dockMod.createDock({ db, actions: null, notifications: null });
++ const limitApp = express();
++ Object.assign(limitApp.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++ limitApp.use(express.json({ limit: '64kb' }));
++ limitApp.use('/api/v1/people', dockMod.serviceRouter({ dock: limiter }));
++ const limitServer = http.createServer(limitApp);
++ await new Promise((r) => limitServer.listen(0, '127.0.0.1', r));
++ const limitPut = (body) => fetch(`http://127.0.0.1:${limitServer.address().port}/api/v1/people/${bob.subject_id}/dock/timer`, {
++ method: 'PUT', headers: { authorization: `Bearer ${CHAT}`, 'content-type': 'application/json' }, body: JSON.stringify(body),
++ });
++ for (let i = 0; i < dockMod.WRITE_LIMIT; i++) {
++ const w = await limitPut({ minutes: 5 });
++ assert.strictEqual(w.status, 200, `write ${i + 1} of ${dockMod.WRITE_LIMIT}`);
++ }
++ const over = await limitPut({ minutes: 5 });
++ assert.strictEqual(over.status, 429, 'the 61st write in a minute is refused');
++ assert.strictEqual((await over.json()).code, 'dock.rate_limited');
++ limitServer.close();
++} finally {
++ server.close();
++ console.log = log;
++}
++console.log('dock timer service: ok');
++process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });