openvibers/openvibe.network Commits

People's own layouts: active, saved, share, gallery and the AI suggestion (#848)

OP OpenVibers committed 1 days ago · 8614dbef6a · 15 files, +978 −17

* People's own layouts: active, saved, share, gallery and the AI suggestion

* Site menu: Compare joins Read & ask; migration 0077 in the trust list

* Pin round: Compare in the site menu and 0077 in the migration lists

* Number the layouts migration 0078, after the in-flight 0077

* Merge main into the layouts branch

* Repin to contracts v0.410.0 and sdk v0.152.0; number layouts migration 0079

* Registry topics: the Elements events in Contracts 0.410.0

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex ef79c97d..b9cfa6f5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/migrations/0079_ui_layouts.sql b/migrations/0079_ui_layouts.sql
nnew file mode 100644
iindex 00000000..d77265ba
---- /dev/null
++++ b/migrations/0079_ui_layouts.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 694c49ae..60856f28 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 589eb803..42880297 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex ef8aaad7..2d8cad5a 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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');
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 1a8cbeb9..e1d97288 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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.
ddiff --git a/server/index.js b/server/index.js
iindex 5f5301f1..6cf4eece 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
ddiff --git a/server/layouts/index.js b/server/layouts/index.js
nnew file mode 100644
iindex 00000000..7559224b
---- /dev/null
++++ b/server/layouts/index.js
@@@ -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 };
ddiff --git a/server/public-cors.js b/server/public-cors.js
iindex 71a229b4..7e48f763 100644
---- a/server/public-cors.js
++++ b/server/public-cors.js
@@@ -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 site
if (/^\/api\/v1\/people\/@[A-Za-z0-9_]{1,40}\/page$/.test(path)) return true; // public person page JSON
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 716cc195..9321ca84 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['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.
ddiff --git a/test/layouts.test.js b/test/layouts.test.js
nnew file mode 100644
iindex 00000000..f8f702b0
---- /dev/null
++++ b/test/layouts.test.js
@@@ -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); });
ddiff --git a/test/public-cors.test.js b/test/public-cors.test.js
iindex 1e8f1fde..12fd0481 100644
---- a/test/public-cors.test.js
++++ b/test/public-cors.test.js
@@@ -11,12 +11,14 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
(async () => {
// 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',
-- '/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`);
}
ddiff --git a/test/registry-topics.test.js b/test/registry-topics.test.js
iindex cc182557..5a7de8b1 100644
---- a/test/registry-topics.test.js
++++ b/test/registry-topics.test.js
@@@ -70,7 +70,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
let 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.');
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex b389e23c..6a8301ee 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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'],
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 50fe6cbb..ea6aaa3a 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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.