openvibers/openvibe.network Commits
The Dock's timer, for a first-party service: Vibe can start a person's focus timer (#486)
Changes
@@@ -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",@@@ -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",@@@ -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 = () => Datereturn { 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 };@@@ -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).@@@ -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 ornnew file mode 100644@@@ -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); });
@@ -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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.77.0",
"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.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",
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.77.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -14,14 +14,31 @@
@@ -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
* 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
* 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.
* 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 express = require('express');
-const { validate, http } = require('openvibe-contracts');
+const { validate, http, serviceAuth, capabilities } = require('openvibe-contracts');
const store = require('../developer/store');
const store = require('../developer/store');
const CONTRACT = 'network.dock-state@1';
const CONTRACT = 'network.dock-state@1';
const WRITE_LIMIT = 60;
const WRITE_LIMIT = 60;
const WRITE_WINDOW_MS = 60_000;
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,
// 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.
// and the site's own size.
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
@@ -63,6 +80,46 @@ function mergeState(current, patch) {
@@ -63,6 +80,46 @@ function mergeState(current, patch) {
return out;
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.
* The Dock for one person.
* db the Network database
* db the Network database
@@ -156,7 +213,32 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
@@ -156,7 +213,32 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
return { state: merged, counts, sources_partial: partial };
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. */
/** 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 }) {
@@ -190,4 +272,83 @@ function router({ dock, requireAuth }) {
return r;
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 };
@@ -136,6 +136,9 @@ const DEFAULT_GRANTS = [
@@ -136,6 +136,9 @@ const DEFAULT_GRANTS = [
['chat', 'actor.task.create', 'openvibe.actor', []],
['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.
// 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', []],
['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', []],
['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
// 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).
// set_up_agent and my_agents tools): one of their OpenVibe Agents, made and listed as them (X-OV-Subject).
@@ -664,6 +664,10 @@ require('./developer/confirmations').start(db);
@@ -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.
// 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 });
const dock = require('./dock').createDock({ db, actions, notifications: notificationService });
app.use('/api/v1/me/dock', require('./dock').router({ dock, requireAuth: requireDock('dock') }));
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
// The consent registry (L24, 2026-10-31; contracts 0.256.0): every use of a person's content or likeness for AI or
new file mode 100644
new file mode 100644
@@ -0,0 +1,219 @@
@@ -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); });