openvibers/openvibe.network Commits
People's own layouts: active, saved, share, gallery and the AI suggestion (#848)
Changes
@@@ -6,8 +6,8 @@"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.407.0",-- "sdk": "openvibe-sdk v0.150.0",++ "contracts": "openvibe-contracts v0.410.0",++ "sdk": "openvibe-sdk v0.152.0","shared": "openvibe-shared v4.211.0","capabilities": ["identity.subject.resolve",nnew file mode 100644@@@ -0,0 +1,28 @@++-- phase: expand++-- People's own controls (make your own controls design, 2026-11-11, piece 4): one row per layout a++-- person made for a surface, ui.layout@1 as a JSON value. The active row per (person, surface, device)++-- is what a product reads; the rest are saved layouts. A share token makes one read-only without an++-- account, and `sharing` says private, link or gallery. The row is the person's own, so it goes into++-- their account export and is erased with them (ADR-033).++--++-- Expand only: an older release never reads this table, and nothing here changes an existing one.++CREATE TABLE IF NOT EXISTS ui_layouts (++ id text COLLATE "C" PRIMARY KEY,++ subject_id text COLLATE "C" NOT NULL,++ surface text COLLATE "C" NOT NULL,++ surface_version integer NOT NULL,++ device text COLLATE "C" NOT NULL CHECK (device IN ('phone', 'tablet', 'computer')),++ name text COLLATE "C" NOT NULL,++ doc jsonb NOT NULL,++ active boolean NOT NULL DEFAULT false,++ sharing text COLLATE "C" NOT NULL DEFAULT 'private' CHECK (sharing IN ('private', 'link', 'gallery')),++ share_token text COLLATE "C" UNIQUE,++ published_at timestamptz,++ revision bigint NOT NULL DEFAULT 1,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now()++);++-- One active layout for a surface on a device class per person: setting another replaces it.++CREATE UNIQUE INDEX IF NOT EXISTS ui_layouts_one_active ON ui_layouts (subject_id, surface, device) WHERE active;++CREATE INDEX IF NOT EXISTS ui_layouts_by_subject ON ui_layouts (subject_id, updated_at DESC);++CREATE INDEX IF NOT EXISTS ui_layouts_gallery ON ui_layouts (surface, device, published_at DESC) WHERE sharing = 'gallery' AND published_at IS NOT NULL;@@@ -22,8 +22,8 @@"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.409.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0","otpauth": "9.6.0","pg": "^8.23.0",@@@ -2679,9 +2679,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.409.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",-- "integrity": "sha512-Yh8XcOEKIrYG7WV8ack03hmobZwMyZIeOogIrCeIWvuHKpvjYYL+UcOj7DrNalxC5fp0EnhVwRj89DTFm/AH2g==",++ "version": "0.410.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "integrity": "sha512-rmmq2mebXim2fv+sip2YQ0PrrZLxGUhEfe1w+1EFfGdoVvLI5tp0/eavY2sW6/VU+rzs7cQMC/A0NZD5th5wrA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -2695,9 +2695,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.150.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",++ "version": "0.152.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",++ "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -26,8 +26,9 @@"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.409.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0","otpauth": "9.6.0","pg": "^8.23.0",@@@ -198,6 +198,13 @@ async function networkPart(db, userId) {if (typeof home === 'string') { try { home = JSON.parse(home); } catch { home = null; } }add('home-screen.json', home && typeof home === 'object' && Array.isArray(home.home) && Array.isArray(home.folders)? home : { home: [], folders: [], updated_at: null });++ // People's own controls (2026-11-11): every layout they made, the active one included, with the++ // catalog fields the store keeps. A share token is exported as the token: it is the person's own link.++ add('layouts.json', await tableExists(db, 'ui_layouts')++ ? (await db.prepare(`SELECT id, surface, surface_version, device, name, doc, active, sharing, share_token, published_at, revision, created_at, updated_at++ FROM ui_layouts WHERE subject_id = ? ORDER BY created_at`).all(u.subject_id)).map((r) => ({++ ...r, doc: typeof r.doc === 'string' ? (() => { try { return JSON.parse(r.doc); } catch { return r.doc; } })() : r.doc, active: !!r.active,++ })) : []);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')@@@ -377,6 +384,8 @@ async function erase(db, deletion, { now = Date.now() } = {}) {consentEvents = await require('../consent').revokeForDeletion(db, subjects, { at });// 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');++ // People's own controls (2026-11-11): the layouts they made, and their share links, go with them.++ await del('ui_layouts', `subject_id IN ${inS}`, subjects, 'ui_layouts');await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');await del('user_mutes', `muter_subject IN ${inS} OR muted_subject IN ${inS}`, [...subjects, ...subjects], 'mutes');@@@ -394,6 +394,9 @@ const DEFAULT_GRANTS = [// The owner's AI tab lists and edits AI's provider registry through AI's own admin API (GET/POST/PATCH// /api/v1/providers), as Network, naming the owner in X-OV-Subject.['network', 'ai.provider.manage', 'openvibe.ai', []],++ // People's own controls (2026-11-11): Network calls OpenVibe.AI's ui.layout.suggest for the signed-in++ // person, naming them in X-OV-Subject, so the run counts against that person's own AI limits.++ ['network', 'ai.layout.suggest', 'openvibe.ai', []],// Host's demand planner reads AI's usage feed (Host #283): ai.usage.read on openvibe.ai.['host', 'ai.usage.read', 'openvibe.ai', []],// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.@@@ -916,10 +916,18 @@ require('./developer/confirmations').start(db);app.use('/api/v1/me/feed-seen', require('./feed-seen').router({ requireAuth }));const homeScreen = require('./home-screen').createHomeScreen({ db });app.use('/api/v1/me/home-screen', require('./home-screen').router({ homeScreen, requireAuth }));++ // People's own controls (design 2026-11-11, piece 4): the active arrangement per surface and device,++ // saved layouts, share links, the gallery and the ui.layout.suggest call. Every write is validated++ // through openvibe-sdk/ui-layout; each change queues network.layout.changed in the same transaction.++ const layouts = require('./layouts').createLayouts({ db, selfToken, aiUrl: process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' });++ app.use('/api/v1/me/layouts', require('./layouts').router({ layouts, requireAuth }));++ app.use('/api/v1/layouts', require('./layouts').publicRouter({ layouts, requireAuth }));// 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 }));++ // A product reads the active layout one person made for its own surface (network.layout.read).++ app.use('/api/v1/people', require('./layouts').serviceRouter({ layouts }));// The Locker key directory (design 2026-11-06, LOCKER-DESIGN-2026-11-06.md §4): my public keys, my// wrapped private keys and anybody's public keys (server/locker/keys.js). Mounted after the Dock's// service route, which writes the timer of one person.nnew file mode 100644@@@ -0,0 +1,610 @@++'use strict';++/**++ * People's own layouts (make your own controls design, 2026-11-11, piece 4).++ *++ * One ui.layout@1 per row: the active arrangement for one surface on one device class, and the saved++ * ones beside it. A product reads the active one for its surface (with the person's session or, for++ * a first-party service, network.layout.read); the person's own pages list, save, rename, delete,++ * set active, share read-only, publish to the gallery and copy from it. Reset to default clears the++ * active arrangement so the product draws its own.++ *++ * Every write is checked twice before it is stored: the document against ui.layout@1, and the++ * document against the ui.surface@1 catalog the caller sends, through openvibe-sdk/ui-layout — the++ * same function the editor and OpenVibe.AI use. A refusal is 422 with the validator's plain reasons.++ * A share or gallery answer carries the name and the parts only, never the account.++ *++ * Each change and its network.layout.changed@1 event are one transaction: the event goes through the++ * existing outbox (server/developer/event-relay.js), so an open page hears about it from Events or++ * refetches. Account export and deletion include the rows (server/identity/account-data.js).++ */++const crypto = require('crypto');++const express = require('express');++const { ids, validate, http, serviceAuth, capabilities } = require('openvibe-contracts');++const uiLayout = require('openvibe-sdk/ui-layout');++const eventRelay = require('../developer/event-relay');++++const CONTRACT = 'ui.layout@1';++const SURFACE_CONTRACT = 'ui.surface@1';++const SUGGESTION_CONTRACT = 'ui.layout-suggestion@1';++const EVENT_TYPE = 'network.layout.changed';++const DEVICES = ['phone', 'tablet', 'computer'];++const SURFACE_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;++const LAYOUT_ID_RE = /^lay_[0-9A-HJKMNP-TV-Z]{26}$/;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const TOKEN_RE = /^[A-Za-z0-9_-]{24,64}$/;++const SERVICE_CAPABILITY = 'network.layout.read';++const SELF_AUDIENCE = 'openvibe.network';++const MAX_SAVED = 200;++const WRITE_LIMIT = 60;++const SUGGEST_LIMIT = 10;++const WINDOW_MS = 60_000;++const MAX_BODY = '192kb';++const BAD_TEXT = /(?:https?:\/\/|www\.|(?:^|[\s"'(])(?:javascript|data|vbscript|file):|<\s*\/?\s*(?:script|iframe|img|svg|style|a|object|embed)\b|\bon[a-z]{3,}\s*=|=>|\bfunction\s*\(|\beval\s*\(|\brequire\s*\(|\bimport\s*\()/i;++++const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);++const iso = (v) => (v instanceof Date ? v.toISOString() : new Date(v).toISOString());++const nowIso = (now) => new Date(now()).toISOString();++++class LayoutError extends Error {++ constructor(status, code, detail, extra) { super(detail); this.status = status; this.code = code; this.extra = extra; }++}++++/** An id in the lay_ prefix the contract names; the contracts' id table has no layout kind. */++const newLayoutId = (now) => `lay_${ids.ulid(now)}`;++++function parseDoc(value) {++ if (isObject(value)) return value;++ if (typeof value !== 'string') return null;++ try { const v = JSON.parse(value); return isObject(v) ? v : null; } catch { return null; }++}++++/** The stored document plus the store's own fields; null when it no longer matches ui.layout@1. */++function layoutOf(row) {++ const doc = row && parseDoc(row.doc);++ if (!doc) return null;++ const out = {++ ...doc,++ id: row.id,++ revision: Number(row.revision) || 1,++ active: Boolean(row.active),++ sharing: row.sharing || doc.sharing || 'private',++ updated_at: iso(row.updated_at),++ };++ return validate(CONTRACT, out).valid ? out : null;++}++++/** What a share link or the gallery shows: the surface, parts, name, author's display name and id. */++function publicView(row) {++ const doc = parseDoc(row && row.doc);++ if (!doc) return null;++ const out = {++ id: row.id,++ surface: row.surface,++ surface_version: Number(row.surface_version),++ device: row.device,++ name: row.name,++ author: { name: (doc.author && doc.author.name) || 'OpenVibe user' },++ sharing: row.sharing,++ parts: doc.parts,++ updated_at: iso(row.updated_at),++ };++ const v = validate(CONTRACT, out);++ return v.valid ? out : null;++}++++function plainFaults(errors) {++ return (errors || []).slice(0, 20).map((e) => `${String(e.path || e.instancePath || '').replace(/^\//, '').replace(/\//g, '.') || 'layout'}: ${e.message}`);++}++++function checked(value, contract = CONTRACT) {++ const r = validate(contract, value);++ if (r.valid) return value;++ throw new LayoutError(422, 'layout.invalid', 'that is not a valid layout', { reasons: plainFaults(r.errors), errors: r.errors });++}++++/** Check a layout and the catalog it belongs to with the SDK, the one authority the editor and AI use. */++function validateWithCatalog(catalog, layout, surface, device) {++ const c = validate(SURFACE_CONTRACT, catalog);++ if (!c.valid) throw new LayoutError(400, 'layout.catalog_invalid', 'the surface catalog is not valid', { errors: c.errors });++ if (catalog.id !== surface) throw new LayoutError(422, 'layout.surface_mismatch', `this catalog is for ${catalog.id}, not ${surface}`);++ if (!Array.isArray(catalog.devices) || !catalog.devices.includes(device)) {++ throw new LayoutError(422, 'layout.device_mismatch', `${surface} is not offered on ${device}`);++ }++ checked(layout);++ if (layout.surface !== surface) throw new LayoutError(422, 'layout.surface_mismatch', `the layout names ${layout.surface}, not ${surface}`);++ if (layout.device !== device) throw new LayoutError(422, 'layout.device_mismatch', `the layout is for ${layout.device}, not ${device}`);++ if (layout.surface_version !== catalog.version) throw new LayoutError(422, 'layout.version_mismatch', `the layout is for version ${layout.surface_version} of ${surface}; the catalog is version ${catalog.version}`);++ const result = uiLayout.validate(catalog, layout);++ if (!result.ok) throw new LayoutError(422, 'layout.invalid', result.reasons.join(' '), { reasons: result.reasons });++ return layout;++}++++/** The person's own document, with the server's author, name and sharing: a client never chooses those. */++function cleanLayout(layout, user) {++ const name = String(user.display_name || user.username || 'OpenVibe user').slice(0, 80);++ return {++ surface: layout.surface,++ surface_version: layout.surface_version,++ device: layout.device,++ name: String(layout.name).slice(0, 80),++ author: { name, subject: { type: 'user', id: user.subject_id } },++ sharing: 'private',++ parts: layout.parts,++ };++}++++function envelope(subject, payload, at) {++ const ms = Date.parse(at) || Date.now();++ const env = {++ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',++ actor: { type: 'user', id: subject }, timestamp: at, visibility: 'internal',++ subject: { type: 'user', id: subject, revision: payload.revision }, payload,++ };++ const v = validate('events.event-envelope@1', env);++ const pv = validate(`${EVENT_TYPE}@1`, payload);++ if (!v.valid || !pv.valid) throw new Error(`layouts: bad ${EVENT_TYPE}: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);++ return env;++}++++async function queue(db, subject, payload, at) {++ await eventRelay.writerFor(db).enqueue(envelope(subject, payload, at));++ try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ }++}++++/** A per-person sliding window for writes and suggestions, the pattern the menu-feeds module uses. */++function limiter() {++ const seen = new Map();++ return function allow(key, limit, at) {++ const kept = (seen.get(key) || []).filter((t) => at - t < WINDOW_MS);++ if (kept.length >= limit) { seen.set(key, kept); return false; }++ kept.push(at); seen.set(key, kept);++ if (seen.size > 5000) seen.delete(seen.keys().next().value);++ return true;++ };++}++++function createLayouts({ db, now = () => Date.now(), log = console, aiUrl = 'http://127.0.0.1:4700', selfToken = null, fetchImpl = globalThis.fetch } = {}) {++ if (!db) throw new TypeError('createLayouts: db is required');++ const allow = limiter();++ const at = () => now();++++ const rowFor = async (subject, id) => await db.prepare('SELECT * FROM ui_layouts WHERE id = ? AND subject_id = ?').get(id, subject);++ const activeRow = async (subject, surface, device) => await db.prepare('SELECT * FROM ui_layouts WHERE subject_id = ? AND surface = ? AND device = ? AND active').get(subject, surface, device);++++ async function active(subject, surface, device) {++ const row = await activeRow(subject, surface, device);++ return row ? layoutOf(row) : null;++ }++++ async function get(subject, id) {++ const row = await rowFor(subject, id);++ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');++ return layoutOf(row);++ }++++ async function list(subject, { surface = null, device = null } = {}) {++ const where = ['subject_id = ?']; const params = [subject];++ if (surface) { where.push('surface = ?'); params.push(surface); }++ if (device) { where.push('device = ?'); params.push(device); }++ const rows = await db.prepare(`SELECT * FROM ui_layouts WHERE ${where.join(' AND ')} ORDER BY active DESC, updated_at DESC LIMIT ${MAX_SAVED}`).all(...params);++ return rows.map(layoutOf).filter(Boolean);++ }++++ /** PUT: the active layout for this surface/device, updated in place when there is one. */++ async function setActive(user, surface, device, body) {++ if (!allow(`write:${user.subject_id}`, WRITE_LIMIT, at())) throw new LayoutError(429, 'layout.rate_limited', 'too many layout writes; wait a minute');++ if (!isObject(body) || !isObject(body.catalog) || !isObject(body.layout)) {++ throw new LayoutError(400, 'layout.body_required', 'send { catalog, layout }: the surface catalog and the arrangement');++ }++ validateWithCatalog(body.catalog, body.layout, surface, device);++ const doc = cleanLayout(body.layout, user);++ const ms = at(); const stamp = nowIso(now);++ return await db.tx(async () => {++ const current = await activeRow(user.subject_id, surface, device);++ let row;++ if (current) {++ const revision = Number(current.revision) + 1;++ await db.prepare(`UPDATE ui_layouts SET doc = ?, name = ?, surface_version = ?, revision = ?, updated_at = ? WHERE id = ?`)++ .run(JSON.stringify({ ...doc, sharing: current.sharing }), doc.name, doc.surface_version, revision, stamp, current.id);++ row = await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(current.id);++ } else {++ const id = newLayoutId(ms);++ await db.prepare(`INSERT INTO ui_layouts (id, subject_id, surface, surface_version, device, name, doc, active, sharing, revision, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, 'private', 1, ?, ?)`)++ .run(id, user.subject_id, surface, doc.surface_version, device, doc.name, JSON.stringify(doc), stamp, stamp);++ row = await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id);++ }++ await queue(db, user.subject_id, { surface, device, layout_id: row.id, change: 'active', revision: Number(row.revision), sharing: row.sharing }, stamp);++ return layoutOf(row);++ });++ }++++ /** POST: a saved layout beside the active one. */++ async function save(user, body) {++ if (!allow(`write:${user.subject_id}`, WRITE_LIMIT, at())) throw new LayoutError(429, 'layout.rate_limited', 'too many layout writes; wait a minute');++ if (!isObject(body) || !isObject(body.catalog) || !isObject(body.layout)) {++ throw new LayoutError(400, 'layout.body_required', 'send { catalog, layout }: the surface catalog and the arrangement');++ }++ const surface = body.layout.surface; const device = body.layout.device;++ if (!SURFACE_RE.test(String(surface || '')) || !DEVICES.includes(device)) throw new LayoutError(422, 'layout.invalid', 'the layout needs a surface and a device');++ validateWithCatalog(body.catalog, body.layout, surface, device);++ const doc = cleanLayout(body.layout, user);++ const ms = at(); const stamp = nowIso(now);++ return await db.tx(async () => {++ const count = (await db.prepare('SELECT COUNT(*) AS n FROM ui_layouts WHERE subject_id = ?').get(user.subject_id)).n;++ if (Number(count) >= MAX_SAVED) throw new LayoutError(409, 'layout.limit', `you can keep ${MAX_SAVED} layouts`);++ const id = newLayoutId(ms);++ await db.prepare(`INSERT INTO ui_layouts (id, subject_id, surface, surface_version, device, name, doc, active, sharing, revision, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, FALSE, 'private', 1, ?, ?)`)++ .run(id, user.subject_id, surface, doc.surface_version, device, doc.name, JSON.stringify(doc), stamp, stamp);++ const row = await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id);++ await queue(db, user.subject_id, { surface, device, layout_id: id, change: 'saved', revision: 1, sharing: 'private' }, stamp);++ return layoutOf(row);++ });++ }++++ /** PATCH: a new name; nothing else changes. */++ async function rename(user, id, body) {++ const row = await rowFor(user.subject_id, id);++ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');++ if (!isObject(body) || typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80) {++ throw new LayoutError(400, 'layout.name_required', 'send { name }: one to eighty characters');++ }++ if (BAD_TEXT.test(body.name)) throw new LayoutError(422, 'layout.invalid', "a name can't carry a URL or code");++ const doc = parseDoc(row.doc);++ if (!doc) throw new LayoutError(500, 'layout.corrupt', 'this layout cannot be read');++ const name = body.name.trim(); const revision = Number(row.revision) + 1; const stamp = nowIso(now);++ return await db.tx(async () => {++ await db.prepare('UPDATE ui_layouts SET name = ?, doc = ?, revision = ?, updated_at = ? WHERE id = ?')++ .run(name, JSON.stringify({ ...doc, name }), revision, stamp, id);++ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'renamed', revision, sharing: row.sharing }, stamp);++ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));++ });++ }++++ async function activate(user, id) {++ const row = await rowFor(user.subject_id, id);++ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');++ if (row.active) return layoutOf(row);++ const revision = Number(row.revision) + 1; const stamp = nowIso(now);++ return await db.tx(async () => {++ await db.prepare('UPDATE ui_layouts SET active = FALSE WHERE subject_id = ? AND surface = ? AND device = ? AND active').run(user.subject_id, row.surface, row.device);++ await db.prepare('UPDATE ui_layouts SET active = TRUE, revision = ?, updated_at = ? WHERE id = ?').run(revision, stamp, id);++ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'active', revision, sharing: row.sharing }, stamp);++ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));++ });++ }++++ async function remove(user, id) {++ const row = await rowFor(user.subject_id, id);++ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');++ const stamp = nowIso(now); const revision = Number(row.revision) + 1;++ await db.tx(async () => {++ await db.prepare('DELETE FROM ui_layouts WHERE id = ?').run(id);++ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'deleted', revision, sharing: row.sharing }, stamp);++ });++ return true;++ }++++ /** Reset to the product's default: the active arrangement stops being active; the saved row stays. */++ async function reset(user, surface, device) {++ const row = await activeRow(user.subject_id, surface, device);++ if (!row) return false;++ const revision = Number(row.revision) + 1; const stamp = nowIso(now);++ await db.tx(async () => {++ await db.prepare('UPDATE ui_layouts SET active = FALSE, revision = ?, updated_at = ? WHERE id = ?').run(revision, stamp, row.id);++ await queue(db, user.subject_id, { surface, device, layout_id: row.id, change: 'reset', revision, sharing: row.sharing }, stamp);++ });++ return true;++ }++++ async function share(user, id) {++ const row = await rowFor(user.subject_id, id);++ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');++ const token = row.share_token || crypto.randomBytes(24).toString('base64url');++ const revision = Number(row.revision) + 1; const stamp = nowIso(now);++ return await db.tx(async () => {++ const doc = parseDoc(row.doc) || {};++ await db.prepare("UPDATE ui_layouts SET sharing = 'link', share_token = ?, doc = ?, revision = ?, updated_at = ? WHERE id = ?")++ .run(token, JSON.stringify({ ...doc, sharing: 'link' }), revision, stamp, id);++ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'shared', revision, sharing: 'link' }, stamp);++ const out = layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));++ return { layout: out, token, url: `/api/v1/layouts/shared/${token}` };++ });++ }++++ async function unshare(user, id) {++ const row = await rowFor(user.subject_id, id);++ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');++ const revision = Number(row.revision) + 1; const stamp = nowIso(now);++ return await db.tx(async () => {++ const doc = parseDoc(row.doc) || {};++ await db.prepare("UPDATE ui_layouts SET sharing = 'private', share_token = NULL, published_at = NULL, doc = ?, revision = ?, updated_at = ? WHERE id = ?")++ .run(JSON.stringify({ ...doc, sharing: 'private' }), revision, stamp, id);++ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'unshared', revision, sharing: 'private' }, stamp);++ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));++ });++ }++++ async function publish(user, id) {++ const row = await rowFor(user.subject_id, id);++ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');++ const revision = Number(row.revision) + 1; const stamp = nowIso(now);++ return await db.tx(async () => {++ const doc = parseDoc(row.doc) || {};++ await db.prepare("UPDATE ui_layouts SET sharing = 'gallery', published_at = ?, doc = ?, revision = ?, updated_at = ? WHERE id = ?")++ .run(stamp, JSON.stringify({ ...doc, sharing: 'gallery' }), revision, stamp, id);++ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'published', revision, sharing: 'gallery' }, stamp);++ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));++ });++ }++++ async function unpublish(user, id) {++ const row = await rowFor(user.subject_id, id);++ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');++ const revision = Number(row.revision) + 1; const stamp = nowIso(now);++ return await db.tx(async () => {++ const doc = parseDoc(row.doc) || {};++ await db.prepare("UPDATE ui_layouts SET sharing = 'private', published_at = NULL, doc = ?, revision = ?, updated_at = ? WHERE id = ?")++ .run(JSON.stringify({ ...doc, sharing: 'private' }), revision, stamp, id);++ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'unpublished', revision, sharing: 'private' }, stamp);++ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));++ });++ }++++ async function gallery({ surface = null, device = null, limit = 60 } = {}) {++ const where = ["sharing = 'gallery'", 'published_at IS NOT NULL']; const params = [];++ if (surface) { where.push('surface = ?'); params.push(surface); }++ if (device) { where.push('device = ?'); params.push(device); }++ const rows = await db.prepare(`SELECT * FROM ui_layouts WHERE ${where.join(' AND ')} ORDER BY published_at DESC LIMIT ?`).all(...params, Math.min(100, Math.max(1, limit)));++ return rows.map(publicView).filter(Boolean);++ }++++ async function shared(token) {++ if (!TOKEN_RE.test(String(token || ''))) return null;++ const row = await db.prepare("SELECT * FROM ui_layouts WHERE share_token = ? AND sharing IN ('link', 'gallery')").get(token);++ return row ? publicView(row) : null;++ }++++ async function copy(user, id) {++ const source = await db.prepare("SELECT * FROM ui_layouts WHERE id = ? AND sharing = 'gallery' AND published_at IS NOT NULL").get(id);++ if (!source) throw new LayoutError(404, 'layout.not_found', 'no such gallery layout');++ const doc = parseDoc(source.doc);++ if (!doc) throw new LayoutError(500, 'layout.corrupt', 'this layout cannot be read');++ const ms = at(); const stamp = nowIso(now);++ const copyId = newLayoutId(ms);++ const name = `${String(source.name).slice(0, 72)} (copy)`;++ return await db.tx(async () => {++ const count = (await db.prepare('SELECT COUNT(*) AS n FROM ui_layouts WHERE subject_id = ?').get(user.subject_id)).n;++ if (Number(count) >= MAX_SAVED) throw new LayoutError(409, 'layout.limit', `you can keep ${MAX_SAVED} layouts`);++ const outDoc = { ...doc, name, author: { name: String(user.display_name || user.username || 'OpenVibe user').slice(0, 80), subject: { type: 'user', id: user.subject_id } }, sharing: 'private' };++ await db.prepare(`INSERT INTO ui_layouts (id, subject_id, surface, surface_version, device, name, doc, active, sharing, revision, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, FALSE, 'private', 1, ?, ?)`)++ .run(copyId, user.subject_id, source.surface, source.surface_version, source.device, name, JSON.stringify(outDoc), stamp, stamp);++ await queue(db, user.subject_id, { surface: source.surface, device: source.device, layout_id: copyId, change: 'copied', revision: 1, sharing: 'private' }, stamp);++ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(copyId));++ });++ }++++ /** The first-party service read: the active layout one person made for the product's own surface. */++ async function forSubject(subject, surface, device) {++ const user = await db.prepare('SELECT id, subject_id FROM users WHERE subject_id = ?').get(subject);++ if (!user) throw new LayoutError(404, 'identity.subject_not_found', 'no such person');++ const layout = await active(subject, surface, device);++ if (!layout) throw new LayoutError(404, 'layout.none', 'this person has not arranged this surface on this device');++ return layout;++ }++++ async function suggest(user, surface, device, body) {++ if (!allow(`suggest:${user.subject_id}`, SUGGEST_LIMIT, at())) throw new LayoutError(429, 'layout.rate_limited', 'too many suggestions; wait a minute');++ if (!isObject(body) || !isObject(body.catalog)) throw new LayoutError(400, 'layout.catalog_required', 'send { catalog, layout, words }');++ const words = typeof body.words === 'string' ? body.words.trim() : '';++ if (!words || words.length > 500) throw new LayoutError(422, 'layout.words_invalid', 'describe the arrangement in one to five hundred characters');++ if (body.layout != null) validateWithCatalog(body.catalog, body.layout, surface, device);++ else {++ const c = validate(SURFACE_CONTRACT, body.catalog);++ if (!c.valid) throw new LayoutError(400, 'layout.catalog_invalid', 'the surface catalog is not valid', { errors: c.errors });++ if (body.catalog.id !== surface || !body.catalog.devices.includes(device)) throw new LayoutError(422, 'layout.surface_mismatch', `this catalog is not ${surface} on ${device}`);++ }++ if (typeof selfToken !== 'function') throw new LayoutError(503, 'layout.ai_unavailable', 'OpenVibe.AI is not configured');++ const token = selfToken('openvibe.ai', ['ai.layout.suggest']);++ if (!token) throw new LayoutError(503, 'layout.ai_unavailable', 'OpenVibe.AI is not configured');++ let response;++ try {++ response = await fetchImpl(`${String(aiUrl).replace(/\/$/, '')}/api/v1/ui/layout/suggest`, {++ method: 'POST',++ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'X-OV-Subject': user.subject_id },++ body: JSON.stringify({ catalog: body.catalog, layout: body.layout || null, words, device }),++ });++ } catch (err) {++ log.warn && log.warn('[Layouts] AI did not answer:', err && err.message);++ throw new LayoutError(503, 'layout.ai_unavailable', 'OpenVibe.AI did not answer');++ }++ let answer = null;++ try { answer = await response.json(); } catch { /* a non-JSON answer is a failure below */ }++ if (!response.ok) {++ throw new LayoutError(response.status === 429 ? 429 : 502, 'layout.ai_failed', (answer && (answer.detail || answer.error)) || 'OpenVibe.AI refused the suggestion');++ }++ if (!validate(SUGGESTION_CONTRACT, answer).valid) throw new LayoutError(502, 'layout.ai_invalid', 'OpenVibe.AI answered something that is not a layout suggestion');++ return answer;++ }++++ return { active, get, list, setActive, save, rename, activate, remove, reset, share, unshare, publish, unpublish, gallery, shared, copy, forSubject, suggest };++}++++function sendError(res, err, ctx, log = console) {++ if (err && err.status) return http.sendProblem(res, err.status, err.code || 'layout.error', { detail: err.message, errors: err.errors, extra: err.extra, ctx });++ log.error('[Layouts]', err && err.stack || err);++ return http.sendProblem(res, 500, 'layout.error', { detail: 'could not complete that', ctx });++}++++function deviceOf(req) {++ const device = String(req.query.device || 'phone');++ if (!DEVICES.includes(device)) throw new LayoutError(400, 'layout.device_invalid', `device must be one of ${DEVICES.join(', ')}`);++ return device;++}++++function surfaceOf(req) {++ const surface = String(req.params.surface || '');++ if (!SURFACE_RE.test(surface)) throw new LayoutError(400, 'layout.surface_invalid', 'name the surface, e.g. bot.controls');++ return surface;++}++++function idOf(req) {++ const id = String(req.params.id || '');++ if (!LAYOUT_ID_RE.test(id)) throw new LayoutError(400, 'layout.id_invalid', 'name a layout id (lay_…)');++ return id;++}++++/** The person's routes, behind Network's session check (a Bearer token from another first-party site). */++function router({ layouts, requireAuth }) {++ const r = express.Router();++ r.use(http.middleware());++ r.use(express.json({ limit: MAX_BODY }));++ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });++ r.get('/', requireAuth, async (req, res) => {++ try {++ const device = req.query.device ? deviceOf(req) : null;++ const surface = req.query.surface ? String(req.query.surface) : null;++ if (surface && !SURFACE_RE.test(surface)) throw new LayoutError(400, 'layout.surface_invalid', 'name the surface, e.g. bot.controls');++ res.json({ layouts: await layouts.list(req.user.subject_id, { surface, device }) });++ } catch (err) { sendError(res, err, req.ov); }++ });++ r.post('/', requireAuth, async (req, res) => {++ try { res.status(201).json(await layouts.save(req.user, req.body)); }++ catch (err) { sendError(res, err, req.ov); }++ });++ r.get('/:id', requireAuth, async (req, res, next) => {++ if (!LAYOUT_ID_RE.test(String(req.params.id || ''))) return next();++ try { res.json(await layouts.get(req.user.subject_id, req.params.id)); }++ catch (err) { sendError(res, err, req.ov); }++ });++ r.get('/:surface', requireAuth, async (req, res) => {++ try {++ const layout = await layouts.active(req.user.subject_id, surfaceOf(req), deviceOf(req));++ if (!layout) return http.sendProblem(res, 404, 'layout.none', { detail: 'no layout saved for this surface and device; draw the default', ctx: req.ov });++ res.json(layout);++ } catch (err) { sendError(res, err, req.ov); }++ });++ r.put('/:surface', requireAuth, async (req, res) => {++ try { res.json(await layouts.setActive(req.user, surfaceOf(req), deviceOf(req), req.body)); }++ catch (err) { sendError(res, err, req.ov); }++ });++ r.post('/:surface/reset', requireAuth, async (req, res) => {++ try {++ const reset = await layouts.reset(req.user, surfaceOf(req), deviceOf(req));++ if (!reset) return http.sendProblem(res, 404, 'layout.none', { detail: 'there is no saved arrangement to reset', ctx: req.ov });++ res.status(204).end();++ } catch (err) { sendError(res, err, req.ov); }++ });++ r.post('/:surface/suggest', requireAuth, async (req, res) => {++ try { res.json(await layouts.suggest(req.user, surfaceOf(req), deviceOf(req), req.body)); }++ catch (err) { sendError(res, err, req.ov); }++ });++ r.patch('/:id', requireAuth, async (req, res) => {++ try { res.json(await layouts.rename(req.user, idOf(req), req.body)); }++ catch (err) { sendError(res, err, req.ov); }++ });++ r.delete('/:id', requireAuth, async (req, res) => {++ try { await layouts.remove(req.user, idOf(req)); res.status(204).end(); }++ catch (err) { sendError(res, err, req.ov); }++ });++ r.post('/:id/activate', requireAuth, async (req, res) => {++ try { res.json(await layouts.activate(req.user, idOf(req))); }++ catch (err) { sendError(res, err, req.ov); }++ });++ r.post('/:id/share', requireAuth, async (req, res) => {++ try { res.json(await layouts.share(req.user, idOf(req))); }++ catch (err) { sendError(res, err, req.ov); }++ });++ r.delete('/:id/share', requireAuth, async (req, res) => {++ try { res.json(await layouts.unshare(req.user, idOf(req))); }++ catch (err) { sendError(res, err, req.ov); }++ });++ r.post('/:id/publish', requireAuth, async (req, res) => {++ try { res.json(await layouts.publish(req.user, idOf(req))); }++ catch (err) { sendError(res, err, req.ov); }++ });++ r.delete('/:id/publish', requireAuth, async (req, res) => {++ try { res.json(await layouts.unpublish(req.user, idOf(req))); }++ catch (err) { sendError(res, err, req.ov); }++ });++ return r;++}++++/** The public, read-only routes: the gallery and one shared layout, with no account data. */++function publicRouter({ layouts, requireAuth }) {++ const r = express.Router();++ r.use(http.middleware());++ const publicCache = (req, res, next) => { res.set('Cache-Control', 'public, max-age=60'); next(); };++ r.get('/gallery', publicCache, async (req, res) => {++ try {++ const surface = req.query.surface ? String(req.query.surface) : null;++ if (surface && !SURFACE_RE.test(surface)) throw new LayoutError(400, 'layout.surface_invalid', 'name the surface, e.g. bot.controls');++ const device = req.query.device ? deviceOf(req) : null;++ res.json({ layouts: await layouts.gallery({ surface, device }) });++ } catch (err) { sendError(res, err, req.ov); }++ });++ r.get('/shared/:token', publicCache, async (req, res) => {++ try {++ const layout = await layouts.shared(req.params.token);++ if (!layout) return http.sendProblem(res, 404, 'layout.not_found', { detail: 'this share link is gone', ctx: req.ov });++ res.json({ layout });++ } catch (err) { sendError(res, err, req.ov); }++ });++ r.post('/gallery/:id/copy', (req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); }, requireAuth, async (req, res) => {++ try { res.status(201).json(await layouts.copy(req.user, idOf(req))); }++ catch (err) { sendError(res, err, req.ov); }++ });++ return r;++}++++/**++ * The guard for the service read: a first-party service token (Network's own issuer) holding++ * network.layout.read. It names the person with X-OV-Subject; the path names the same person.++ */++function serviceGuard() {++ return async function layoutServiceGuard(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 read a layout here', 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();++ };++}++++/** GET /api/v1/people/:subject/layouts/:surface, guarded by network.layout.read. */++function serviceRouter({ layouts }) {++ const r = express.Router();++ r.use(http.middleware());++ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });++ r.get('/:subject/layouts/:surface', serviceGuard(), async (req, res) => {++ try {++ const subject = String(req.params.subject || '');++ if (!SUBJECT_RE.test(subject)) return http.sendProblem(res, 400, 'layout.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, 'layout.subject_mismatch', { detail: 'X-OV-Subject names another person', ctx: req.ov });++ res.json(await layouts.forSubject(subject, surfaceOf(req), deviceOf(req)));++ } catch (err) { sendError(res, err, req.ov); }++ });++ return r;++}++++module.exports = { createLayouts, router, publicRouter, serviceRouter, serviceGuard, CONTRACT, EVENT_TYPE, SERVICE_CAPABILITY, MAX_SAVED };@@@ -32,6 +32,10 @@ function isPublicDiscoveryPath(p) {if (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;if (/^\/api\/v1\/people\/[A-Za-z0-9_]{3,24}\/card$/.test(path)) return true;if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads++ // People's own controls (2026-11-11): the gallery and a shared layout are read-only and carry no++ // account data, so any site may draw them without a token.++ if (path === '/api/v1/layouts/gallery' || path === '/api/v1/layouts/gallery/') return true;++ if (/^\/api\/v1\/layouts\/shared\/[A-Za-z0-9_-]{24,64}$/.test(path)) return true;if (path === '/api/v1/frame/today' || path === '/api/v1/frame/today/') return true;if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every siteif (/^\/api\/v1\/people\/@[A-Za-z0-9_]{1,40}\/page$/.test(path)) return true; // public person page JSON@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand'], ['0078', 'feed_seen', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand'], ['0078', 'feed_seen', 'expand'], ['0079', 'ui_layouts', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.nnew file mode 100644@@@ -0,0 +1,295 @@++'use strict';++// People's own controls, stage 1 (design 2026-11-11, piece 4): the active layout per surface and++// device, saved layouts, share links, the gallery and copying, the service read, the AI suggestion++// proxy, account export/deletion and network.layout.changed@1.++// node --import ./test/helpers/pg-preload.mjs test/layouts.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 { serviceAuth, validate } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const authRoutes = require('../server/auth/routes');++const accountData = require('../server/identity/account-data');++const layoutsMod = require('../server/layouts');++++(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, display_name) VALUES (?, ?, ?, ?) RETURNING id').run(name, `${name}@example.test`, 'x', name)).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('layoutana');++const bob = await mk('layoutbob');++++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 serviceToken = (cap, sub = 'svc:bot') => serviceAuth.signServiceToken({++ iss: config.jwt.issuer, sub, actor_type: 'service', aud: ['openvibe.network'], cap: [cap],++ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: 'tok_layouts_test',++}, keys.privateKey);++++const catalog = () => ({++ id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone', 'tablet', 'computer'],++ grid: { columns: { phone: 4, tablet: 8, computer: 12 }, cell_px: { phone: 44, tablet: 44, computer: 44 } },++ theme_tokens: ['danger', 'surface', 'text'],++ parts: [++ { id: 'stop', name: 'Stop', kind: 'button', required: true, pressed: true, min: { w: 2, h: 2, px: 64 },++ styles: { tints: ['danger'], shapes: ['round', 'square'], opacity: { min: 0.7, max: 1 }, label: true },++ bindings: { keys: ['Space'], gamepad: ['south'] }, never_covers: [] },++ { id: 'stick', name: 'Drive stick', kind: 'stick', required: false, pressed: true, min: { w: 2, h: 2 },++ styles: { tints: ['surface'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false },++ bindings: { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] }, never_covers: ['stop'] },++ { id: 'battery', name: 'Battery', kind: 'readout', required: false, pressed: false, min: { w: 1, h: 1 },++ styles: { tints: ['text'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true },++ bindings: { keys: [], gamepad: [] }, never_covers: [] },++ ],++});++const part = (id, place) => ({++ id, visible: true, place,++ style: { tint: id === 'stop' ? 'danger' : id === 'stick' ? 'surface' : 'text', shape: id === 'stop' ? 'round' : id === 'battery' ? 'pill' : 'round', opacity: 1, label: id !== 'stick' },++ bindings: id === 'stop' ? { keys: ['Space'], gamepad: ['south'] } : id === 'stick' ? { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] } : { keys: [], gamepad: [] },++});++const layout = (name = 'My phone controls') => ({++ surface: 'bot.controls', surface_version: 1, device: 'phone', name,++ author: { name: 'Ana' }, sharing: 'private',++ parts: [part('stop', { x: 0, y: 0, w: 2, h: 2 }), part('stick', { x: 0, y: 2, w: 2, h: 2 }), part('battery', { x: 2, y: 2, w: 1, h: 1 })],++});++++let aiRequest = null;++let aiAnswer = null;++const suggestion = () => ({ ok: true, layout: { ...layout('Suggested'), id: undefined }, summary: 'Moved Stop to the left.', reasons: ['Stop stayed visible.'], receipt: { model: 'stub-1' } });++const layouts = layoutsMod.createLayouts({++ db, selfToken: () => 'test-service-token', fetchImpl: async (url, opts) => {++ aiRequest = { url, opts };++ if (aiAnswer) return aiAnswer;++ return { ok: true, status: 200, json: async () => suggestion() };++ },++});++++const app = express();++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++app.use(require('openvibe-contracts').http.middleware());++app.use('/api/v1/me/layouts', layoutsMod.router({ layouts, requireAuth }));++app.use('/api/v1/layouts', layoutsMod.publicRouter({ layouts, requireAuth }));++app.use('/api/v1/people', layoutsMod.serviceRouter({ layouts }));++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 call = (method, path, body, { token = tokenOf(ana), headers = {} } = {}) => fetch(base + path, {++ method,++ headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers },++ ...(body === undefined ? {} : { body: JSON.stringify(body) }),++}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})), headers: r.headers }));++const active = '/api/v1/me/layouts/bot.controls?device=phone';++const outbox = async () => (await db.prepare("SELECT envelope FROM network_event_outbox WHERE (envelope->>'event_type') = 'network.layout.changed' ORDER BY id").all()).map((r) => r.envelope);++const clearOutbox = async () => { await db.prepare("DELETE FROM network_event_outbox WHERE (envelope->>'event_type') = 'network.layout.changed'").run(); };++++try {++ // The migration is expand-only: an older release never reads the table.++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'migrations', '0079_ui_layouts.sql'), 'utf8'), /^-- phase: expand$/m);++ // No arrangement yet: 404 with a code the editor understands, never a made-up default.++ let r = await call('GET', active);++ assert.strictEqual(r.status, 404);++ assert.strictEqual(r.body.code, 'layout.none');++ r = await call('GET', active, undefined, { token: null });++ assert.strictEqual(r.status, 401, 'a session is needed to read a person’s layout');++++ // A layout that hides Stop is refused with the SDK's plain reason; nothing is stored.++ const hidden = layout(); hidden.parts[0].visible = false;++ r = await call('PUT', active, { catalog: catalog(), layout: hidden });++ assert.strictEqual(r.status, 422, JSON.stringify(r.body));++ assert.strictEqual(r.body.code, 'layout.invalid');++ assert.ok(r.body.reasons.includes('Stop has to stay visible.'), JSON.stringify(r.body.reasons));++ r = await call('GET', active);++ assert.strictEqual(r.status, 404, 'the refused layout was not stored');++++ // The body must carry the catalog: validation is the SDK's, against a real surface.++ r = await call('PUT', active, { layout: layout() });++ assert.strictEqual(r.status, 400);++ assert.strictEqual(r.body.code, 'layout.body_required');++ r = await call('PUT', active, { catalog: { ...catalog(), id: 'bot.overlay' }, layout: layout() });++ assert.strictEqual(r.status, 422);++ assert.strictEqual(r.body.code, 'layout.surface_mismatch');++++ // A complete arrangement is stored, active, with the store's id, revision and the person's name.++ await clearOutbox();++ r = await call('PUT', active, { catalog: catalog(), layout: layout() });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.match(r.body.id, /^lay_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(r.body.revision, 1);++ assert.strictEqual(r.body.active, true);++ assert.strictEqual(r.body.sharing, 'private');++ assert.strictEqual(r.body.name, 'My phone controls');++ assert.strictEqual(r.body.author.name, 'layoutana');++ assert.deepStrictEqual(r.body.author.subject, { type: 'user', id: ana.subject_id });++ assert.ok(validate('ui.layout@1', r.body).valid);++ const firstId = r.body.id;++ r = await call('GET', active);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.id, firstId, 'the active layout reads back');++ let events = await outbox();++ assert.strictEqual(events.length, 1, 'one change event');++ assert.strictEqual(events[0].payload.change, 'active');++ assert.strictEqual(events[0].payload.layout_id, firstId);++ assert.strictEqual(events[0].subject.id, ana.subject_id);++ assert.strictEqual(events[0].subject.revision, 1);++++ // Saving again updates the active row in place; a second PUT is a new revision and a new event.++ r = await call('PUT', active, { catalog: catalog(), layout: layout('One thumb') });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.id, firstId);++ assert.strictEqual(r.body.revision, 2);++ assert.strictEqual(r.body.name, 'One thumb');++ events = await outbox();++ assert.strictEqual(events.length, 2);++ assert.strictEqual(events[1].payload.revision, 2);++++ // Saved layouts: POST creates one beside the active, list answers both, rename moves only the name.++ r = await call('POST', '/api/v1/me/layouts', { catalog: catalog(), layout: layout('Saved one') });++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ assert.strictEqual(r.body.active, false);++ const savedId = r.body.id;++ r = await call('GET', '/api/v1/me/layouts');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.layouts.length, 2);++ assert.strictEqual(r.body.layouts.find((x) => x.id === firstId).active, true, 'active first');++ r = await call('PATCH', `/api/v1/me/layouts/${savedId}`, { name: 'One hand' });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.name, 'One hand');++ assert.strictEqual(r.body.revision, 2);++ assert.ok((await outbox()).some((e) => e.payload.change === 'renamed' && e.payload.layout_id === savedId));++++ // Another person never sees or changes it.++ r = await call('GET', `/api/v1/me/layouts/${savedId}`, undefined, { token: tokenOf(bob) });++ assert.strictEqual(r.status, 404);++ r = await call('PATCH', `/api/v1/me/layouts/${savedId}`, { name: 'Mine now' }, { token: tokenOf(bob) });++ assert.strictEqual(r.status, 404);++ r = await call('DELETE', `/api/v1/me/layouts/${savedId}`, undefined, { token: tokenOf(bob) });++ assert.strictEqual(r.status, 404);++++ // Activate swaps which one is active; reset returns to the product's default and keeps the saved row.++ r = await call('POST', `/api/v1/me/layouts/${savedId}/activate`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.active, true);++ let list = (await call('GET', '/api/v1/me/layouts')).body.layouts;++ assert.strictEqual(list.find((x) => x.id === firstId).active, false);++ assert.strictEqual(list.find((x) => x.id === savedId).active, true);++ r = await call('POST', '/api/v1/me/layouts/bot.controls/reset?device=phone');++ assert.strictEqual(r.status, 204);++ assert.strictEqual((await call('GET', active)).status, 404, 'reset removes the active arrangement');++ list = (await call('GET', '/api/v1/me/layouts')).body.layouts;++ assert.strictEqual(list.length, 2, 'reset keeps the saved layouts');++ assert.ok((await outbox()).some((e) => e.payload.change === 'reset'));++++ // Share: a token and a read-only public answer with no account data; unshare makes it private.++ r = await call('POST', `/api/v1/me/layouts/${firstId}/share`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.layout.sharing, 'link');++ assert.match(r.body.token, /^[A-Za-z0-9_-]{24,64}$/);++ assert.strictEqual(r.body.url, `/api/v1/layouts/shared/${r.body.token}`);++ const shareToken = r.body.token;++ const shared = await call('GET', r.body.url, undefined, { token: null });++ assert.strictEqual(shared.status, 200);++ assert.strictEqual(shared.body.layout.author.name, 'layoutana');++ assert.strictEqual(shared.body.layout.author.subject, undefined, 'no account in a share link');++ assert.strictEqual(shared.body.layout.id, firstId);++ assert.strictEqual((await call('GET', '/api/v1/layouts/shared/nope', undefined, { token: null })).status, 404);++ r = await call('DELETE', `/api/v1/me/layouts/${firstId}/share`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.sharing, 'private');++ assert.strictEqual((await call('GET', `/api/v1/layouts/shared/${shareToken}`, undefined, { token: null })).status, 404, 'an unshared link is gone');++++ // Gallery: publish, list publicly, copy into another person's account, unpublish.++ r = await call('POST', `/api/v1/me/layouts/${firstId}/publish`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.sharing, 'gallery');++ r = await call('GET', '/api/v1/layouts/gallery?surface=bot.controls&device=phone', undefined, { token: null });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.layouts.length, 1);++ assert.strictEqual(r.body.layouts[0].author.subject, undefined, 'the gallery carries no account');++ assert.strictEqual(r.body.layouts[0].id, firstId);++ r = await call('POST', `/api/v1/layouts/gallery/${firstId}/copy`, undefined, { token: tokenOf(bob) });++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ assert.strictEqual(r.body.author.subject.id, bob.subject_id);++ assert.strictEqual(r.body.active, false);++ assert.strictEqual(r.body.sharing, 'private');++ assert.match(r.body.name, /\(copy\)$/);++ const bobCopy = r.body.id;++ assert.strictEqual((await call('GET', '/api/v1/me/layouts', undefined, { token: tokenOf(bob) })).body.layouts.length, 1);++ r = await call('GET', '/api/v1/layouts/gallery?surface=bot.controls&device=phone', undefined, { token: null });++ assert.strictEqual(r.body.layouts.length, 1, 'copying does not publish the copy');++ r = await call('DELETE', `/api/v1/me/layouts/${firstId}/publish`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual((await call('GET', '/api/v1/layouts/gallery', undefined, { token: null })).body.layouts.length, 0);++ r = await call('POST', '/api/v1/layouts/gallery/nope/copy', undefined, { token: tokenOf(bob) });++ assert.strictEqual(r.status, 400, 'a gallery id has a shape');++ assert.strictEqual(typeof bobCopy, 'string');++++ // Make the first layout active again for the product read.++ r = await call('PUT', active, { catalog: catalog(), layout: layout() });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ const activeAgain = r.body.id;++ // The service read: a first-party token with network.layout.read, naming the person.++ r = await call('GET', `/api/v1/people/${ana.subject_id}/layouts/bot.controls?device=phone`, undefined, { token: tokenOf(ana) });++ assert.strictEqual(r.status, 401, 'a person token does not open the service route');++ r = await call('GET', `/api/v1/people/${ana.subject_id}/layouts/bot.controls?device=phone`, undefined, { token: serviceToken('network.layout.read', 'svc:bot') });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.id, activeAgain);++ r = await call('GET', `/api/v1/people/${ana.subject_id}/layouts/bot.controls?device=phone`, undefined, { token: serviceToken('network.layout.read', 'svc:bot'), headers: { 'X-OV-Subject': bob.subject_id } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.body.code, 'layout.subject_mismatch');++ r = await call('GET', `/api/v1/people/${ana.subject_id}/layouts/bot.controls?device=phone`, undefined, { token: serviceToken('network.layout.read', 'svc:bot'), headers: { 'X-OV-Subject': ana.subject_id } });++ assert.strictEqual(r.status, 200);++ r = await call('GET', `/api/v1/people/${ana.subject_id}/layouts/bot.controls?device=phone`, undefined, { token: serviceToken('network.follows.read', 'svc:bot') });++ assert.strictEqual(r.status, 403, 'a service without the layout capability is refused');++ r = await call('GET', `/api/v1/people/usr_01JAB2C3D4E5F6G7H8J9K0MNPQ/layouts/bot.controls?device=phone`, undefined, { token: serviceToken('network.layout.read', 'svc:bot') });++ assert.strictEqual(r.status, 404);++++ // The AI call goes through Network to OpenVibe.AI as the person, with the catalog and the words.++ aiRequest = null;++ await call('PUT', active, { catalog: catalog(), layout: layout() });++ r = await call('POST', '/api/v1/me/layouts/bot.controls/suggest?device=phone', { catalog: catalog(), layout: layout(), words: 'put Stop on the left and make it huge' });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.strictEqual(r.body.ok, true);++ assert.ok(aiRequest, 'OpenVibe.AI was called');++ assert.ok(aiRequest.url.endsWith('/api/v1/ui/layout/suggest'));++ assert.strictEqual(aiRequest.opts.headers['X-OV-Subject'], ana.subject_id);++ assert.strictEqual(aiRequest.opts.headers.authorization, 'Bearer test-service-token');++ assert.strictEqual(JSON.parse(aiRequest.opts.body).words, 'put Stop on the left and make it huge');++ r = await call('POST', '/api/v1/me/layouts/bot.controls/suggest?device=phone', { catalog: catalog(), words: '' });++ assert.strictEqual(r.status, 422, 'empty words are refused before a model call');++ aiAnswer = { ok: false, status: 502, json: async () => ({ detail: 'the model refused' }) };++ r = await call('POST', '/api/v1/me/layouts/bot.controls/suggest?device=phone', { catalog: catalog(), words: 'make it one thumb' });++ assert.strictEqual(r.status, 502);++ assert.strictEqual(r.body.code, 'layout.ai_failed');++ aiAnswer = { ok: true, status: 200, json: async () => ({ nope: true }) };++ r = await call('POST', '/api/v1/me/layouts/bot.controls/suggest?device=phone', { catalog: catalog(), words: 'make it one thumb' });++ assert.strictEqual(r.status, 502);++ assert.strictEqual(r.body.code, 'layout.ai_invalid');++ aiAnswer = null;++++ // Account export carries the layouts; deletion removes them.++ const part = await accountData.networkPart(db, ana.id);++ const exported = part.files.find((f) => f.name === 'layouts.json');++ assert.ok(exported, 'layouts.json is in the export');++ assert.ok(exported.content.some((x) => x.id === firstId && x.doc.surface === 'bot.controls'));++ await accountData.scheduleDeletion(db, ana, { auth_time: Math.floor(Date.now() / 1000) }, 'layoutana');++ const deletion = await db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(ana.id);++ const erased = await accountData.erase(db, deletion);++ assert.ok(erased.ui_layouts >= 1, 'the layouts are erased with the account');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM ui_layouts WHERE subject_id = ?').get(ana.subject_id)).n, 0);++++ log('layouts: active, saved, share, gallery, service read, AI proxy, export and deletion passed');++} finally {++ await new Promise((r) => server.close(r));++}++})().catch((err) => { console.error(err); process.exit(1); });@@@ -11,12 +11,14 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');(async () => {// Path decisionfor (const p of ['/.well-known/openvibe', '/api/v1/registry', '/api/v1/registry/', '/api/v1/registry/services', '/api/v1/registry/services/network', '/api/v1/people/@Ana/page',-- '/api/v1/registry/domains/openvibe.community', '/api/v1/registry/capabilities/media.object.upload', '/contracts/identity/subject-ref.v1.json', '/contracts/events/payloads/deals.watch.matched.v1.json']) {++ '/api/v1/registry/domains/openvibe.community', '/api/v1/registry/capabilities/media.object.upload', '/contracts/identity/subject-ref.v1.json', '/contracts/events/payloads/deals.watch.matched.v1.json',++ '/api/v1/layouts/gallery', '/api/v1/layouts/gallery/', '/api/v1/layouts/shared/abcdefghijklmnopqrstuvwx']) {assert.ok(publicCors.isPublicDiscoveryPath(p), `${p} is public`);}for (const p of ['/api/v1/registryx', '/api/v1/registry-admin', '/api/v1/registry/../projects', '/api/v1/registry/%2e%2e/projects', '/api/v1/registry/a%2Fb','/api/v1/registry//services', '/contracts/../x', '/contracts/foo', '/contracts/identity/subject-ref.json', '/contracts/identity/subject-ref.v1.json/x',-- '/.well-known/openvibe/x', '/.well-known/openid-configuration', '/api/.well-known/jwks', '/api/auth/login', '/api/v1/projects', '/oauth/token', '/']) {++ '/.well-known/openvibe/x', '/.well-known/openid-configuration', '/api/.well-known/jwks', '/api/auth/login', '/api/v1/projects', '/oauth/token', '/',++ '/api/v1/layouts/gallery/secret', '/api/v1/layouts/galleryx', '/api/v1/layouts/shared/short', '/api/v1/layouts/shared/' + 'a'.repeat(65), '/api/v1/me/layouts']) {assert.ok(!publicCors.isPublicDiscoveryPath(p), `${p} is not public`);}@@@ -70,7 +70,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcdlet r = await get('/api/v1/registry/topics');assert.ok(r.body.topics.length >= produced.size, 'no longer empty');r = await get('/api/v1/registry/topics?producer=live');-- assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), ['live.agent_stream.changed', 'live.index_document.deleted', 'live.index_document.upserted', 'live.moderation.action', 'live.release.deployed', 'live.setup.task_completed', 'live.stream.ended', 'live.stream.started']);++ assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), ['live.agent_stream.changed', 'live.element.pressed', 'live.element.state', 'live.index_document.deleted', 'live.index_document.upserted', 'live.moderation.action', 'live.release.deployed', 'live.setup.task_completed', 'live.stream.ended', 'live.stream.started']);r = await get('/api/v1/registry/topics?consumer=network');assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), [...TOPICS].sort());r = await get('/api/v1/registry/topics?prefix=media.vod.');@@@ -140,6 +140,7 @@ const REVIEWED = {'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],'server/link/send.js fetch': [8, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys, the resumable transfer parts and the delivery list); page input supplies file bytes, never a URL or destination host'],'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],++ 'server/layouts/index.js fetch': [1, 'OpenVibe.AI internal URL from operator config (OV_AI_INTERNAL_URL, loopback default); the fixed /api/v1/ui/layout/suggest path, a service token and the person\'s usr_ id in a header, never a request-selected host or URL'],'server/actions/index.js fetch': [7, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],'server/profile/card.js fetch': [2, 'Operator-configured Quest and Live bases only, fixed paths with encoded canonical identities, no redirects, two second timeout'],'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],@@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand'], ['0078', 'feed_seen', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand'], ['0078', 'feed_seen', 'expand'], ['0079', 'ui_layouts', '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// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
@@ -6,8 +6,8 @@
@@ -6,8 +6,8 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
- "contracts": "openvibe-contracts v0.407.0",
- "sdk": "openvibe-sdk v0.150.0",
+ "contracts": "openvibe-contracts v0.410.0",
+ "sdk": "openvibe-sdk v0.152.0",
"shared": "openvibe-shared v4.211.0",
"shared": "openvibe-shared v4.211.0",
"capabilities": [
"capabilities": [
"identity.subject.resolve",
"identity.subject.resolve",
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+-- phase: expand
+-- People's own controls (make your own controls design, 2026-11-11, piece 4): one row per layout a
+-- person made for a surface, ui.layout@1 as a JSON value. The active row per (person, surface, device)
+-- is what a product reads; the rest are saved layouts. A share token makes one read-only without an
+-- account, and `sharing` says private, link or gallery. The row is the person's own, so it goes into
+-- their account export and is erased with them (ADR-033).
+--
+-- Expand only: an older release never reads this table, and nothing here changes an existing one.
+CREATE TABLE IF NOT EXISTS ui_layouts (
+ id text COLLATE "C" PRIMARY KEY,
+ subject_id text COLLATE "C" NOT NULL,
+ surface text COLLATE "C" NOT NULL,
+ surface_version integer NOT NULL,
+ device text COLLATE "C" NOT NULL CHECK (device IN ('phone', 'tablet', 'computer')),
+ name text COLLATE "C" NOT NULL,
+ doc jsonb NOT NULL,
+ active boolean NOT NULL DEFAULT false,
+ sharing text COLLATE "C" NOT NULL DEFAULT 'private' CHECK (sharing IN ('private', 'link', 'gallery')),
+ share_token text COLLATE "C" UNIQUE,
+ published_at timestamptz,
+ revision bigint NOT NULL DEFAULT 1,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+-- One active layout for a surface on a device class per person: setting another replaces it.
+CREATE UNIQUE INDEX IF NOT EXISTS ui_layouts_one_active ON ui_layouts (subject_id, surface, device) WHERE active;
+CREATE INDEX IF NOT EXISTS ui_layouts_by_subject ON ui_layouts (subject_id, updated_at DESC);
+CREATE INDEX IF NOT EXISTS ui_layouts_gallery ON ui_layouts (surface, device, published_at DESC) WHERE sharing = 'gallery' AND published_at IS NOT NULL;
@@ -22,8 +22,8 @@
@@ -22,8 +22,8 @@
"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.409.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2679,9 +2679,9 @@
@@ -2679,9 +2679,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.409.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",
- "integrity": "sha512-Yh8XcOEKIrYG7WV8ack03hmobZwMyZIeOogIrCeIWvuHKpvjYYL+UcOj7DrNalxC5fp0EnhVwRj89DTFm/AH2g==",
+ "version": "0.410.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "integrity": "sha512-rmmq2mebXim2fv+sip2YQ0PrrZLxGUhEfe1w+1EFfGdoVvLI5tp0/eavY2sW6/VU+rzs7cQMC/A0NZD5th5wrA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -2695,9 +2695,9 @@
@@ -2695,9 +2695,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.150.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",
+ "version": "0.152.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
+ "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -26,8 +26,9 @@
@@ -26,8 +26,9 @@
"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.409.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -198,6 +198,13 @@ async function networkPart(db, userId) {
@@ -198,6 +198,13 @@ async function networkPart(db, userId) {
if (typeof home === 'string') { try { home = JSON.parse(home); } catch { home = null; } }
if (typeof home === 'string') { try { home = JSON.parse(home); } catch { home = null; } }
add('home-screen.json', home && typeof home === 'object' && Array.isArray(home.home) && Array.isArray(home.folders)
add('home-screen.json', home && typeof home === 'object' && Array.isArray(home.home) && Array.isArray(home.folders)
? home : { home: [], folders: [], updated_at: null });
? home : { home: [], folders: [], updated_at: null });
+ // People's own controls (2026-11-11): every layout they made, the active one included, with the
+ // catalog fields the store keeps. A share token is exported as the token: it is the person's own link.
+ add('layouts.json', await tableExists(db, 'ui_layouts')
+ ? (await db.prepare(`SELECT id, surface, surface_version, device, name, doc, active, sharing, share_token, published_at, revision, created_at, updated_at
+ FROM ui_layouts WHERE subject_id = ? ORDER BY created_at`).all(u.subject_id)).map((r) => ({
+ ...r, doc: typeof r.doc === 'string' ? (() => { try { return JSON.parse(r.doc); } catch { return r.doc; } })() : r.doc, active: !!r.active,
+ })) : []);
await byUser('history.json', 'user_history');
await byUser('history.json', 'user_history');
// The Dock's state (the Dock plan 2026-10-30, K-D1): the little floating surface every OpenVibe site draws.
// 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')
add('dock.json', await tableExists(db, 'dock_states')
@@ -377,6 +384,8 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -377,6 +384,8 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
consentEvents = await require('../consent').revokeForDeletion(db, subjects, { at });
consentEvents = await require('../consent').revokeForDeletion(db, subjects, { at });
// The Dock's state (the Dock plan 2026-10-30, K-D1): the person's own row, gone with them.
// 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('dock_states', `subject IN ${inS}`, subjects, 'dock_states');
+ // People's own controls (2026-11-11): the layouts they made, and their share links, go with them.
+ await del('ui_layouts', `subject_id IN ${inS}`, subjects, 'ui_layouts');
await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
await del('user_mutes', `muter_subject IN ${inS} OR muted_subject IN ${inS}`, [...subjects, ...subjects], 'mutes');
await del('user_mutes', `muter_subject IN ${inS} OR muted_subject IN ${inS}`, [...subjects, ...subjects], 'mutes');
@@ -394,6 +394,9 @@ const DEFAULT_GRANTS = [
@@ -394,6 +394,9 @@ const DEFAULT_GRANTS = [
// The owner's AI tab lists and edits AI's provider registry through AI's own admin API (GET/POST/PATCH
// The owner's AI tab lists and edits AI's provider registry through AI's own admin API (GET/POST/PATCH
// /api/v1/providers), as Network, naming the owner in X-OV-Subject.
// /api/v1/providers), as Network, naming the owner in X-OV-Subject.
['network', 'ai.provider.manage', 'openvibe.ai', []],
['network', 'ai.provider.manage', 'openvibe.ai', []],
+ // People's own controls (2026-11-11): Network calls OpenVibe.AI's ui.layout.suggest for the signed-in
+ // person, naming them in X-OV-Subject, so the run counts against that person's own AI limits.
+ ['network', 'ai.layout.suggest', 'openvibe.ai', []],
// Host's demand planner reads AI's usage feed (Host #283): ai.usage.read on openvibe.ai.
// Host's demand planner reads AI's usage feed (Host #283): ai.usage.read on openvibe.ai.
['host', 'ai.usage.read', 'openvibe.ai', []],
['host', 'ai.usage.read', 'openvibe.ai', []],
// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.
// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.
@@ -916,10 +916,18 @@ require('./developer/confirmations').start(db);
@@ -916,10 +916,18 @@ require('./developer/confirmations').start(db);
app.use('/api/v1/me/feed-seen', require('./feed-seen').router({ requireAuth }));
app.use('/api/v1/me/feed-seen', require('./feed-seen').router({ requireAuth }));
const homeScreen = require('./home-screen').createHomeScreen({ db });
const homeScreen = require('./home-screen').createHomeScreen({ db });
app.use('/api/v1/me/home-screen', require('./home-screen').router({ homeScreen, requireAuth }));
app.use('/api/v1/me/home-screen', require('./home-screen').router({ homeScreen, requireAuth }));
+ // People's own controls (design 2026-11-11, piece 4): the active arrangement per surface and device,
+ // saved layouts, share links, the gallery and the ui.layout.suggest call. Every write is validated
+ // through openvibe-sdk/ui-layout; each change queues network.layout.changed in the same transaction.
+ const layouts = require('./layouts').createLayouts({ db, selfToken, aiUrl: process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' });
+ app.use('/api/v1/me/layouts', require('./layouts').router({ layouts, requireAuth }));
+ app.use('/api/v1/layouts', require('./layouts').publicRouter({ layouts, requireAuth }));
// The Dock's timer, for a first-party service and not for a person (plan L48 R2): Vibe starts the focus timer of
// 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 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).
// 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 }));
app.use('/api/v1/people', require('./dock').serviceRouter({ dock }));
+ // A product reads the active layout one person made for its own surface (network.layout.read).
+ app.use('/api/v1/people', require('./layouts').serviceRouter({ layouts }));
// The Locker key directory (design 2026-11-06, LOCKER-DESIGN-2026-11-06.md §4): my public keys, my
// The Locker key directory (design 2026-11-06, LOCKER-DESIGN-2026-11-06.md §4): my public keys, my
// wrapped private keys and anybody's public keys (server/locker/keys.js). Mounted after the Dock's
// wrapped private keys and anybody's public keys (server/locker/keys.js). Mounted after the Dock's
// service route, which writes the timer of one person.
// service route, which writes the timer of one person.
new file mode 100644
new file mode 100644
@@ -0,0 +1,610 @@
@@ -0,0 +1,610 @@
+'use strict';
+/**
+ * People's own layouts (make your own controls design, 2026-11-11, piece 4).
+ *
+ * One ui.layout@1 per row: the active arrangement for one surface on one device class, and the saved
+ * ones beside it. A product reads the active one for its surface (with the person's session or, for
+ * a first-party service, network.layout.read); the person's own pages list, save, rename, delete,
+ * set active, share read-only, publish to the gallery and copy from it. Reset to default clears the
+ * active arrangement so the product draws its own.
+ *
+ * Every write is checked twice before it is stored: the document against ui.layout@1, and the
+ * document against the ui.surface@1 catalog the caller sends, through openvibe-sdk/ui-layout — the
+ * same function the editor and OpenVibe.AI use. A refusal is 422 with the validator's plain reasons.
+ * A share or gallery answer carries the name and the parts only, never the account.
+ *
+ * Each change and its network.layout.changed@1 event are one transaction: the event goes through the
+ * existing outbox (server/developer/event-relay.js), so an open page hears about it from Events or
+ * refetches. Account export and deletion include the rows (server/identity/account-data.js).
+ */
+const crypto = require('crypto');
+const express = require('express');
+const { ids, validate, http, serviceAuth, capabilities } = require('openvibe-contracts');
+const uiLayout = require('openvibe-sdk/ui-layout');
+const eventRelay = require('../developer/event-relay');
+
+const CONTRACT = 'ui.layout@1';
+const SURFACE_CONTRACT = 'ui.surface@1';
+const SUGGESTION_CONTRACT = 'ui.layout-suggestion@1';
+const EVENT_TYPE = 'network.layout.changed';
+const DEVICES = ['phone', 'tablet', 'computer'];
+const SURFACE_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
+const LAYOUT_ID_RE = /^lay_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TOKEN_RE = /^[A-Za-z0-9_-]{24,64}$/;
+const SERVICE_CAPABILITY = 'network.layout.read';
+const SELF_AUDIENCE = 'openvibe.network';
+const MAX_SAVED = 200;
+const WRITE_LIMIT = 60;
+const SUGGEST_LIMIT = 10;
+const WINDOW_MS = 60_000;
+const MAX_BODY = '192kb';
+const BAD_TEXT = /(?:https?:\/\/|www\.|(?:^|[\s"'(])(?:javascript|data|vbscript|file):|<\s*\/?\s*(?:script|iframe|img|svg|style|a|object|embed)\b|\bon[a-z]{3,}\s*=|=>|\bfunction\s*\(|\beval\s*\(|\brequire\s*\(|\bimport\s*\()/i;
+
+const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+const iso = (v) => (v instanceof Date ? v.toISOString() : new Date(v).toISOString());
+const nowIso = (now) => new Date(now()).toISOString();
+
+class LayoutError extends Error {
+ constructor(status, code, detail, extra) { super(detail); this.status = status; this.code = code; this.extra = extra; }
+}
+
+/** An id in the lay_ prefix the contract names; the contracts' id table has no layout kind. */
+const newLayoutId = (now) => `lay_${ids.ulid(now)}`;
+
+function parseDoc(value) {
+ if (isObject(value)) return value;
+ if (typeof value !== 'string') return null;
+ try { const v = JSON.parse(value); return isObject(v) ? v : null; } catch { return null; }
+}
+
+/** The stored document plus the store's own fields; null when it no longer matches ui.layout@1. */
+function layoutOf(row) {
+ const doc = row && parseDoc(row.doc);
+ if (!doc) return null;
+ const out = {
+ ...doc,
+ id: row.id,
+ revision: Number(row.revision) || 1,
+ active: Boolean(row.active),
+ sharing: row.sharing || doc.sharing || 'private',
+ updated_at: iso(row.updated_at),
+ };
+ return validate(CONTRACT, out).valid ? out : null;
+}
+
+/** What a share link or the gallery shows: the surface, parts, name, author's display name and id. */
+function publicView(row) {
+ const doc = parseDoc(row && row.doc);
+ if (!doc) return null;
+ const out = {
+ id: row.id,
+ surface: row.surface,
+ surface_version: Number(row.surface_version),
+ device: row.device,
+ name: row.name,
+ author: { name: (doc.author && doc.author.name) || 'OpenVibe user' },
+ sharing: row.sharing,
+ parts: doc.parts,
+ updated_at: iso(row.updated_at),
+ };
+ const v = validate(CONTRACT, out);
+ return v.valid ? out : null;
+}
+
+function plainFaults(errors) {
+ return (errors || []).slice(0, 20).map((e) => `${String(e.path || e.instancePath || '').replace(/^\//, '').replace(/\//g, '.') || 'layout'}: ${e.message}`);
+}
+
+function checked(value, contract = CONTRACT) {
+ const r = validate(contract, value);
+ if (r.valid) return value;
+ throw new LayoutError(422, 'layout.invalid', 'that is not a valid layout', { reasons: plainFaults(r.errors), errors: r.errors });
+}
+
+/** Check a layout and the catalog it belongs to with the SDK, the one authority the editor and AI use. */
+function validateWithCatalog(catalog, layout, surface, device) {
+ const c = validate(SURFACE_CONTRACT, catalog);
+ if (!c.valid) throw new LayoutError(400, 'layout.catalog_invalid', 'the surface catalog is not valid', { errors: c.errors });
+ if (catalog.id !== surface) throw new LayoutError(422, 'layout.surface_mismatch', `this catalog is for ${catalog.id}, not ${surface}`);
+ if (!Array.isArray(catalog.devices) || !catalog.devices.includes(device)) {
+ throw new LayoutError(422, 'layout.device_mismatch', `${surface} is not offered on ${device}`);
+ }
+ checked(layout);
+ if (layout.surface !== surface) throw new LayoutError(422, 'layout.surface_mismatch', `the layout names ${layout.surface}, not ${surface}`);
+ if (layout.device !== device) throw new LayoutError(422, 'layout.device_mismatch', `the layout is for ${layout.device}, not ${device}`);
+ if (layout.surface_version !== catalog.version) throw new LayoutError(422, 'layout.version_mismatch', `the layout is for version ${layout.surface_version} of ${surface}; the catalog is version ${catalog.version}`);
+ const result = uiLayout.validate(catalog, layout);
+ if (!result.ok) throw new LayoutError(422, 'layout.invalid', result.reasons.join(' '), { reasons: result.reasons });
+ return layout;
+}
+
+/** The person's own document, with the server's author, name and sharing: a client never chooses those. */
+function cleanLayout(layout, user) {
+ const name = String(user.display_name || user.username || 'OpenVibe user').slice(0, 80);
+ return {
+ surface: layout.surface,
+ surface_version: layout.surface_version,
+ device: layout.device,
+ name: String(layout.name).slice(0, 80),
+ author: { name, subject: { type: 'user', id: user.subject_id } },
+ sharing: 'private',
+ parts: layout.parts,
+ };
+}
+
+function envelope(subject, payload, at) {
+ const ms = Date.parse(at) || Date.now();
+ const env = {
+ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
+ actor: { type: 'user', id: subject }, timestamp: at, visibility: 'internal',
+ subject: { type: 'user', id: subject, revision: payload.revision }, payload,
+ };
+ const v = validate('events.event-envelope@1', env);
+ const pv = validate(`${EVENT_TYPE}@1`, payload);
+ if (!v.valid || !pv.valid) throw new Error(`layouts: bad ${EVENT_TYPE}: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
+ return env;
+}
+
+async function queue(db, subject, payload, at) {
+ await eventRelay.writerFor(db).enqueue(envelope(subject, payload, at));
+ try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ }
+}
+
+/** A per-person sliding window for writes and suggestions, the pattern the menu-feeds module uses. */
+function limiter() {
+ const seen = new Map();
+ return function allow(key, limit, at) {
+ const kept = (seen.get(key) || []).filter((t) => at - t < WINDOW_MS);
+ if (kept.length >= limit) { seen.set(key, kept); return false; }
+ kept.push(at); seen.set(key, kept);
+ if (seen.size > 5000) seen.delete(seen.keys().next().value);
+ return true;
+ };
+}
+
+function createLayouts({ db, now = () => Date.now(), log = console, aiUrl = 'http://127.0.0.1:4700', selfToken = null, fetchImpl = globalThis.fetch } = {}) {
+ if (!db) throw new TypeError('createLayouts: db is required');
+ const allow = limiter();
+ const at = () => now();
+
+ const rowFor = async (subject, id) => await db.prepare('SELECT * FROM ui_layouts WHERE id = ? AND subject_id = ?').get(id, subject);
+ const activeRow = async (subject, surface, device) => await db.prepare('SELECT * FROM ui_layouts WHERE subject_id = ? AND surface = ? AND device = ? AND active').get(subject, surface, device);
+
+ async function active(subject, surface, device) {
+ const row = await activeRow(subject, surface, device);
+ return row ? layoutOf(row) : null;
+ }
+
+ async function get(subject, id) {
+ const row = await rowFor(subject, id);
+ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');
+ return layoutOf(row);
+ }
+
+ async function list(subject, { surface = null, device = null } = {}) {
+ const where = ['subject_id = ?']; const params = [subject];
+ if (surface) { where.push('surface = ?'); params.push(surface); }
+ if (device) { where.push('device = ?'); params.push(device); }
+ const rows = await db.prepare(`SELECT * FROM ui_layouts WHERE ${where.join(' AND ')} ORDER BY active DESC, updated_at DESC LIMIT ${MAX_SAVED}`).all(...params);
+ return rows.map(layoutOf).filter(Boolean);
+ }
+
+ /** PUT: the active layout for this surface/device, updated in place when there is one. */
+ async function setActive(user, surface, device, body) {
+ if (!allow(`write:${user.subject_id}`, WRITE_LIMIT, at())) throw new LayoutError(429, 'layout.rate_limited', 'too many layout writes; wait a minute');
+ if (!isObject(body) || !isObject(body.catalog) || !isObject(body.layout)) {
+ throw new LayoutError(400, 'layout.body_required', 'send { catalog, layout }: the surface catalog and the arrangement');
+ }
+ validateWithCatalog(body.catalog, body.layout, surface, device);
+ const doc = cleanLayout(body.layout, user);
+ const ms = at(); const stamp = nowIso(now);
+ return await db.tx(async () => {
+ const current = await activeRow(user.subject_id, surface, device);
+ let row;
+ if (current) {
+ const revision = Number(current.revision) + 1;
+ await db.prepare(`UPDATE ui_layouts SET doc = ?, name = ?, surface_version = ?, revision = ?, updated_at = ? WHERE id = ?`)
+ .run(JSON.stringify({ ...doc, sharing: current.sharing }), doc.name, doc.surface_version, revision, stamp, current.id);
+ row = await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(current.id);
+ } else {
+ const id = newLayoutId(ms);
+ await db.prepare(`INSERT INTO ui_layouts (id, subject_id, surface, surface_version, device, name, doc, active, sharing, revision, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, 'private', 1, ?, ?)`)
+ .run(id, user.subject_id, surface, doc.surface_version, device, doc.name, JSON.stringify(doc), stamp, stamp);
+ row = await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id);
+ }
+ await queue(db, user.subject_id, { surface, device, layout_id: row.id, change: 'active', revision: Number(row.revision), sharing: row.sharing }, stamp);
+ return layoutOf(row);
+ });
+ }
+
+ /** POST: a saved layout beside the active one. */
+ async function save(user, body) {
+ if (!allow(`write:${user.subject_id}`, WRITE_LIMIT, at())) throw new LayoutError(429, 'layout.rate_limited', 'too many layout writes; wait a minute');
+ if (!isObject(body) || !isObject(body.catalog) || !isObject(body.layout)) {
+ throw new LayoutError(400, 'layout.body_required', 'send { catalog, layout }: the surface catalog and the arrangement');
+ }
+ const surface = body.layout.surface; const device = body.layout.device;
+ if (!SURFACE_RE.test(String(surface || '')) || !DEVICES.includes(device)) throw new LayoutError(422, 'layout.invalid', 'the layout needs a surface and a device');
+ validateWithCatalog(body.catalog, body.layout, surface, device);
+ const doc = cleanLayout(body.layout, user);
+ const ms = at(); const stamp = nowIso(now);
+ return await db.tx(async () => {
+ const count = (await db.prepare('SELECT COUNT(*) AS n FROM ui_layouts WHERE subject_id = ?').get(user.subject_id)).n;
+ if (Number(count) >= MAX_SAVED) throw new LayoutError(409, 'layout.limit', `you can keep ${MAX_SAVED} layouts`);
+ const id = newLayoutId(ms);
+ await db.prepare(`INSERT INTO ui_layouts (id, subject_id, surface, surface_version, device, name, doc, active, sharing, revision, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, FALSE, 'private', 1, ?, ?)`)
+ .run(id, user.subject_id, surface, doc.surface_version, device, doc.name, JSON.stringify(doc), stamp, stamp);
+ const row = await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id);
+ await queue(db, user.subject_id, { surface, device, layout_id: id, change: 'saved', revision: 1, sharing: 'private' }, stamp);
+ return layoutOf(row);
+ });
+ }
+
+ /** PATCH: a new name; nothing else changes. */
+ async function rename(user, id, body) {
+ const row = await rowFor(user.subject_id, id);
+ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');
+ if (!isObject(body) || typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80) {
+ throw new LayoutError(400, 'layout.name_required', 'send { name }: one to eighty characters');
+ }
+ if (BAD_TEXT.test(body.name)) throw new LayoutError(422, 'layout.invalid', "a name can't carry a URL or code");
+ const doc = parseDoc(row.doc);
+ if (!doc) throw new LayoutError(500, 'layout.corrupt', 'this layout cannot be read');
+ const name = body.name.trim(); const revision = Number(row.revision) + 1; const stamp = nowIso(now);
+ return await db.tx(async () => {
+ await db.prepare('UPDATE ui_layouts SET name = ?, doc = ?, revision = ?, updated_at = ? WHERE id = ?')
+ .run(name, JSON.stringify({ ...doc, name }), revision, stamp, id);
+ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'renamed', revision, sharing: row.sharing }, stamp);
+ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));
+ });
+ }
+
+ async function activate(user, id) {
+ const row = await rowFor(user.subject_id, id);
+ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');
+ if (row.active) return layoutOf(row);
+ const revision = Number(row.revision) + 1; const stamp = nowIso(now);
+ return await db.tx(async () => {
+ await db.prepare('UPDATE ui_layouts SET active = FALSE WHERE subject_id = ? AND surface = ? AND device = ? AND active').run(user.subject_id, row.surface, row.device);
+ await db.prepare('UPDATE ui_layouts SET active = TRUE, revision = ?, updated_at = ? WHERE id = ?').run(revision, stamp, id);
+ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'active', revision, sharing: row.sharing }, stamp);
+ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));
+ });
+ }
+
+ async function remove(user, id) {
+ const row = await rowFor(user.subject_id, id);
+ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');
+ const stamp = nowIso(now); const revision = Number(row.revision) + 1;
+ await db.tx(async () => {
+ await db.prepare('DELETE FROM ui_layouts WHERE id = ?').run(id);
+ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'deleted', revision, sharing: row.sharing }, stamp);
+ });
+ return true;
+ }
+
+ /** Reset to the product's default: the active arrangement stops being active; the saved row stays. */
+ async function reset(user, surface, device) {
+ const row = await activeRow(user.subject_id, surface, device);
+ if (!row) return false;
+ const revision = Number(row.revision) + 1; const stamp = nowIso(now);
+ await db.tx(async () => {
+ await db.prepare('UPDATE ui_layouts SET active = FALSE, revision = ?, updated_at = ? WHERE id = ?').run(revision, stamp, row.id);
+ await queue(db, user.subject_id, { surface, device, layout_id: row.id, change: 'reset', revision, sharing: row.sharing }, stamp);
+ });
+ return true;
+ }
+
+ async function share(user, id) {
+ const row = await rowFor(user.subject_id, id);
+ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');
+ const token = row.share_token || crypto.randomBytes(24).toString('base64url');
+ const revision = Number(row.revision) + 1; const stamp = nowIso(now);
+ return await db.tx(async () => {
+ const doc = parseDoc(row.doc) || {};
+ await db.prepare("UPDATE ui_layouts SET sharing = 'link', share_token = ?, doc = ?, revision = ?, updated_at = ? WHERE id = ?")
+ .run(token, JSON.stringify({ ...doc, sharing: 'link' }), revision, stamp, id);
+ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'shared', revision, sharing: 'link' }, stamp);
+ const out = layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));
+ return { layout: out, token, url: `/api/v1/layouts/shared/${token}` };
+ });
+ }
+
+ async function unshare(user, id) {
+ const row = await rowFor(user.subject_id, id);
+ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');
+ const revision = Number(row.revision) + 1; const stamp = nowIso(now);
+ return await db.tx(async () => {
+ const doc = parseDoc(row.doc) || {};
+ await db.prepare("UPDATE ui_layouts SET sharing = 'private', share_token = NULL, published_at = NULL, doc = ?, revision = ?, updated_at = ? WHERE id = ?")
+ .run(JSON.stringify({ ...doc, sharing: 'private' }), revision, stamp, id);
+ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'unshared', revision, sharing: 'private' }, stamp);
+ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));
+ });
+ }
+
+ async function publish(user, id) {
+ const row = await rowFor(user.subject_id, id);
+ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');
+ const revision = Number(row.revision) + 1; const stamp = nowIso(now);
+ return await db.tx(async () => {
+ const doc = parseDoc(row.doc) || {};
+ await db.prepare("UPDATE ui_layouts SET sharing = 'gallery', published_at = ?, doc = ?, revision = ?, updated_at = ? WHERE id = ?")
+ .run(stamp, JSON.stringify({ ...doc, sharing: 'gallery' }), revision, stamp, id);
+ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'published', revision, sharing: 'gallery' }, stamp);
+ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));
+ });
+ }
+
+ async function unpublish(user, id) {
+ const row = await rowFor(user.subject_id, id);
+ if (!row) throw new LayoutError(404, 'layout.not_found', 'no such layout');
+ const revision = Number(row.revision) + 1; const stamp = nowIso(now);
+ return await db.tx(async () => {
+ const doc = parseDoc(row.doc) || {};
+ await db.prepare("UPDATE ui_layouts SET sharing = 'private', published_at = NULL, doc = ?, revision = ?, updated_at = ? WHERE id = ?")
+ .run(JSON.stringify({ ...doc, sharing: 'private' }), revision, stamp, id);
+ await queue(db, user.subject_id, { surface: row.surface, device: row.device, layout_id: id, change: 'unpublished', revision, sharing: 'private' }, stamp);
+ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(id));
+ });
+ }
+
+ async function gallery({ surface = null, device = null, limit = 60 } = {}) {
+ const where = ["sharing = 'gallery'", 'published_at IS NOT NULL']; const params = [];
+ if (surface) { where.push('surface = ?'); params.push(surface); }
+ if (device) { where.push('device = ?'); params.push(device); }
+ const rows = await db.prepare(`SELECT * FROM ui_layouts WHERE ${where.join(' AND ')} ORDER BY published_at DESC LIMIT ?`).all(...params, Math.min(100, Math.max(1, limit)));
+ return rows.map(publicView).filter(Boolean);
+ }
+
+ async function shared(token) {
+ if (!TOKEN_RE.test(String(token || ''))) return null;
+ const row = await db.prepare("SELECT * FROM ui_layouts WHERE share_token = ? AND sharing IN ('link', 'gallery')").get(token);
+ return row ? publicView(row) : null;
+ }
+
+ async function copy(user, id) {
+ const source = await db.prepare("SELECT * FROM ui_layouts WHERE id = ? AND sharing = 'gallery' AND published_at IS NOT NULL").get(id);
+ if (!source) throw new LayoutError(404, 'layout.not_found', 'no such gallery layout');
+ const doc = parseDoc(source.doc);
+ if (!doc) throw new LayoutError(500, 'layout.corrupt', 'this layout cannot be read');
+ const ms = at(); const stamp = nowIso(now);
+ const copyId = newLayoutId(ms);
+ const name = `${String(source.name).slice(0, 72)} (copy)`;
+ return await db.tx(async () => {
+ const count = (await db.prepare('SELECT COUNT(*) AS n FROM ui_layouts WHERE subject_id = ?').get(user.subject_id)).n;
+ if (Number(count) >= MAX_SAVED) throw new LayoutError(409, 'layout.limit', `you can keep ${MAX_SAVED} layouts`);
+ const outDoc = { ...doc, name, author: { name: String(user.display_name || user.username || 'OpenVibe user').slice(0, 80), subject: { type: 'user', id: user.subject_id } }, sharing: 'private' };
+ await db.prepare(`INSERT INTO ui_layouts (id, subject_id, surface, surface_version, device, name, doc, active, sharing, revision, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, FALSE, 'private', 1, ?, ?)`)
+ .run(copyId, user.subject_id, source.surface, source.surface_version, source.device, name, JSON.stringify(outDoc), stamp, stamp);
+ await queue(db, user.subject_id, { surface: source.surface, device: source.device, layout_id: copyId, change: 'copied', revision: 1, sharing: 'private' }, stamp);
+ return layoutOf(await db.prepare('SELECT * FROM ui_layouts WHERE id = ?').get(copyId));
+ });
+ }
+
+ /** The first-party service read: the active layout one person made for the product's own surface. */
+ async function forSubject(subject, surface, device) {
+ const user = await db.prepare('SELECT id, subject_id FROM users WHERE subject_id = ?').get(subject);
+ if (!user) throw new LayoutError(404, 'identity.subject_not_found', 'no such person');
+ const layout = await active(subject, surface, device);
+ if (!layout) throw new LayoutError(404, 'layout.none', 'this person has not arranged this surface on this device');
+ return layout;
+ }
+
+ async function suggest(user, surface, device, body) {
+ if (!allow(`suggest:${user.subject_id}`, SUGGEST_LIMIT, at())) throw new LayoutError(429, 'layout.rate_limited', 'too many suggestions; wait a minute');
+ if (!isObject(body) || !isObject(body.catalog)) throw new LayoutError(400, 'layout.catalog_required', 'send { catalog, layout, words }');
+ const words = typeof body.words === 'string' ? body.words.trim() : '';
+ if (!words || words.length > 500) throw new LayoutError(422, 'layout.words_invalid', 'describe the arrangement in one to five hundred characters');
+ if (body.layout != null) validateWithCatalog(body.catalog, body.layout, surface, device);
+ else {
+ const c = validate(SURFACE_CONTRACT, body.catalog);
+ if (!c.valid) throw new LayoutError(400, 'layout.catalog_invalid', 'the surface catalog is not valid', { errors: c.errors });
+ if (body.catalog.id !== surface || !body.catalog.devices.includes(device)) throw new LayoutError(422, 'layout.surface_mismatch', `this catalog is not ${surface} on ${device}`);
+ }
+ if (typeof selfToken !== 'function') throw new LayoutError(503, 'layout.ai_unavailable', 'OpenVibe.AI is not configured');
+ const token = selfToken('openvibe.ai', ['ai.layout.suggest']);
+ if (!token) throw new LayoutError(503, 'layout.ai_unavailable', 'OpenVibe.AI is not configured');
+ let response;
+ try {
+ response = await fetchImpl(`${String(aiUrl).replace(/\/$/, '')}/api/v1/ui/layout/suggest`, {
+ method: 'POST',
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'X-OV-Subject': user.subject_id },
+ body: JSON.stringify({ catalog: body.catalog, layout: body.layout || null, words, device }),
+ });
+ } catch (err) {
+ log.warn && log.warn('[Layouts] AI did not answer:', err && err.message);
+ throw new LayoutError(503, 'layout.ai_unavailable', 'OpenVibe.AI did not answer');
+ }
+ let answer = null;
+ try { answer = await response.json(); } catch { /* a non-JSON answer is a failure below */ }
+ if (!response.ok) {
+ throw new LayoutError(response.status === 429 ? 429 : 502, 'layout.ai_failed', (answer && (answer.detail || answer.error)) || 'OpenVibe.AI refused the suggestion');
+ }
+ if (!validate(SUGGESTION_CONTRACT, answer).valid) throw new LayoutError(502, 'layout.ai_invalid', 'OpenVibe.AI answered something that is not a layout suggestion');
+ return answer;
+ }
+
+ return { active, get, list, setActive, save, rename, activate, remove, reset, share, unshare, publish, unpublish, gallery, shared, copy, forSubject, suggest };
+}
+
+function sendError(res, err, ctx, log = console) {
+ if (err && err.status) return http.sendProblem(res, err.status, err.code || 'layout.error', { detail: err.message, errors: err.errors, extra: err.extra, ctx });
+ log.error('[Layouts]', err && err.stack || err);
+ return http.sendProblem(res, 500, 'layout.error', { detail: 'could not complete that', ctx });
+}
+
+function deviceOf(req) {
+ const device = String(req.query.device || 'phone');
+ if (!DEVICES.includes(device)) throw new LayoutError(400, 'layout.device_invalid', `device must be one of ${DEVICES.join(', ')}`);
+ return device;
+}
+
+function surfaceOf(req) {
+ const surface = String(req.params.surface || '');
+ if (!SURFACE_RE.test(surface)) throw new LayoutError(400, 'layout.surface_invalid', 'name the surface, e.g. bot.controls');
+ return surface;
+}
+
+function idOf(req) {
+ const id = String(req.params.id || '');
+ if (!LAYOUT_ID_RE.test(id)) throw new LayoutError(400, 'layout.id_invalid', 'name a layout id (lay_…)');
+ return id;
+}
+
+/** The person's routes, behind Network's session check (a Bearer token from another first-party site). */
+function router({ layouts, requireAuth }) {
+ const r = express.Router();
+ r.use(http.middleware());
+ r.use(express.json({ limit: MAX_BODY }));
+ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
+ r.get('/', requireAuth, async (req, res) => {
+ try {
+ const device = req.query.device ? deviceOf(req) : null;
+ const surface = req.query.surface ? String(req.query.surface) : null;
+ if (surface && !SURFACE_RE.test(surface)) throw new LayoutError(400, 'layout.surface_invalid', 'name the surface, e.g. bot.controls');
+ res.json({ layouts: await layouts.list(req.user.subject_id, { surface, device }) });
+ } catch (err) { sendError(res, err, req.ov); }
+ });
+ r.post('/', requireAuth, async (req, res) => {
+ try { res.status(201).json(await layouts.save(req.user, req.body)); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ r.get('/:id', requireAuth, async (req, res, next) => {
+ if (!LAYOUT_ID_RE.test(String(req.params.id || ''))) return next();
+ try { res.json(await layouts.get(req.user.subject_id, req.params.id)); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ r.get('/:surface', requireAuth, async (req, res) => {
+ try {
+ const layout = await layouts.active(req.user.subject_id, surfaceOf(req), deviceOf(req));
+ if (!layout) return http.sendProblem(res, 404, 'layout.none', { detail: 'no layout saved for this surface and device; draw the default', ctx: req.ov });
+ res.json(layout);
+ } catch (err) { sendError(res, err, req.ov); }
+ });
+ r.put('/:surface', requireAuth, async (req, res) => {
+ try { res.json(await layouts.setActive(req.user, surfaceOf(req), deviceOf(req), req.body)); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ r.post('/:surface/reset', requireAuth, async (req, res) => {
+ try {
+ const reset = await layouts.reset(req.user, surfaceOf(req), deviceOf(req));
+ if (!reset) return http.sendProblem(res, 404, 'layout.none', { detail: 'there is no saved arrangement to reset', ctx: req.ov });
+ res.status(204).end();
+ } catch (err) { sendError(res, err, req.ov); }
+ });
+ r.post('/:surface/suggest', requireAuth, async (req, res) => {
+ try { res.json(await layouts.suggest(req.user, surfaceOf(req), deviceOf(req), req.body)); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ r.patch('/:id', requireAuth, async (req, res) => {
+ try { res.json(await layouts.rename(req.user, idOf(req), req.body)); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ r.delete('/:id', requireAuth, async (req, res) => {
+ try { await layouts.remove(req.user, idOf(req)); res.status(204).end(); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ r.post('/:id/activate', requireAuth, async (req, res) => {
+ try { res.json(await layouts.activate(req.user, idOf(req))); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ r.post('/:id/share', requireAuth, async (req, res) => {
+ try { res.json(await layouts.share(req.user, idOf(req))); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ r.delete('/:id/share', requireAuth, async (req, res) => {
+ try { res.json(await layouts.unshare(req.user, idOf(req))); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ r.post('/:id/publish', requireAuth, async (req, res) => {
+ try { res.json(await layouts.publish(req.user, idOf(req))); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ r.delete('/:id/publish', requireAuth, async (req, res) => {
+ try { res.json(await layouts.unpublish(req.user, idOf(req))); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ return r;
+}
+
+/** The public, read-only routes: the gallery and one shared layout, with no account data. */
+function publicRouter({ layouts, requireAuth }) {
+ const r = express.Router();
+ r.use(http.middleware());
+ const publicCache = (req, res, next) => { res.set('Cache-Control', 'public, max-age=60'); next(); };
+ r.get('/gallery', publicCache, async (req, res) => {
+ try {
+ const surface = req.query.surface ? String(req.query.surface) : null;
+ if (surface && !SURFACE_RE.test(surface)) throw new LayoutError(400, 'layout.surface_invalid', 'name the surface, e.g. bot.controls');
+ const device = req.query.device ? deviceOf(req) : null;
+ res.json({ layouts: await layouts.gallery({ surface, device }) });
+ } catch (err) { sendError(res, err, req.ov); }
+ });
+ r.get('/shared/:token', publicCache, async (req, res) => {
+ try {
+ const layout = await layouts.shared(req.params.token);
+ if (!layout) return http.sendProblem(res, 404, 'layout.not_found', { detail: 'this share link is gone', ctx: req.ov });
+ res.json({ layout });
+ } catch (err) { sendError(res, err, req.ov); }
+ });
+ r.post('/gallery/:id/copy', (req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); }, requireAuth, async (req, res) => {
+ try { res.status(201).json(await layouts.copy(req.user, idOf(req))); }
+ catch (err) { sendError(res, err, req.ov); }
+ });
+ return r;
+}
+
+/**
+ * The guard for the service read: a first-party service token (Network's own issuer) holding
+ * network.layout.read. It names the person with X-OV-Subject; the path names the same person.
+ */
+function serviceGuard() {
+ return async function layoutServiceGuard(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 read a layout here', 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();
+ };
+}
+
+/** GET /api/v1/people/:subject/layouts/:surface, guarded by network.layout.read. */
+function serviceRouter({ layouts }) {
+ const r = express.Router();
+ r.use(http.middleware());
+ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
+ r.get('/:subject/layouts/:surface', serviceGuard(), async (req, res) => {
+ try {
+ const subject = String(req.params.subject || '');
+ if (!SUBJECT_RE.test(subject)) return http.sendProblem(res, 400, 'layout.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, 'layout.subject_mismatch', { detail: 'X-OV-Subject names another person', ctx: req.ov });
+ res.json(await layouts.forSubject(subject, surfaceOf(req), deviceOf(req)));
+ } catch (err) { sendError(res, err, req.ov); }
+ });
+ return r;
+}
+
+module.exports = { createLayouts, router, publicRouter, serviceRouter, serviceGuard, CONTRACT, EVENT_TYPE, SERVICE_CAPABILITY, MAX_SAVED };
@@ -32,6 +32,10 @@ function isPublicDiscoveryPath(p) {
@@ -32,6 +32,10 @@ function isPublicDiscoveryPath(p) {
if (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;
if (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;
if (/^\/api\/v1\/people\/[A-Za-z0-9_]{3,24}\/card$/.test(path)) return true;
if (/^\/api\/v1\/people\/[A-Za-z0-9_]{3,24}\/card$/.test(path)) return true;
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
+ // People's own controls (2026-11-11): the gallery and a shared layout are read-only and carry no
+ // account data, so any site may draw them without a token.
+ if (path === '/api/v1/layouts/gallery' || path === '/api/v1/layouts/gallery/') return true;
+ if (/^\/api\/v1\/layouts\/shared\/[A-Za-z0-9_-]{24,64}$/.test(path)) return true;
if (path === '/api/v1/frame/today' || path === '/api/v1/frame/today/') return true;
if (path === '/api/v1/frame/today' || path === '/api/v1/frame/today/') return true;
if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every site
if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every site
if (/^\/api\/v1\/people\/@[A-Za-z0-9_]{1,40}\/page$/.test(path)) return true; // public person page JSON
if (/^\/api\/v1\/people\/@[A-Za-z0-9_]{1,40}\/page$/.test(path)) return true; // public person page JSON
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand'], ['0078', 'feed_seen', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand'], ['0078', 'feed_seen', 'expand'], ['0079', 'ui_layouts', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,295 @@
@@ -0,0 +1,295 @@
+'use strict';
+// People's own controls, stage 1 (design 2026-11-11, piece 4): the active layout per surface and
+// device, saved layouts, share links, the gallery and copying, the service read, the AI suggestion
+// proxy, account export/deletion and network.layout.changed@1.
+// node --import ./test/helpers/pg-preload.mjs test/layouts.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 { serviceAuth, validate } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const authRoutes = require('../server/auth/routes');
+const accountData = require('../server/identity/account-data');
+const layoutsMod = require('../server/layouts');
+
+(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, display_name) VALUES (?, ?, ?, ?) RETURNING id').run(name, `${name}@example.test`, 'x', name)).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('layoutana');
+const bob = await mk('layoutbob');
+
+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 serviceToken = (cap, sub = 'svc:bot') => serviceAuth.signServiceToken({
+ iss: config.jwt.issuer, sub, actor_type: 'service', aud: ['openvibe.network'], cap: [cap],
+ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: 'tok_layouts_test',
+}, keys.privateKey);
+
+const catalog = () => ({
+ id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone', 'tablet', 'computer'],
+ grid: { columns: { phone: 4, tablet: 8, computer: 12 }, cell_px: { phone: 44, tablet: 44, computer: 44 } },
+ theme_tokens: ['danger', 'surface', 'text'],
+ parts: [
+ { id: 'stop', name: 'Stop', kind: 'button', required: true, pressed: true, min: { w: 2, h: 2, px: 64 },
+ styles: { tints: ['danger'], shapes: ['round', 'square'], opacity: { min: 0.7, max: 1 }, label: true },
+ bindings: { keys: ['Space'], gamepad: ['south'] }, never_covers: [] },
+ { id: 'stick', name: 'Drive stick', kind: 'stick', required: false, pressed: true, min: { w: 2, h: 2 },
+ styles: { tints: ['surface'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false },
+ bindings: { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] }, never_covers: ['stop'] },
+ { id: 'battery', name: 'Battery', kind: 'readout', required: false, pressed: false, min: { w: 1, h: 1 },
+ styles: { tints: ['text'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true },
+ bindings: { keys: [], gamepad: [] }, never_covers: [] },
+ ],
+});
+const part = (id, place) => ({
+ id, visible: true, place,
+ style: { tint: id === 'stop' ? 'danger' : id === 'stick' ? 'surface' : 'text', shape: id === 'stop' ? 'round' : id === 'battery' ? 'pill' : 'round', opacity: 1, label: id !== 'stick' },
+ bindings: id === 'stop' ? { keys: ['Space'], gamepad: ['south'] } : id === 'stick' ? { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] } : { keys: [], gamepad: [] },
+});
+const layout = (name = 'My phone controls') => ({
+ surface: 'bot.controls', surface_version: 1, device: 'phone', name,
+ author: { name: 'Ana' }, sharing: 'private',
+ parts: [part('stop', { x: 0, y: 0, w: 2, h: 2 }), part('stick', { x: 0, y: 2, w: 2, h: 2 }), part('battery', { x: 2, y: 2, w: 1, h: 1 })],
+});
+
+let aiRequest = null;
+let aiAnswer = null;
+const suggestion = () => ({ ok: true, layout: { ...layout('Suggested'), id: undefined }, summary: 'Moved Stop to the left.', reasons: ['Stop stayed visible.'], receipt: { model: 'stub-1' } });
+const layouts = layoutsMod.createLayouts({
+ db, selfToken: () => 'test-service-token', fetchImpl: async (url, opts) => {
+ aiRequest = { url, opts };
+ if (aiAnswer) return aiAnswer;
+ return { ok: true, status: 200, json: async () => suggestion() };
+ },
+});
+
+const app = express();
+Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+app.use(require('openvibe-contracts').http.middleware());
+app.use('/api/v1/me/layouts', layoutsMod.router({ layouts, requireAuth }));
+app.use('/api/v1/layouts', layoutsMod.publicRouter({ layouts, requireAuth }));
+app.use('/api/v1/people', layoutsMod.serviceRouter({ layouts }));
+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 call = (method, path, body, { token = tokenOf(ana), headers = {} } = {}) => fetch(base + path, {
+ method,
+ headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})), headers: r.headers }));
+const active = '/api/v1/me/layouts/bot.controls?device=phone';
+const outbox = async () => (await db.prepare("SELECT envelope FROM network_event_outbox WHERE (envelope->>'event_type') = 'network.layout.changed' ORDER BY id").all()).map((r) => r.envelope);
+const clearOutbox = async () => { await db.prepare("DELETE FROM network_event_outbox WHERE (envelope->>'event_type') = 'network.layout.changed'").run(); };
+
+try {
+ // The migration is expand-only: an older release never reads the table.
+ assert.match(fs.readFileSync(path.join(__dirname, '..', 'migrations', '0079_ui_layouts.sql'), 'utf8'), /^-- phase: expand$/m);
+ // No arrangement yet: 404 with a code the editor understands, never a made-up default.
+ let r = await call('GET', active);
+ assert.strictEqual(r.status, 404);
+ assert.strictEqual(r.body.code, 'layout.none');
+ r = await call('GET', active, undefined, { token: null });
+ assert.strictEqual(r.status, 401, 'a session is needed to read a person’s layout');
+
+ // A layout that hides Stop is refused with the SDK's plain reason; nothing is stored.
+ const hidden = layout(); hidden.parts[0].visible = false;
+ r = await call('PUT', active, { catalog: catalog(), layout: hidden });
+ assert.strictEqual(r.status, 422, JSON.stringify(r.body));
+ assert.strictEqual(r.body.code, 'layout.invalid');
+ assert.ok(r.body.reasons.includes('Stop has to stay visible.'), JSON.stringify(r.body.reasons));
+ r = await call('GET', active);
+ assert.strictEqual(r.status, 404, 'the refused layout was not stored');
+
+ // The body must carry the catalog: validation is the SDK's, against a real surface.
+ r = await call('PUT', active, { layout: layout() });
+ assert.strictEqual(r.status, 400);
+ assert.strictEqual(r.body.code, 'layout.body_required');
+ r = await call('PUT', active, { catalog: { ...catalog(), id: 'bot.overlay' }, layout: layout() });
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(r.body.code, 'layout.surface_mismatch');
+
+ // A complete arrangement is stored, active, with the store's id, revision and the person's name.
+ await clearOutbox();
+ r = await call('PUT', active, { catalog: catalog(), layout: layout() });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.match(r.body.id, /^lay_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(r.body.revision, 1);
+ assert.strictEqual(r.body.active, true);
+ assert.strictEqual(r.body.sharing, 'private');
+ assert.strictEqual(r.body.name, 'My phone controls');
+ assert.strictEqual(r.body.author.name, 'layoutana');
+ assert.deepStrictEqual(r.body.author.subject, { type: 'user', id: ana.subject_id });
+ assert.ok(validate('ui.layout@1', r.body).valid);
+ const firstId = r.body.id;
+ r = await call('GET', active);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.id, firstId, 'the active layout reads back');
+ let events = await outbox();
+ assert.strictEqual(events.length, 1, 'one change event');
+ assert.strictEqual(events[0].payload.change, 'active');
+ assert.strictEqual(events[0].payload.layout_id, firstId);
+ assert.strictEqual(events[0].subject.id, ana.subject_id);
+ assert.strictEqual(events[0].subject.revision, 1);
+
+ // Saving again updates the active row in place; a second PUT is a new revision and a new event.
+ r = await call('PUT', active, { catalog: catalog(), layout: layout('One thumb') });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.id, firstId);
+ assert.strictEqual(r.body.revision, 2);
+ assert.strictEqual(r.body.name, 'One thumb');
+ events = await outbox();
+ assert.strictEqual(events.length, 2);
+ assert.strictEqual(events[1].payload.revision, 2);
+
+ // Saved layouts: POST creates one beside the active, list answers both, rename moves only the name.
+ r = await call('POST', '/api/v1/me/layouts', { catalog: catalog(), layout: layout('Saved one') });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ assert.strictEqual(r.body.active, false);
+ const savedId = r.body.id;
+ r = await call('GET', '/api/v1/me/layouts');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.layouts.length, 2);
+ assert.strictEqual(r.body.layouts.find((x) => x.id === firstId).active, true, 'active first');
+ r = await call('PATCH', `/api/v1/me/layouts/${savedId}`, { name: 'One hand' });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.name, 'One hand');
+ assert.strictEqual(r.body.revision, 2);
+ assert.ok((await outbox()).some((e) => e.payload.change === 'renamed' && e.payload.layout_id === savedId));
+
+ // Another person never sees or changes it.
+ r = await call('GET', `/api/v1/me/layouts/${savedId}`, undefined, { token: tokenOf(bob) });
+ assert.strictEqual(r.status, 404);
+ r = await call('PATCH', `/api/v1/me/layouts/${savedId}`, { name: 'Mine now' }, { token: tokenOf(bob) });
+ assert.strictEqual(r.status, 404);
+ r = await call('DELETE', `/api/v1/me/layouts/${savedId}`, undefined, { token: tokenOf(bob) });
+ assert.strictEqual(r.status, 404);
+
+ // Activate swaps which one is active; reset returns to the product's default and keeps the saved row.
+ r = await call('POST', `/api/v1/me/layouts/${savedId}/activate`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.active, true);
+ let list = (await call('GET', '/api/v1/me/layouts')).body.layouts;
+ assert.strictEqual(list.find((x) => x.id === firstId).active, false);
+ assert.strictEqual(list.find((x) => x.id === savedId).active, true);
+ r = await call('POST', '/api/v1/me/layouts/bot.controls/reset?device=phone');
+ assert.strictEqual(r.status, 204);
+ assert.strictEqual((await call('GET', active)).status, 404, 'reset removes the active arrangement');
+ list = (await call('GET', '/api/v1/me/layouts')).body.layouts;
+ assert.strictEqual(list.length, 2, 'reset keeps the saved layouts');
+ assert.ok((await outbox()).some((e) => e.payload.change === 'reset'));
+
+ // Share: a token and a read-only public answer with no account data; unshare makes it private.
+ r = await call('POST', `/api/v1/me/layouts/${firstId}/share`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.layout.sharing, 'link');
+ assert.match(r.body.token, /^[A-Za-z0-9_-]{24,64}$/);
+ assert.strictEqual(r.body.url, `/api/v1/layouts/shared/${r.body.token}`);
+ const shareToken = r.body.token;
+ const shared = await call('GET', r.body.url, undefined, { token: null });
+ assert.strictEqual(shared.status, 200);
+ assert.strictEqual(shared.body.layout.author.name, 'layoutana');
+ assert.strictEqual(shared.body.layout.author.subject, undefined, 'no account in a share link');
+ assert.strictEqual(shared.body.layout.id, firstId);
+ assert.strictEqual((await call('GET', '/api/v1/layouts/shared/nope', undefined, { token: null })).status, 404);
+ r = await call('DELETE', `/api/v1/me/layouts/${firstId}/share`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.sharing, 'private');
+ assert.strictEqual((await call('GET', `/api/v1/layouts/shared/${shareToken}`, undefined, { token: null })).status, 404, 'an unshared link is gone');
+
+ // Gallery: publish, list publicly, copy into another person's account, unpublish.
+ r = await call('POST', `/api/v1/me/layouts/${firstId}/publish`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.sharing, 'gallery');
+ r = await call('GET', '/api/v1/layouts/gallery?surface=bot.controls&device=phone', undefined, { token: null });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.layouts.length, 1);
+ assert.strictEqual(r.body.layouts[0].author.subject, undefined, 'the gallery carries no account');
+ assert.strictEqual(r.body.layouts[0].id, firstId);
+ r = await call('POST', `/api/v1/layouts/gallery/${firstId}/copy`, undefined, { token: tokenOf(bob) });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ assert.strictEqual(r.body.author.subject.id, bob.subject_id);
+ assert.strictEqual(r.body.active, false);
+ assert.strictEqual(r.body.sharing, 'private');
+ assert.match(r.body.name, /\(copy\)$/);
+ const bobCopy = r.body.id;
+ assert.strictEqual((await call('GET', '/api/v1/me/layouts', undefined, { token: tokenOf(bob) })).body.layouts.length, 1);
+ r = await call('GET', '/api/v1/layouts/gallery?surface=bot.controls&device=phone', undefined, { token: null });
+ assert.strictEqual(r.body.layouts.length, 1, 'copying does not publish the copy');
+ r = await call('DELETE', `/api/v1/me/layouts/${firstId}/publish`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual((await call('GET', '/api/v1/layouts/gallery', undefined, { token: null })).body.layouts.length, 0);
+ r = await call('POST', '/api/v1/layouts/gallery/nope/copy', undefined, { token: tokenOf(bob) });
+ assert.strictEqual(r.status, 400, 'a gallery id has a shape');
+ assert.strictEqual(typeof bobCopy, 'string');
+
+ // Make the first layout active again for the product read.
+ r = await call('PUT', active, { catalog: catalog(), layout: layout() });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ const activeAgain = r.body.id;
+ // The service read: a first-party token with network.layout.read, naming the person.
+ r = await call('GET', `/api/v1/people/${ana.subject_id}/layouts/bot.controls?device=phone`, undefined, { token: tokenOf(ana) });
+ assert.strictEqual(r.status, 401, 'a person token does not open the service route');
+ r = await call('GET', `/api/v1/people/${ana.subject_id}/layouts/bot.controls?device=phone`, undefined, { token: serviceToken('network.layout.read', 'svc:bot') });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.id, activeAgain);
+ r = await call('GET', `/api/v1/people/${ana.subject_id}/layouts/bot.controls?device=phone`, undefined, { token: serviceToken('network.layout.read', 'svc:bot'), headers: { 'X-OV-Subject': bob.subject_id } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.body.code, 'layout.subject_mismatch');
+ r = await call('GET', `/api/v1/people/${ana.subject_id}/layouts/bot.controls?device=phone`, undefined, { token: serviceToken('network.layout.read', 'svc:bot'), headers: { 'X-OV-Subject': ana.subject_id } });
+ assert.strictEqual(r.status, 200);
+ r = await call('GET', `/api/v1/people/${ana.subject_id}/layouts/bot.controls?device=phone`, undefined, { token: serviceToken('network.follows.read', 'svc:bot') });
+ assert.strictEqual(r.status, 403, 'a service without the layout capability is refused');
+ r = await call('GET', `/api/v1/people/usr_01JAB2C3D4E5F6G7H8J9K0MNPQ/layouts/bot.controls?device=phone`, undefined, { token: serviceToken('network.layout.read', 'svc:bot') });
+ assert.strictEqual(r.status, 404);
+
+ // The AI call goes through Network to OpenVibe.AI as the person, with the catalog and the words.
+ aiRequest = null;
+ await call('PUT', active, { catalog: catalog(), layout: layout() });
+ r = await call('POST', '/api/v1/me/layouts/bot.controls/suggest?device=phone', { catalog: catalog(), layout: layout(), words: 'put Stop on the left and make it huge' });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.strictEqual(r.body.ok, true);
+ assert.ok(aiRequest, 'OpenVibe.AI was called');
+ assert.ok(aiRequest.url.endsWith('/api/v1/ui/layout/suggest'));
+ assert.strictEqual(aiRequest.opts.headers['X-OV-Subject'], ana.subject_id);
+ assert.strictEqual(aiRequest.opts.headers.authorization, 'Bearer test-service-token');
+ assert.strictEqual(JSON.parse(aiRequest.opts.body).words, 'put Stop on the left and make it huge');
+ r = await call('POST', '/api/v1/me/layouts/bot.controls/suggest?device=phone', { catalog: catalog(), words: '' });
+ assert.strictEqual(r.status, 422, 'empty words are refused before a model call');
+ aiAnswer = { ok: false, status: 502, json: async () => ({ detail: 'the model refused' }) };
+ r = await call('POST', '/api/v1/me/layouts/bot.controls/suggest?device=phone', { catalog: catalog(), words: 'make it one thumb' });
+ assert.strictEqual(r.status, 502);
+ assert.strictEqual(r.body.code, 'layout.ai_failed');
+ aiAnswer = { ok: true, status: 200, json: async () => ({ nope: true }) };
+ r = await call('POST', '/api/v1/me/layouts/bot.controls/suggest?device=phone', { catalog: catalog(), words: 'make it one thumb' });
+ assert.strictEqual(r.status, 502);
+ assert.strictEqual(r.body.code, 'layout.ai_invalid');
+ aiAnswer = null;
+
+ // Account export carries the layouts; deletion removes them.
+ const part = await accountData.networkPart(db, ana.id);
+ const exported = part.files.find((f) => f.name === 'layouts.json');
+ assert.ok(exported, 'layouts.json is in the export');
+ assert.ok(exported.content.some((x) => x.id === firstId && x.doc.surface === 'bot.controls'));
+ await accountData.scheduleDeletion(db, ana, { auth_time: Math.floor(Date.now() / 1000) }, 'layoutana');
+ const deletion = await db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(ana.id);
+ const erased = await accountData.erase(db, deletion);
+ assert.ok(erased.ui_layouts >= 1, 'the layouts are erased with the account');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM ui_layouts WHERE subject_id = ?').get(ana.subject_id)).n, 0);
+
+ log('layouts: active, saved, share, gallery, service read, AI proxy, export and deletion passed');
+} finally {
+ await new Promise((r) => server.close(r));
+}
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -11,12 +11,14 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -11,12 +11,14 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
(async () => {
(async () => {
// Path decision
// Path decision
for (const p of ['/.well-known/openvibe', '/api/v1/registry', '/api/v1/registry/', '/api/v1/registry/services', '/api/v1/registry/services/network', '/api/v1/people/@Ana/page',
for (const p of ['/.well-known/openvibe', '/api/v1/registry', '/api/v1/registry/', '/api/v1/registry/services', '/api/v1/registry/services/network', '/api/v1/people/@Ana/page',
- '/api/v1/registry/domains/openvibe.community', '/api/v1/registry/capabilities/media.object.upload', '/contracts/identity/subject-ref.v1.json', '/contracts/events/payloads/deals.watch.matched.v1.json']) {
+ '/api/v1/registry/domains/openvibe.community', '/api/v1/registry/capabilities/media.object.upload', '/contracts/identity/subject-ref.v1.json', '/contracts/events/payloads/deals.watch.matched.v1.json',
+ '/api/v1/layouts/gallery', '/api/v1/layouts/gallery/', '/api/v1/layouts/shared/abcdefghijklmnopqrstuvwx']) {
assert.ok(publicCors.isPublicDiscoveryPath(p), `${p} is public`);
assert.ok(publicCors.isPublicDiscoveryPath(p), `${p} is public`);
}
}
for (const p of ['/api/v1/registryx', '/api/v1/registry-admin', '/api/v1/registry/../projects', '/api/v1/registry/%2e%2e/projects', '/api/v1/registry/a%2Fb',
for (const p of ['/api/v1/registryx', '/api/v1/registry-admin', '/api/v1/registry/../projects', '/api/v1/registry/%2e%2e/projects', '/api/v1/registry/a%2Fb',
'/api/v1/registry//services', '/contracts/../x', '/contracts/foo', '/contracts/identity/subject-ref.json', '/contracts/identity/subject-ref.v1.json/x',
'/api/v1/registry//services', '/contracts/../x', '/contracts/foo', '/contracts/identity/subject-ref.json', '/contracts/identity/subject-ref.v1.json/x',
- '/.well-known/openvibe/x', '/.well-known/openid-configuration', '/api/.well-known/jwks', '/api/auth/login', '/api/v1/projects', '/oauth/token', '/']) {
+ '/.well-known/openvibe/x', '/.well-known/openid-configuration', '/api/.well-known/jwks', '/api/auth/login', '/api/v1/projects', '/oauth/token', '/',
+ '/api/v1/layouts/gallery/secret', '/api/v1/layouts/galleryx', '/api/v1/layouts/shared/short', '/api/v1/layouts/shared/' + 'a'.repeat(65), '/api/v1/me/layouts']) {
assert.ok(!publicCors.isPublicDiscoveryPath(p), `${p} is not public`);
assert.ok(!publicCors.isPublicDiscoveryPath(p), `${p} is not public`);
}
}
@@ -70,7 +70,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
@@ -70,7 +70,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
let r = await get('/api/v1/registry/topics');
let r = await get('/api/v1/registry/topics');
assert.ok(r.body.topics.length >= produced.size, 'no longer empty');
assert.ok(r.body.topics.length >= produced.size, 'no longer empty');
r = await get('/api/v1/registry/topics?producer=live');
r = await get('/api/v1/registry/topics?producer=live');
- assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), ['live.agent_stream.changed', 'live.index_document.deleted', 'live.index_document.upserted', 'live.moderation.action', 'live.release.deployed', 'live.setup.task_completed', 'live.stream.ended', 'live.stream.started']);
+ assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), ['live.agent_stream.changed', 'live.element.pressed', 'live.element.state', 'live.index_document.deleted', 'live.index_document.upserted', 'live.moderation.action', 'live.release.deployed', 'live.setup.task_completed', 'live.stream.ended', 'live.stream.started']);
r = await get('/api/v1/registry/topics?consumer=network');
r = await get('/api/v1/registry/topics?consumer=network');
assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), [...TOPICS].sort());
assert.deepStrictEqual(r.body.topics.map(t => t.topic).sort(), [...TOPICS].sort());
r = await get('/api/v1/registry/topics?prefix=media.vod.');
r = await get('/api/v1/registry/topics?prefix=media.vod.');
@@ -140,6 +140,7 @@ const REVIEWED = {
@@ -140,6 +140,7 @@ const REVIEWED = {
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/link/send.js fetch': [8, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys, the resumable transfer parts and the delivery list); page input supplies file bytes, never a URL or destination host'],
'server/link/send.js fetch': [8, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys, the resumable transfer parts and the delivery list); page input supplies file bytes, never a URL or destination host'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
+ 'server/layouts/index.js fetch': [1, 'OpenVibe.AI internal URL from operator config (OV_AI_INTERNAL_URL, loopback default); the fixed /api/v1/ui/layout/suggest path, a service token and the person\'s usr_ id in a header, never a request-selected host or URL'],
'server/actions/index.js fetch': [7, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/actions/index.js fetch': [7, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/profile/card.js fetch': [2, 'Operator-configured Quest and Live bases only, fixed paths with encoded canonical identities, no redirects, two second timeout'],
'server/profile/card.js fetch': [2, 'Operator-configured Quest and Live bases only, fixed paths with encoded canonical identities, no redirects, two second timeout'],
'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand'], ['0078', 'feed_seen', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand'], ['0078', 'feed_seen', 'expand'], ['0079', 'ui_layouts', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.