openvibers/openvibe.design Commits
Publish a design on openvibe.space (open item D8) (#137)
Changes
nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- Publish a design on openvibe.space (open item D8): the Space site a design was published to, its address, and when.++-- Null for every design today; the code before this one never reads them.++ALTER TABLE designs ADD COLUMN IF NOT EXISTS space_site_id text;++ALTER TABLE designs ADD COLUMN IF NOT EXISTS space_url text;++ALTER TABLE designs ADD COLUMN IF NOT EXISTS published_at timestamptz;@@@ -693,3 +693,18 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef.lk-style[aria-pressed="true"] .lk-st-cap b{color:var(--accent)}@media (max-width:480px){.lk-styles{gap:8px}.lk-st-screen{padding:8px 7px 0;border-radius:14px}.lk-st-head{font-size:19px;gap:6px}.lk-st-lines>span+span{display:none}.lk-st-bar{margin:4px -7px 0;font-size:15px}.lk-st-tab{width:26px}.lk-st-cap small{font-size:11px}}@media (prefers-reduced-motion:reduce){.lk-st-screen{transition:none}.lk-style:active .lk-st-screen{transform:none}}++++/* Publish on openvibe.space (open item D8), drawn like the Versions panel. */++.dv-publish { max-width: 1320px; margin: 0 auto 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); }++.dv-publish summary { display: flex; align-items: center; gap: 8px; min-height: 34px; cursor: pointer; font-weight: 800; font-size: .92rem; }++.dv-publish summary span { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 8px; border-radius: 99px; background: var(--bg-secondary); color: var(--text-muted); font-size: .74rem; }++.dv-publish-form { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; margin: 8px 0 4px; }++.dv-publish-name { display: grid; gap: 4px; flex: 1 1 260px; min-width: 0; font-size: .86rem; }++.dv-publish-name small { color: var(--text-muted); }++.dv-publish-addr { display: flex; align-items: center; min-height: 42px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-secondary); overflow: hidden; }++.dv-publish-addr input { flex: 1; min-width: 0; height: 40px; padding: 0 10px; border: 0; background: transparent; color: inherit; font: inherit; font-size: 16px; }++.dv-publish-addr span { padding: 0 10px; color: var(--text-muted); white-space: nowrap; font-size: .86rem; }++.dv-publish-form button { min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--accent); color: var(--on-accent, #fff); font: inherit; font-weight: 800; cursor: pointer; }++.dv-live, .dv-ok, .dv-err { margin: 8px 0; font-size: .9rem; overflow-wrap: anywhere; }++.dv-ok { padding: 8px 12px; border-radius: 10px; background: color-mix(in srgb, #22c55e 14%, transparent); }++.dv-err { padding: 8px 12px; border-radius: 10px; background: color-mix(in srgb, #ef4444 14%, transparent); }@@@ -74,6 +74,8 @@ async function createApp(opts = {}) {// The Arena generation job now lives on Bench; Design keeps a no-op loop for test compatibility.const jobs = opts.jobs || { tick: async () => null, start: () => null, stop: () => null, get running() { return false; } };const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now });++ // Publishing a design on openvibe.space (open item D8).++ const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });const accountData = accountDataLib.create({ db: s.db, log });const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });const accountSend = opts.accountSend || (config.oauth.clientSecret@@@ -81,7 +83,7 @@ async function createApp(opts = {}) {: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });const ctx = {-- config, s, store: s, keys, sso, catalog, searchIndex, arena, bench, ai, jobs, designs, accountData, accountSend, log, picks,++ config, s, store: s, keys, sso, catalog, searchIndex, arena, bench, ai, jobs, designs, publisher, accountData, accountSend, log, picks,random: opts.random || Math.random,};@@@ -64,6 +64,15 @@ function load(env = process.env) {maxHtmlBytes: 200 * 1024,},++ // OpenVibe.Space (open item D8): a design is published as a static site, with Design's service token acting for++ // the person (X-OV-Subject); Network grants space.site.manage and space.deploy.create.++ space: {++ url: trim(env.OV_SPACE_INTERNAL_URL || 'http://127.0.0.1:4940'),++ audience: env.DESIGN_SPACE_AUDIENCE || 'openvibe.space',++ scope: env.DESIGN_SPACE_SCOPE || 'space.site.manage space.deploy.create',++ timeoutMs: 30_000,++ },++// OpenVibe.Bench: Design creates tasks with its service token. A round uses its creator's Network token// so Bench can enforce personal budgets and prevent voting on one's own round.bench: {@@@ -98,7 +98,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolconst publicRow = d => ({ id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,status: d.variants.every(v => v.status === 'done' || v.status === 'failed')? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',-- createdAt: d.created_at, updatedAt: d.updated_at });++ createdAt: d.created_at, updatedAt: d.updated_at,++ published: d.space_url ? { url: d.space_url, at: d.published_at || null } : null });function allowed(d, owner, key) {if (d.owner && owner === d.owner) return true;if (!d.guest_hash || !key) return false;@@@ -469,7 +470,23 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolconst pending = await db.many("SELECT id FROM designs WHERE variants @> '[{\"status\":\"pending\"}]'::jsonb OR variants @> '[{\"status\":\"running\"}]'::jsonb ORDER BY created_at LIMIT 20");for (const d of pending) run(d.id).catch(err => log.warn('[OpenVibe.Design] resume:', err.message));}-- return { create, read, setTheme, recent, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit };++ /**++ * Publish the working frame on openvibe.space (publish.js): its owner only (a guest's private link cannot), and++ * only once a frame is picked. The site is remembered, so publishing again updates the same address.++ */++ async function publish({ id, owner, name = '', publisher }) {++ if (!owner) throw fail(401, 'auth.required', 'Sign in to publish a design on openvibe.space.');++ const d = await row(id, owner, null);++ if (d.owner !== owner) throw fail(403, 'design.forbidden', 'Only the person who made this design can publish it.');++ const data = await read(id, owner, null);++ const index = data.pick ? Number(data.pick.variantIndex) : -1;++ const variant = index >= 0 ? data.variants[index] : null;++ const out = await publisher.publish({ design: d, html: variant && variant.html, subject: owner, name });++ await db.exec('UPDATE designs SET space_site_id = $2, space_url = $3, published_at = now(), updated_at = now() WHERE id = $1', [id, out.siteId, out.url]);++ return out;++ }++++ return { create, read, setTheme, recent, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit, publish };}module.exports = { createDesigns, wrap, tokenCss, themeCss, themePrompt, editPrompt };@@@ -53,6 +53,9 @@ function createDesignApi(ctx) {await ctx.designs.read(req.params.id, owner(req), keyOf(req)))));routes.post('/:id/theme', sameOrigin, safe(async (req, res) => priv(res, 200,await ctx.designs.setTheme(req.params.id, (req.body || {}).themeId, owner(req), keyOf(req)))));++ // Publish the working frame on openvibe.space (open item D8): the owner only; { name? } for a new site.++ routes.post('/:id/publish', sameOrigin, safe(async (req, res) => priv(res, 200,++ await ctx.designs.publish({ id: req.params.id, owner: owner(req), name: (req.body || {}).name || '', publisher: ctx.publisher }))));routes.post('/:id/pick', sameOrigin, safe(async (req, res) => {const body = req.body || {};const result = await ctx.designs.pick({ id: req.params.id, variantIndex: body.variantIndex,@@@ -126,7 +126,7 @@ function createPageRoutes(ctx) {try {const data = await ctx.designs.read(req.params.id, subjectOf(req), req.query.key);const themes = (await catalog.list()).themes;-- return page(res, 200, designView.canvas({ data, themes, editKey: req.query.key || '' }), req.viewer,++ return page(res, 200, designView.canvas({ data, themes, editKey: req.query.key || '', viewer: subjectOf(req), publishing: { enabled: Boolean(ctx.publisher && ctx.publisher.enabled), notice: req.query.published ? 'Published.' : null, error: req.query.publish_error ? String(req.query.publish_error).slice(0, 300) : null } }), req.viewer,{ cacheControl: 'private, no-store' });} catch (err) {return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not read the design.');@@@ -165,6 +165,18 @@ function createPageRoutes(ctx) {return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not edit that frame.');}});++ // Publish the working frame on openvibe.space (open item D8): the owner, signed in, from openvibe.design itself.++ r.post('/d/:id/publish', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ const back = (q) => res.redirect(303, `/d/${encodeURIComponent(req.params.id)}?${q}#dv-publish`);++ try {++ await ctx.designs.publish({ id: req.params.id, owner: subjectOf(req), name: req.body.name || '', publisher: ctx.publisher });++ return back('published=1');++ } catch (err) {++ if (!err.status) throw err;++ return back(`publish_error=${encodeURIComponent(err.detail || 'That did not publish.')}`);++ }++ });for (const action of ['undo', 'redo']) {r.post(`/d/:id/${action}`, express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');@@@ -14,7 +14,7 @@ const TABLES = [{ table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.-- { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'created_at', 'updated_at'] },++ { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'space_url', 'published_at', 'created_at', 'updated_at'] },// The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved// drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++++/**++ * Publish a design on openvibe.space (open item D8): the working frame becomes a static site at++ * <name>.openvibe.space, in the person's own Space project "Designs", deployed and live.++ *++ * Design calls Space's API with its own Network client-credentials token (audience openvibe.space; Network grants++ * space.site.manage and space.deploy.create) and names the person it acts for with X-OV-Subject, so the project and the++ * site are the person's, under their own Space quotas and visible on their Space dashboard. Publishing again uploads++ * the frame as it is now to the same site (a new deploy; Space keeps the earlier ones for a rollback).++ *++ * publish({ design, html, subject, name }) → { url, siteId, deployId, created }++ * NAME_RE, slugFor(prompt)++ *++ * The page is exactly the frame's document (designs.js wrap(): sanitized, no scripts, the theme's tokens inlined), as++ * index.html. Nothing else is uploaded.++ */++++const { serviceAuth } = require('openvibe-contracts');++++// Space's site names: 3–40 of a–z, 0–9 and single hyphens (Space server/domain/sites.js NAME_RE).++const NAME_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$/;++const PROJECT_NAME = 'Designs';++++class PublishError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; this.detail = detail; }++}++++/** A site name from the design's prompt: its first words, lowercased, hyphenated, then a short tail so it is free. */++function slugFor(prompt, tail = '') {++ const words = String(prompt || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')++ .replace(/[^a-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean).slice(0, 4).join('-').slice(0, 30).replace(/-+$/, '');++ const base = words.length >= 3 ? words : `design${words ? `-${words}` : ''}`;++ return tail ? `${base}-${tail}`.slice(0, 40).replace(/-+$/, '') : base;++}++++function createPublisher({ config, fetchImpl = globalThis.fetch, log = console } = {}) {++ const space = config.space || {};++ const tokenClient = config.oauth && config.oauth.clientSecret && space.url++ ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: space.audience || 'openvibe.space',++ scope: space.scope || 'space.site.manage space.deploy.create',++ fetchImpl,++ })++ : null;++ const enabled = Boolean(tokenClient);++++ async function call(subject, method, path, { json, form } = {}) {++ const headers = { Accept: 'application/json', 'X-OV-Subject': subject, ...(await tokenClient.authHeaders()) };++ if (json !== undefined) headers['Content-Type'] = 'application/json';++ let res;++ try {++ res = await fetchImpl(`${space.url.replace(/\/+$/, '')}/api/v1${path}`, {++ method, headers, body: json !== undefined ? JSON.stringify(json) : form, signal: AbortSignal.timeout(space.timeoutMs || 30_000),++ });++ } catch (err) {++ throw new PublishError(503, 'publish.unavailable', 'openvibe.space did not answer. Try again in a moment.');++ }++ const body = await res.json().catch(() => ({}));++ if (res.status === 401) tokenClient.invalidate();++ return { status: res.status, body };++ }++ const problem = (r, what) => {++ const detail = (r.body && (r.body.detail || r.body.title || r.body.message)) || `openvibe.space answered ${r.status}`;++ log.warn(`[Design] publish: ${what} ${r.status} ${r.body && r.body.code ? r.body.code : ''}`);++ return new PublishError(r.status >= 500 ? 502 : r.status, (r.body && r.body.code) || 'publish.refused', `${what}: ${detail}`);++ };++++ /** The person's "Designs" project on Space, made the first time. */++ async function project(subject) {++ const list = await call(subject, 'GET', '/projects');++ if (list.status !== 200) throw problem(list, 'Reading your Space projects');++ const found = (list.body.projects || []).find((p) => p && p.name === PROJECT_NAME && p.owner === subject && p.environment !== 'sandbox');++ if (found) return found;++ const made = await call(subject, 'POST', '/projects', { json: { name: PROJECT_NAME } });++ if (made.status !== 201) throw problem(made, 'Making your Designs project on Space');++ return made.body.project;++ }++++ /** The design's site: the one it was published to before, or a new one (the name asked for, else from the prompt). */++ async function site(subject, design, name) {++ if (design.space_site_id) {++ const had = await call(subject, 'GET', `/sites/${encodeURIComponent(design.space_site_id)}`);++ if (had.status === 200 && had.body.site) return { site: had.body.site, created: false };++ }++ const p = await project(subject);++ const wanted = name ? String(name).trim().toLowerCase() : '';++ if (wanted && !NAME_RE.test(wanted)) throw new PublishError(422, 'publish.invalid_name', 'A site name is 3–40 of a–z, 0–9 and single hyphens.');++ const tries = wanted ? [wanted] : [slugFor(design.prompt), slugFor(design.prompt, design.id.slice(-4).toLowerCase().replace(/[^a-z0-9]/g, 'x')), slugFor(design.prompt, Math.random().toString(36).slice(2, 6))];++ for (const n of tries) {++ const made = await call(subject, 'POST', `/projects/${encodeURIComponent(p.id)}/sites`, { json: { name: n } });++ if (made.status === 201) return { site: made.body.site, created: true };++ if (made.status === 409 && !wanted) continue;++ if (made.status === 409) throw new PublishError(409, 'publish.name_taken', `${wanted}.openvibe.space is taken. Pick another name.`);++ throw problem(made, 'Making the site on Space');++ }++ throw new PublishError(409, 'publish.name_taken', 'Every name tried was taken. Type a name for the site.');++ }++++ async function publish({ design, html, subject, name = '' }) {++ if (!enabled) throw new PublishError(503, 'publish.off', 'Publishing to openvibe.space is not set up on this server.');++ if (!subject || !/^usr_/.test(String(subject))) throw new PublishError(401, 'auth.required', 'Sign in to publish a design on openvibe.space.');++ if (!html) throw new PublishError(409, 'publish.no_frame', 'Pick a frame first: the working frame is what gets published.');++ const { site: s, created } = await site(subject, design, name);++ const form = new FormData();++ form.append('files', new Blob([html], { type: 'text/html' }), 'index.html');++ form.append('activate', '1');++ const dep = await call(subject, 'POST', `/sites/${encodeURIComponent(s.id)}/deploys?activate=1`, { form });++ if (dep.status !== 201) throw problem(dep, 'Uploading the page to Space');++ return { url: dep.body.url || `https://${s.name}.openvibe.space`, siteId: s.id, siteName: s.name, deployId: dep.body.deploy && dep.body.deploy.id, created };++ }++++ return { publish, enabled };++}++++module.exports = { createPublisher, slugFor, NAME_RE, PublishError, PROJECT_NAME };@@@ -66,7 +66,30 @@ const versionDetail = (v) => v.kind === 'chat' && v.instruction? `<span class="dv-fine">${esc((v.change.path || '').replace(/^body > /, ''))}${v.change.text !== undefined ? ' · text' : ''}${v.change.color ? ' · colour' : ''}${v.change.spacing ? ' · spacing' : ''}${v.change.align ? ' · alignment' : ''}</span>`: '';--function canvas({ data, themes = [], editKey = '' }) {++/**++ * Publish on openvibe.space (open item D8): the working frame as a site at <name>.openvibe.space, in the person's own++ * Space project. Only the owner, signed in, sees the form; a guest's private link is told to sign in.++ */++function publishPanel({ design, pick, viewer, editKey, publishing = {} }) {++ const published = design.published;++ const mine = Boolean(viewer) && !editKey;++ const head = `<summary>Publish <span>${published ? 'live' : 'on openvibe.space'}</span></summary>`;++ const note = publishing.notice ? `<p class="dv-ok" role="status">${esc(publishing.notice)} <a href="${esc(published ? published.url : '#')}" target="_blank" rel="noopener">${esc(published ? published.url.replace(/^https:\/\//, '') : '')}</a></p>` : '';++ const err = publishing.error ? `<p class="dv-err" role="alert">${esc(publishing.error)}</p>` : '';++ let inner;++ if (!publishing.enabled) inner = '<p class="dv-fine">Publishing is not set up on this server.</p>';++ else if (!mine) inner = '<p class="dv-fine">Sign in as the person who made this design to publish it as a site on openvibe.space.</p>';++ else if (!pick) inner = '<p class="dv-fine">Pick a frame first: the working frame is what gets published.</p>';++ else inner = `${published ? `<p class="dv-live">Live at <a href="${esc(published.url)}" target="_blank" rel="noopener">${esc(published.url.replace(/^https:\/\//, ''))}</a></p>` : ''}++<form method="post" action="/d/${encodeURIComponent(design.id)}/publish" class="dv-publish-form">++${published ? '' : '<label class="dv-publish-name"><span>Site name <small>(optional; a–z, 0–9 and hyphens)</small></span><span class="dv-publish-addr"><input name="name" maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){2,39}" autocapitalize="none" autocomplete="off" spellcheck="false" placeholder="my-landing-page"><span>.openvibe.space</span></span></label>'}++<button type="submit">${published ? 'Publish this version' : 'Publish on openvibe.space'}</button>++</form>++<p class="dv-fine">The working frame becomes the site's page, in a project called Designs on your Space account. Publishing again updates the same address; Space keeps the earlier versions for a rollback.</p>`;++ return `<details class="dv-publish" id="dv-publish"${publishing.notice || publishing.error || published ? ' open' : ''}>${head}${note}${err}${inner}</details>`;++}++++function canvas({ data, themes = [], editKey = '', viewer = null, publishing = { enabled: false } }) {const { design, variants, pick, versions = [] } = data;const selectedTheme = themes.find(t => t.id === design.themeId) || themes[0] || { variables: {} };const variables = selectedTheme.variables || {};@@@ -125,6 +148,7 @@ ${keyInput}<p class="dv-fine">${pick ? `${chatLeft} of ${pick.chatEditLimit} AI edits left on this design. Click edits and undo are free.` : 'Chat edits work on the picked frame.'}</p></form><div class="dv-rail" id="dv-rail">${cards}</div>++${publishPanel({ design, pick, viewer, editKey, publishing })}<details class="dv-versions" id="dv-versions" open><summary>Versions <span>${versions.length}</span></summary><div class="dv-vcontrols">nnew file mode 100644@@@ -0,0 +1,158 @@++'use strict';++++/**++ * Publish a design on openvibe.space (open item D8). Two halves:++ * publish.js against a fake Space: Design's service token acts for the person (X-OV-Subject), the person's++ * "Designs" project is made once, the site is named from the prompt (or the name asked for), a taken name tries++ * another, the working frame is uploaded as index.html and activated, and publishing again reuses the site.++ * The routes: the owner publishes from the canvas (and the API); a guest's private link and another person cannot;++ * nothing publishes before a frame is picked; the canvas then says where it is live.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { createPublisher, slugFor, NAME_RE } = require('../server/publish');++++const PERSON = 'usr_01JZ00000000000000000PUB01';++++/** A fake Space API: projects, sites (names global), deploys; records each call. */++function fakeSpace({ taken = [] } = {}) {++ const calls = [];++ const projects = [];++ const sites = [];++ const names = new Set(taken);++ const reply = (status, body) => ({ status, ok: status >= 200 && status < 300, headers: new Headers({ 'content-type': 'application/json' }), json: async () => body, text: async () => JSON.stringify(body) });++ const fetchImpl = async (url, init = {}) => {++ const u = new URL(url);++ if (u.pathname === '/oauth/token') return reply(200, { access_token: 'svc-design-token', token_type: 'Bearer', expires_in: 300 });++ const headers = init.headers || {};++ const call = { method: init.method || 'GET', path: u.pathname, query: u.search, subject: headers['X-OV-Subject'], auth: headers.Authorization };++ calls.push(call);++ if (call.path === '/api/v1/projects' && call.method === 'GET') return reply(200, { projects: projects.filter((p) => p.owner === call.subject) });++ if (call.path === '/api/v1/projects' && call.method === 'POST') {++ const p = { id: `prj_${projects.length + 1}`, name: JSON.parse(init.body).name, owner: call.subject, environment: 'production' };++ projects.push(p);++ return reply(201, { project: p });++ }++ let m = /^\/api\/v1\/projects\/([^/]+)\/sites$/.exec(call.path);++ if (m && call.method === 'POST') {++ const name = JSON.parse(init.body).name;++ if (names.has(name)) return reply(409, { code: 'site.name_taken', detail: 'taken' });++ names.add(name);++ const s = { id: `site_${sites.length + 1}`, name, project_id: m[1] };++ sites.push(s);++ return reply(201, { site: s });++ }++ m = /^\/api\/v1\/sites\/([^/]+)$/.exec(call.path);++ if (m && call.method === 'GET') { const s = sites.find((x) => x.id === m[1]); return s ? reply(200, { site: s }) : reply(404, { code: 'site.not_found' }); }++ m = /^\/api\/v1\/sites\/([^/]+)\/deploys$/.exec(call.path);++ if (m && call.method === 'POST') {++ const files = [];++ for (const [k, v] of init.body.entries()) if (k === 'files') files.push({ name: v.name, text: await v.text() });++ call.files = files;++ const s = sites.find((x) => x.id === m[1]);++ return reply(201, { deploy: { id: `dep_${calls.length}` }, activated: true, url: `https://${s.name}.openvibe.space` });++ }++ return reply(404, { code: 'not_found' });++ };++ return { calls, fetchImpl, projects, sites };++}++++const config = (extra = {}) => ({ networkInternalUrl: 'http://network.test', oauth: { clientId: 'design', clientSecret: 'design-secret' }, space: { url: 'http://space.test', audience: 'openvibe.space', scope: 'space.site.manage space.deploy.create' }, ...extra });++const quiet = { log() {}, warn() {}, error() {} };++++(async () => {++ await check('site names: from the prompt, valid for Space', async () => {++ assert.equal(slugFor('A coffee roaster landing page!'), 'a-coffee-roaster-landing');++ assert.equal(slugFor('Hi'), 'design-hi');++ assert.equal(slugFor('Café ☕ menu', 'ab12'), 'cafe-menu-ab12');++ ['a-coffee-roaster-landing', 'design-hi', 'cafe-menu-ab12'].forEach((n) => assert.ok(NAME_RE.test(n), n));++ });++++ await check('the first publish makes the person\'s Designs project and a site, uploads the frame and activates it', async () => {++ const space = fakeSpace({ taken: ['a-coffee-roaster-landing'] });++ const pub = createPublisher({ config: config(), fetchImpl: space.fetchImpl, log: quiet });++ const design = { id: 'des_01ABCDEFGH', prompt: 'A coffee roaster landing page' };++ const out = await pub.publish({ design, html: '<!doctype html><html><body><h1>Roast</h1></body></html>', subject: PERSON });++ assert.ok(out.created);++ assert.equal(out.url, 'https://a-coffee-roaster-landing-efgh.openvibe.space', 'a taken name tries the next one');++ assert.ok(space.calls.every((c) => c.subject === PERSON && c.auth === 'Bearer svc-design-token'), 'Design\'s token, acting for the person');++ assert.deepStrictEqual(space.projects.map((p) => [p.name, p.owner]), [['Designs', PERSON]]);++ const dep = space.calls.find((c) => c.files);++ assert.match(dep.query, /activate=1/);++ assert.deepStrictEqual(dep.files.map((f) => f.name), ['index.html']);++ assert.match(dep.files[0].text, /<h1>Roast<\/h1>/);++++ // Again: the same site, a new deploy, no new project.++ const again = await pub.publish({ design: { ...design, space_site_id: out.siteId }, html: '<!doctype html><html><body><h1>Roast 2</h1></body></html>', subject: PERSON });++ assert.equal(again.siteId, out.siteId);++ assert.equal(again.created, false);++ assert.equal(space.projects.length, 1);++ assert.equal(space.sites.length, 1);++ });++++ await check('a name asked for is used, or refused plainly', async () => {++ const space = fakeSpace({ taken: ['taken-name'] });++ const pub = createPublisher({ config: config(), fetchImpl: space.fetchImpl, log: quiet });++ const d = { id: 'des_02', prompt: 'x' };++ assert.equal((await pub.publish({ design: d, html: '<p>x</p>', subject: PERSON, name: 'my-page' })).url, 'https://my-page.openvibe.space');++ await assert.rejects(pub.publish({ design: d, html: '<p>x</p>', subject: PERSON, name: 'taken-name' }), (e) => e.code === 'publish.name_taken' && /taken-name\.openvibe\.space is taken/.test(e.detail));++ await assert.rejects(pub.publish({ design: d, html: '<p>x</p>', subject: PERSON, name: 'Bad_Name' }), (e) => e.code === 'publish.invalid_name');++ await assert.rejects(pub.publish({ design: d, html: '', subject: PERSON }), (e) => e.code === 'publish.no_frame');++ await assert.rejects(pub.publish({ design: d, html: '<p>x</p>', subject: null }), (e) => e.code === 'auth.required');++ });++++ await check('without its secret the publisher is off and says so', async () => {++ const pub = createPublisher({ config: config({ oauth: { clientId: 'design', clientSecret: '' } }), fetchImpl: async () => { throw new Error('no call'); }, log: quiet });++ assert.equal(pub.enabled, false);++ await assert.rejects(pub.publish({ design: { id: 'd', prompt: 'p' }, html: '<p></p>', subject: PERSON }), (e) => e.code === 'publish.off');++ });++++ // ── The routes, with an in-process fake publisher ──++ const published = [];++ const publisher = { enabled: true, publish: async ({ design, html, subject, name }) => { published.push({ id: design.id, html, subject, name }); return { url: `https://${name || 'from-prompt'}.openvibe.space`, siteId: 'site_9', siteName: name || 'from-prompt', created: true }; } };++ const ai = {++ slotPlan: async () => ({ slots: [{ provider: 'alpha', model: 'one' }, { provider: 'beta', model: 'two' }] }),++ generate: async ({ slot }) => ({ html: '<!doctype html><html><body><h1>Shop</h1></body></html>', provider: slot.provider, model: slot.model, cost_usd: 0.001, ms: 10 }),++ };++ const t = await boot({ ai, publisher });++ const kim = t.network.addUser('kim-pub');++ const bob = t.network.addUser('bob-pub');++ const POST = { origin: 'https://openvibe.design' };++ const formPost = (fields, extra = {}) => ({ method: 'POST', body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...extra });++ try {++ const made = await t.get('/api/v1/designs', { ...POST, as: kim, json: { prompt: 'A shop page', kind: 'page' } });++ assert.equal(made.status, 201, made.text);++ const id = made.json().design.id;++ for (let i = 0; i < 80; i++) { if ((await t.get(`/api/v1/designs/${id}`, { as: kim })).json().design.status === 'done') break; await new Promise((r) => setTimeout(r, 20)); }++++ await check('nothing publishes before a frame is picked; the canvas says to pick one', async () => {++ const page = await t.get(`/d/${id}`, { as: kim });++ assert.match(page.text, /id="dv-publish"/);++ assert.match(page.text, /Pick a frame first: the working frame is what gets published\./);++ });++++ await check('the owner publishes from the canvas; the canvas then says where it is live', async () => {++ assert.equal((await t.get(`/api/v1/designs/${id}/pick`, { ...POST, as: kim, json: { variantIndex: 0 } })).status, 201);++ const form = await t.get(`/d/${id}/publish`, formPost({ name: 'kims-shop' }, { ...POST, as: kim }));++ assert.equal(form.status, 303, form.text);++ assert.match(form.headers.get('location'), /published=1#dv-publish$/);++ assert.deepStrictEqual([published[0].subject, published[0].name], [kim.subject, 'kims-shop']);++ assert.match(published[0].html, /<h1>Shop<\/h1>/, 'the working frame');++ const page = await t.get(`/d/${id}?published=1`, { as: kim });++ assert.match(page.text, /Live at <a href="https:\/\/kims-shop\.openvibe\.space"/);++ assert.match(page.text, />Publish this version</);++ const api = (await t.get(`/api/v1/designs/${id}`, { as: kim })).json();++ assert.equal(api.design.published.url, 'https://kims-shop.openvibe.space');++ });++++ await check('another person cannot publish it; a cross-site form is refused', async () => {++ const n = published.length;++ const other = await t.get(`/api/v1/designs/${id}/publish`, { ...POST, as: bob, json: {} });++ assert.ok([403, 404].includes(other.status), other.text);++ const cross = await t.get(`/d/${id}/publish`, formPost({}, { origin: 'https://evil.example', as: kim }));++ assert.equal(cross.status, 403);++ assert.equal(published.length, n);++ });++ } finally { await t.close(); }++ done();++})();@@@ -47,7 +47,7 @@ async function boot(opts = {}) {const config = configLib.load(env);const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });const store = opts.store || createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai });++ const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai, publisher: opts.publisher });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- Publish a design on openvibe.space (open item D8): the Space site a design was published to, its address, and when.
+-- Null for every design today; the code before this one never reads them.
+ALTER TABLE designs ADD COLUMN IF NOT EXISTS space_site_id text;
+ALTER TABLE designs ADD COLUMN IF NOT EXISTS space_url text;
+ALTER TABLE designs ADD COLUMN IF NOT EXISTS published_at timestamptz;
@@ -693,3 +693,18 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -693,3 +693,18 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.lk-style[aria-pressed="true"] .lk-st-cap b{color:var(--accent)}
.lk-style[aria-pressed="true"] .lk-st-cap b{color:var(--accent)}
@media (max-width:480px){.lk-styles{gap:8px}.lk-st-screen{padding:8px 7px 0;border-radius:14px}.lk-st-head{font-size:19px;gap:6px}.lk-st-lines>span+span{display:none}.lk-st-bar{margin:4px -7px 0;font-size:15px}.lk-st-tab{width:26px}.lk-st-cap small{font-size:11px}}
@media (max-width:480px){.lk-styles{gap:8px}.lk-st-screen{padding:8px 7px 0;border-radius:14px}.lk-st-head{font-size:19px;gap:6px}.lk-st-lines>span+span{display:none}.lk-st-bar{margin:4px -7px 0;font-size:15px}.lk-st-tab{width:26px}.lk-st-cap small{font-size:11px}}
@media (prefers-reduced-motion:reduce){.lk-st-screen{transition:none}.lk-style:active .lk-st-screen{transform:none}}
@media (prefers-reduced-motion:reduce){.lk-st-screen{transition:none}.lk-style:active .lk-st-screen{transform:none}}
+
+/* Publish on openvibe.space (open item D8), drawn like the Versions panel. */
+.dv-publish { max-width: 1320px; margin: 0 auto 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); }
+.dv-publish summary { display: flex; align-items: center; gap: 8px; min-height: 34px; cursor: pointer; font-weight: 800; font-size: .92rem; }
+.dv-publish summary span { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 8px; border-radius: 99px; background: var(--bg-secondary); color: var(--text-muted); font-size: .74rem; }
+.dv-publish-form { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; margin: 8px 0 4px; }
+.dv-publish-name { display: grid; gap: 4px; flex: 1 1 260px; min-width: 0; font-size: .86rem; }
+.dv-publish-name small { color: var(--text-muted); }
+.dv-publish-addr { display: flex; align-items: center; min-height: 42px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-secondary); overflow: hidden; }
+.dv-publish-addr input { flex: 1; min-width: 0; height: 40px; padding: 0 10px; border: 0; background: transparent; color: inherit; font: inherit; font-size: 16px; }
+.dv-publish-addr span { padding: 0 10px; color: var(--text-muted); white-space: nowrap; font-size: .86rem; }
+.dv-publish-form button { min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--accent); color: var(--on-accent, #fff); font: inherit; font-weight: 800; cursor: pointer; }
+.dv-live, .dv-ok, .dv-err { margin: 8px 0; font-size: .9rem; overflow-wrap: anywhere; }
+.dv-ok { padding: 8px 12px; border-radius: 10px; background: color-mix(in srgb, #22c55e 14%, transparent); }
+.dv-err { padding: 8px 12px; border-radius: 10px; background: color-mix(in srgb, #ef4444 14%, transparent); }
@@ -74,6 +74,8 @@ async function createApp(opts = {}) {
@@ -74,6 +74,8 @@ async function createApp(opts = {}) {
// The Arena generation job now lives on Bench; Design keeps a no-op loop for test compatibility.
// The Arena generation job now lives on Bench; Design keeps a no-op loop for test compatibility.
const jobs = opts.jobs || { tick: async () => null, start: () => null, stop: () => null, get running() { return false; } };
const jobs = opts.jobs || { tick: async () => null, start: () => null, stop: () => null, get running() { return false; } };
const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now });
const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now });
+ // Publishing a design on openvibe.space (open item D8).
+ const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });
const accountData = accountDataLib.create({ db: s.db, log });
const accountData = accountDataLib.create({ db: s.db, log });
const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
@@ -81,7 +83,7 @@ async function createApp(opts = {}) {
@@ -81,7 +83,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
const ctx = {
- config, s, store: s, keys, sso, catalog, searchIndex, arena, bench, ai, jobs, designs, accountData, accountSend, log, picks,
+ config, s, store: s, keys, sso, catalog, searchIndex, arena, bench, ai, jobs, designs, publisher, accountData, accountSend, log, picks,
random: opts.random || Math.random,
random: opts.random || Math.random,
};
};
@@ -64,6 +64,15 @@ function load(env = process.env) {
@@ -64,6 +64,15 @@ function load(env = process.env) {
maxHtmlBytes: 200 * 1024,
maxHtmlBytes: 200 * 1024,
},
},
+ // OpenVibe.Space (open item D8): a design is published as a static site, with Design's service token acting for
+ // the person (X-OV-Subject); Network grants space.site.manage and space.deploy.create.
+ space: {
+ url: trim(env.OV_SPACE_INTERNAL_URL || 'http://127.0.0.1:4940'),
+ audience: env.DESIGN_SPACE_AUDIENCE || 'openvibe.space',
+ scope: env.DESIGN_SPACE_SCOPE || 'space.site.manage space.deploy.create',
+ timeoutMs: 30_000,
+ },
+
// OpenVibe.Bench: Design creates tasks with its service token. A round uses its creator's Network token
// OpenVibe.Bench: Design creates tasks with its service token. A round uses its creator's Network token
// so Bench can enforce personal budgets and prevent voting on one's own round.
// so Bench can enforce personal budgets and prevent voting on one's own round.
bench: {
bench: {
@@ -98,7 +98,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -98,7 +98,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const publicRow = d => ({ id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,
const publicRow = d => ({ id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,
status: d.variants.every(v => v.status === 'done' || v.status === 'failed')
status: d.variants.every(v => v.status === 'done' || v.status === 'failed')
? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
- createdAt: d.created_at, updatedAt: d.updated_at });
+ createdAt: d.created_at, updatedAt: d.updated_at,
+ published: d.space_url ? { url: d.space_url, at: d.published_at || null } : null });
function allowed(d, owner, key) {
function allowed(d, owner, key) {
if (d.owner && owner === d.owner) return true;
if (d.owner && owner === d.owner) return true;
if (!d.guest_hash || !key) return false;
if (!d.guest_hash || !key) return false;
@@ -469,7 +470,23 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -469,7 +470,23 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const pending = await db.many("SELECT id FROM designs WHERE variants @> '[{\"status\":\"pending\"}]'::jsonb OR variants @> '[{\"status\":\"running\"}]'::jsonb ORDER BY created_at LIMIT 20");
const pending = await db.many("SELECT id FROM designs WHERE variants @> '[{\"status\":\"pending\"}]'::jsonb OR variants @> '[{\"status\":\"running\"}]'::jsonb ORDER BY created_at LIMIT 20");
for (const d of pending) run(d.id).catch(err => log.warn('[OpenVibe.Design] resume:', err.message));
for (const d of pending) run(d.id).catch(err => log.warn('[OpenVibe.Design] resume:', err.message));
}
}
- return { create, read, setTheme, recent, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit };
+ /**
+ * Publish the working frame on openvibe.space (publish.js): its owner only (a guest's private link cannot), and
+ * only once a frame is picked. The site is remembered, so publishing again updates the same address.
+ */
+ async function publish({ id, owner, name = '', publisher }) {
+ if (!owner) throw fail(401, 'auth.required', 'Sign in to publish a design on openvibe.space.');
+ const d = await row(id, owner, null);
+ if (d.owner !== owner) throw fail(403, 'design.forbidden', 'Only the person who made this design can publish it.');
+ const data = await read(id, owner, null);
+ const index = data.pick ? Number(data.pick.variantIndex) : -1;
+ const variant = index >= 0 ? data.variants[index] : null;
+ const out = await publisher.publish({ design: d, html: variant && variant.html, subject: owner, name });
+ await db.exec('UPDATE designs SET space_site_id = $2, space_url = $3, published_at = now(), updated_at = now() WHERE id = $1', [id, out.siteId, out.url]);
+ return out;
+ }
+
+ return { create, read, setTheme, recent, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit, publish };
}
}
module.exports = { createDesigns, wrap, tokenCss, themeCss, themePrompt, editPrompt };
module.exports = { createDesigns, wrap, tokenCss, themeCss, themePrompt, editPrompt };
@@ -53,6 +53,9 @@ function createDesignApi(ctx) {
@@ -53,6 +53,9 @@ function createDesignApi(ctx) {
await ctx.designs.read(req.params.id, owner(req), keyOf(req)))));
await ctx.designs.read(req.params.id, owner(req), keyOf(req)))));
routes.post('/:id/theme', sameOrigin, safe(async (req, res) => priv(res, 200,
routes.post('/:id/theme', sameOrigin, safe(async (req, res) => priv(res, 200,
await ctx.designs.setTheme(req.params.id, (req.body || {}).themeId, owner(req), keyOf(req)))));
await ctx.designs.setTheme(req.params.id, (req.body || {}).themeId, owner(req), keyOf(req)))));
+ // Publish the working frame on openvibe.space (open item D8): the owner only; { name? } for a new site.
+ routes.post('/:id/publish', sameOrigin, safe(async (req, res) => priv(res, 200,
+ await ctx.designs.publish({ id: req.params.id, owner: owner(req), name: (req.body || {}).name || '', publisher: ctx.publisher }))));
routes.post('/:id/pick', sameOrigin, safe(async (req, res) => {
routes.post('/:id/pick', sameOrigin, safe(async (req, res) => {
const body = req.body || {};
const body = req.body || {};
const result = await ctx.designs.pick({ id: req.params.id, variantIndex: body.variantIndex,
const result = await ctx.designs.pick({ id: req.params.id, variantIndex: body.variantIndex,
@@ -126,7 +126,7 @@ function createPageRoutes(ctx) {
@@ -126,7 +126,7 @@ function createPageRoutes(ctx) {
try {
try {
const data = await ctx.designs.read(req.params.id, subjectOf(req), req.query.key);
const data = await ctx.designs.read(req.params.id, subjectOf(req), req.query.key);
const themes = (await catalog.list()).themes;
const themes = (await catalog.list()).themes;
- return page(res, 200, designView.canvas({ data, themes, editKey: req.query.key || '' }), req.viewer,
+ return page(res, 200, designView.canvas({ data, themes, editKey: req.query.key || '', viewer: subjectOf(req), publishing: { enabled: Boolean(ctx.publisher && ctx.publisher.enabled), notice: req.query.published ? 'Published.' : null, error: req.query.publish_error ? String(req.query.publish_error).slice(0, 300) : null } }), req.viewer,
{ cacheControl: 'private, no-store' });
{ cacheControl: 'private, no-store' });
} catch (err) {
} catch (err) {
return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not read the design.');
return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not read the design.');
@@ -165,6 +165,18 @@ function createPageRoutes(ctx) {
@@ -165,6 +165,18 @@ function createPageRoutes(ctx) {
return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not edit that frame.');
return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not edit that frame.');
}
}
});
});
+ // Publish the working frame on openvibe.space (open item D8): the owner, signed in, from openvibe.design itself.
+ r.post('/d/:id/publish', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ const back = (q) => res.redirect(303, `/d/${encodeURIComponent(req.params.id)}?${q}#dv-publish`);
+ try {
+ await ctx.designs.publish({ id: req.params.id, owner: subjectOf(req), name: req.body.name || '', publisher: ctx.publisher });
+ return back('published=1');
+ } catch (err) {
+ if (!err.status) throw err;
+ return back(`publish_error=${encodeURIComponent(err.detail || 'That did not publish.')}`);
+ }
+ });
for (const action of ['undo', 'redo']) {
for (const action of ['undo', 'redo']) {
r.post(`/d/:id/${action}`, express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
r.post(`/d/:id/${action}`, express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
@@ -14,7 +14,7 @@ const TABLES = [
@@ -14,7 +14,7 @@ const TABLES = [
{ table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },
{ table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },
{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },
{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },
// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.
// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.
- { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'created_at', 'updated_at'] },
+ { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'space_url', 'published_at', 'created_at', 'updated_at'] },
// The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved
// The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved
// drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.
// drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.
// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).
// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+
+/**
+ * Publish a design on openvibe.space (open item D8): the working frame becomes a static site at
+ * <name>.openvibe.space, in the person's own Space project "Designs", deployed and live.
+ *
+ * Design calls Space's API with its own Network client-credentials token (audience openvibe.space; Network grants
+ * space.site.manage and space.deploy.create) and names the person it acts for with X-OV-Subject, so the project and the
+ * site are the person's, under their own Space quotas and visible on their Space dashboard. Publishing again uploads
+ * the frame as it is now to the same site (a new deploy; Space keeps the earlier ones for a rollback).
+ *
+ * publish({ design, html, subject, name }) → { url, siteId, deployId, created }
+ * NAME_RE, slugFor(prompt)
+ *
+ * The page is exactly the frame's document (designs.js wrap(): sanitized, no scripts, the theme's tokens inlined), as
+ * index.html. Nothing else is uploaded.
+ */
+
+const { serviceAuth } = require('openvibe-contracts');
+
+// Space's site names: 3–40 of a–z, 0–9 and single hyphens (Space server/domain/sites.js NAME_RE).
+const NAME_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$/;
+const PROJECT_NAME = 'Designs';
+
+class PublishError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; this.detail = detail; }
+}
+
+/** A site name from the design's prompt: its first words, lowercased, hyphenated, then a short tail so it is free. */
+function slugFor(prompt, tail = '') {
+ const words = String(prompt || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
+ .replace(/[^a-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean).slice(0, 4).join('-').slice(0, 30).replace(/-+$/, '');
+ const base = words.length >= 3 ? words : `design${words ? `-${words}` : ''}`;
+ return tail ? `${base}-${tail}`.slice(0, 40).replace(/-+$/, '') : base;
+}
+
+function createPublisher({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const space = config.space || {};
+ const tokenClient = config.oauth && config.oauth.clientSecret && space.url
+ ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: space.audience || 'openvibe.space',
+ scope: space.scope || 'space.site.manage space.deploy.create',
+ fetchImpl,
+ })
+ : null;
+ const enabled = Boolean(tokenClient);
+
+ async function call(subject, method, path, { json, form } = {}) {
+ const headers = { Accept: 'application/json', 'X-OV-Subject': subject, ...(await tokenClient.authHeaders()) };
+ if (json !== undefined) headers['Content-Type'] = 'application/json';
+ let res;
+ try {
+ res = await fetchImpl(`${space.url.replace(/\/+$/, '')}/api/v1${path}`, {
+ method, headers, body: json !== undefined ? JSON.stringify(json) : form, signal: AbortSignal.timeout(space.timeoutMs || 30_000),
+ });
+ } catch (err) {
+ throw new PublishError(503, 'publish.unavailable', 'openvibe.space did not answer. Try again in a moment.');
+ }
+ const body = await res.json().catch(() => ({}));
+ if (res.status === 401) tokenClient.invalidate();
+ return { status: res.status, body };
+ }
+ const problem = (r, what) => {
+ const detail = (r.body && (r.body.detail || r.body.title || r.body.message)) || `openvibe.space answered ${r.status}`;
+ log.warn(`[Design] publish: ${what} ${r.status} ${r.body && r.body.code ? r.body.code : ''}`);
+ return new PublishError(r.status >= 500 ? 502 : r.status, (r.body && r.body.code) || 'publish.refused', `${what}: ${detail}`);
+ };
+
+ /** The person's "Designs" project on Space, made the first time. */
+ async function project(subject) {
+ const list = await call(subject, 'GET', '/projects');
+ if (list.status !== 200) throw problem(list, 'Reading your Space projects');
+ const found = (list.body.projects || []).find((p) => p && p.name === PROJECT_NAME && p.owner === subject && p.environment !== 'sandbox');
+ if (found) return found;
+ const made = await call(subject, 'POST', '/projects', { json: { name: PROJECT_NAME } });
+ if (made.status !== 201) throw problem(made, 'Making your Designs project on Space');
+ return made.body.project;
+ }
+
+ /** The design's site: the one it was published to before, or a new one (the name asked for, else from the prompt). */
+ async function site(subject, design, name) {
+ if (design.space_site_id) {
+ const had = await call(subject, 'GET', `/sites/${encodeURIComponent(design.space_site_id)}`);
+ if (had.status === 200 && had.body.site) return { site: had.body.site, created: false };
+ }
+ const p = await project(subject);
+ const wanted = name ? String(name).trim().toLowerCase() : '';
+ if (wanted && !NAME_RE.test(wanted)) throw new PublishError(422, 'publish.invalid_name', 'A site name is 3–40 of a–z, 0–9 and single hyphens.');
+ const tries = wanted ? [wanted] : [slugFor(design.prompt), slugFor(design.prompt, design.id.slice(-4).toLowerCase().replace(/[^a-z0-9]/g, 'x')), slugFor(design.prompt, Math.random().toString(36).slice(2, 6))];
+ for (const n of tries) {
+ const made = await call(subject, 'POST', `/projects/${encodeURIComponent(p.id)}/sites`, { json: { name: n } });
+ if (made.status === 201) return { site: made.body.site, created: true };
+ if (made.status === 409 && !wanted) continue;
+ if (made.status === 409) throw new PublishError(409, 'publish.name_taken', `${wanted}.openvibe.space is taken. Pick another name.`);
+ throw problem(made, 'Making the site on Space');
+ }
+ throw new PublishError(409, 'publish.name_taken', 'Every name tried was taken. Type a name for the site.');
+ }
+
+ async function publish({ design, html, subject, name = '' }) {
+ if (!enabled) throw new PublishError(503, 'publish.off', 'Publishing to openvibe.space is not set up on this server.');
+ if (!subject || !/^usr_/.test(String(subject))) throw new PublishError(401, 'auth.required', 'Sign in to publish a design on openvibe.space.');
+ if (!html) throw new PublishError(409, 'publish.no_frame', 'Pick a frame first: the working frame is what gets published.');
+ const { site: s, created } = await site(subject, design, name);
+ const form = new FormData();
+ form.append('files', new Blob([html], { type: 'text/html' }), 'index.html');
+ form.append('activate', '1');
+ const dep = await call(subject, 'POST', `/sites/${encodeURIComponent(s.id)}/deploys?activate=1`, { form });
+ if (dep.status !== 201) throw problem(dep, 'Uploading the page to Space');
+ return { url: dep.body.url || `https://${s.name}.openvibe.space`, siteId: s.id, siteName: s.name, deployId: dep.body.deploy && dep.body.deploy.id, created };
+ }
+
+ return { publish, enabled };
+}
+
+module.exports = { createPublisher, slugFor, NAME_RE, PublishError, PROJECT_NAME };
@@ -66,7 +66,30 @@ const versionDetail = (v) => v.kind === 'chat' && v.instruction
@@ -66,7 +66,30 @@ const versionDetail = (v) => v.kind === 'chat' && v.instruction
? `<span class="dv-fine">${esc((v.change.path || '').replace(/^body > /, ''))}${v.change.text !== undefined ? ' · text' : ''}${v.change.color ? ' · colour' : ''}${v.change.spacing ? ' · spacing' : ''}${v.change.align ? ' · alignment' : ''}</span>`
? `<span class="dv-fine">${esc((v.change.path || '').replace(/^body > /, ''))}${v.change.text !== undefined ? ' · text' : ''}${v.change.color ? ' · colour' : ''}${v.change.spacing ? ' · spacing' : ''}${v.change.align ? ' · alignment' : ''}</span>`
: '';
: '';
-function canvas({ data, themes = [], editKey = '' }) {
+/**
+ * Publish on openvibe.space (open item D8): the working frame as a site at <name>.openvibe.space, in the person's own
+ * Space project. Only the owner, signed in, sees the form; a guest's private link is told to sign in.
+ */
+function publishPanel({ design, pick, viewer, editKey, publishing = {} }) {
+ const published = design.published;
+ const mine = Boolean(viewer) && !editKey;
+ const head = `<summary>Publish <span>${published ? 'live' : 'on openvibe.space'}</span></summary>`;
+ const note = publishing.notice ? `<p class="dv-ok" role="status">${esc(publishing.notice)} <a href="${esc(published ? published.url : '#')}" target="_blank" rel="noopener">${esc(published ? published.url.replace(/^https:\/\//, '') : '')}</a></p>` : '';
+ const err = publishing.error ? `<p class="dv-err" role="alert">${esc(publishing.error)}</p>` : '';
+ let inner;
+ if (!publishing.enabled) inner = '<p class="dv-fine">Publishing is not set up on this server.</p>';
+ else if (!mine) inner = '<p class="dv-fine">Sign in as the person who made this design to publish it as a site on openvibe.space.</p>';
+ else if (!pick) inner = '<p class="dv-fine">Pick a frame first: the working frame is what gets published.</p>';
+ else inner = `${published ? `<p class="dv-live">Live at <a href="${esc(published.url)}" target="_blank" rel="noopener">${esc(published.url.replace(/^https:\/\//, ''))}</a></p>` : ''}
+<form method="post" action="/d/${encodeURIComponent(design.id)}/publish" class="dv-publish-form">
+${published ? '' : '<label class="dv-publish-name"><span>Site name <small>(optional; a–z, 0–9 and hyphens)</small></span><span class="dv-publish-addr"><input name="name" maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){2,39}" autocapitalize="none" autocomplete="off" spellcheck="false" placeholder="my-landing-page"><span>.openvibe.space</span></span></label>'}
+<button type="submit">${published ? 'Publish this version' : 'Publish on openvibe.space'}</button>
+</form>
+<p class="dv-fine">The working frame becomes the site's page, in a project called Designs on your Space account. Publishing again updates the same address; Space keeps the earlier versions for a rollback.</p>`;
+ return `<details class="dv-publish" id="dv-publish"${publishing.notice || publishing.error || published ? ' open' : ''}>${head}${note}${err}${inner}</details>`;
+}
+
+function canvas({ data, themes = [], editKey = '', viewer = null, publishing = { enabled: false } }) {
const { design, variants, pick, versions = [] } = data;
const { design, variants, pick, versions = [] } = data;
const selectedTheme = themes.find(t => t.id === design.themeId) || themes[0] || { variables: {} };
const selectedTheme = themes.find(t => t.id === design.themeId) || themes[0] || { variables: {} };
const variables = selectedTheme.variables || {};
const variables = selectedTheme.variables || {};
@@ -125,6 +148,7 @@ ${keyInput}
@@ -125,6 +148,7 @@ ${keyInput}
<p class="dv-fine">${pick ? `${chatLeft} of ${pick.chatEditLimit} AI edits left on this design. Click edits and undo are free.` : 'Chat edits work on the picked frame.'}</p>
<p class="dv-fine">${pick ? `${chatLeft} of ${pick.chatEditLimit} AI edits left on this design. Click edits and undo are free.` : 'Chat edits work on the picked frame.'}</p>
</form>
</form>
<div class="dv-rail" id="dv-rail">${cards}</div>
<div class="dv-rail" id="dv-rail">${cards}</div>
+${publishPanel({ design, pick, viewer, editKey, publishing })}
<details class="dv-versions" id="dv-versions" open>
<details class="dv-versions" id="dv-versions" open>
<summary>Versions <span>${versions.length}</span></summary>
<summary>Versions <span>${versions.length}</span></summary>
<div class="dv-vcontrols">
<div class="dv-vcontrols">
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
+'use strict';
+
+/**
+ * Publish a design on openvibe.space (open item D8). Two halves:
+ * publish.js against a fake Space: Design's service token acts for the person (X-OV-Subject), the person's
+ * "Designs" project is made once, the site is named from the prompt (or the name asked for), a taken name tries
+ * another, the working frame is uploaded as index.html and activated, and publishing again reuses the site.
+ * The routes: the owner publishes from the canvas (and the API); a guest's private link and another person cannot;
+ * nothing publishes before a frame is picked; the canvas then says where it is live.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { createPublisher, slugFor, NAME_RE } = require('../server/publish');
+
+const PERSON = 'usr_01JZ00000000000000000PUB01';
+
+/** A fake Space API: projects, sites (names global), deploys; records each call. */
+function fakeSpace({ taken = [] } = {}) {
+ const calls = [];
+ const projects = [];
+ const sites = [];
+ const names = new Set(taken);
+ const reply = (status, body) => ({ status, ok: status >= 200 && status < 300, headers: new Headers({ 'content-type': 'application/json' }), json: async () => body, text: async () => JSON.stringify(body) });
+ const fetchImpl = async (url, init = {}) => {
+ const u = new URL(url);
+ if (u.pathname === '/oauth/token') return reply(200, { access_token: 'svc-design-token', token_type: 'Bearer', expires_in: 300 });
+ const headers = init.headers || {};
+ const call = { method: init.method || 'GET', path: u.pathname, query: u.search, subject: headers['X-OV-Subject'], auth: headers.Authorization };
+ calls.push(call);
+ if (call.path === '/api/v1/projects' && call.method === 'GET') return reply(200, { projects: projects.filter((p) => p.owner === call.subject) });
+ if (call.path === '/api/v1/projects' && call.method === 'POST') {
+ const p = { id: `prj_${projects.length + 1}`, name: JSON.parse(init.body).name, owner: call.subject, environment: 'production' };
+ projects.push(p);
+ return reply(201, { project: p });
+ }
+ let m = /^\/api\/v1\/projects\/([^/]+)\/sites$/.exec(call.path);
+ if (m && call.method === 'POST') {
+ const name = JSON.parse(init.body).name;
+ if (names.has(name)) return reply(409, { code: 'site.name_taken', detail: 'taken' });
+ names.add(name);
+ const s = { id: `site_${sites.length + 1}`, name, project_id: m[1] };
+ sites.push(s);
+ return reply(201, { site: s });
+ }
+ m = /^\/api\/v1\/sites\/([^/]+)$/.exec(call.path);
+ if (m && call.method === 'GET') { const s = sites.find((x) => x.id === m[1]); return s ? reply(200, { site: s }) : reply(404, { code: 'site.not_found' }); }
+ m = /^\/api\/v1\/sites\/([^/]+)\/deploys$/.exec(call.path);
+ if (m && call.method === 'POST') {
+ const files = [];
+ for (const [k, v] of init.body.entries()) if (k === 'files') files.push({ name: v.name, text: await v.text() });
+ call.files = files;
+ const s = sites.find((x) => x.id === m[1]);
+ return reply(201, { deploy: { id: `dep_${calls.length}` }, activated: true, url: `https://${s.name}.openvibe.space` });
+ }
+ return reply(404, { code: 'not_found' });
+ };
+ return { calls, fetchImpl, projects, sites };
+}
+
+const config = (extra = {}) => ({ networkInternalUrl: 'http://network.test', oauth: { clientId: 'design', clientSecret: 'design-secret' }, space: { url: 'http://space.test', audience: 'openvibe.space', scope: 'space.site.manage space.deploy.create' }, ...extra });
+const quiet = { log() {}, warn() {}, error() {} };
+
+(async () => {
+ await check('site names: from the prompt, valid for Space', async () => {
+ assert.equal(slugFor('A coffee roaster landing page!'), 'a-coffee-roaster-landing');
+ assert.equal(slugFor('Hi'), 'design-hi');
+ assert.equal(slugFor('Café ☕ menu', 'ab12'), 'cafe-menu-ab12');
+ ['a-coffee-roaster-landing', 'design-hi', 'cafe-menu-ab12'].forEach((n) => assert.ok(NAME_RE.test(n), n));
+ });
+
+ await check('the first publish makes the person\'s Designs project and a site, uploads the frame and activates it', async () => {
+ const space = fakeSpace({ taken: ['a-coffee-roaster-landing'] });
+ const pub = createPublisher({ config: config(), fetchImpl: space.fetchImpl, log: quiet });
+ const design = { id: 'des_01ABCDEFGH', prompt: 'A coffee roaster landing page' };
+ const out = await pub.publish({ design, html: '<!doctype html><html><body><h1>Roast</h1></body></html>', subject: PERSON });
+ assert.ok(out.created);
+ assert.equal(out.url, 'https://a-coffee-roaster-landing-efgh.openvibe.space', 'a taken name tries the next one');
+ assert.ok(space.calls.every((c) => c.subject === PERSON && c.auth === 'Bearer svc-design-token'), 'Design\'s token, acting for the person');
+ assert.deepStrictEqual(space.projects.map((p) => [p.name, p.owner]), [['Designs', PERSON]]);
+ const dep = space.calls.find((c) => c.files);
+ assert.match(dep.query, /activate=1/);
+ assert.deepStrictEqual(dep.files.map((f) => f.name), ['index.html']);
+ assert.match(dep.files[0].text, /<h1>Roast<\/h1>/);
+
+ // Again: the same site, a new deploy, no new project.
+ const again = await pub.publish({ design: { ...design, space_site_id: out.siteId }, html: '<!doctype html><html><body><h1>Roast 2</h1></body></html>', subject: PERSON });
+ assert.equal(again.siteId, out.siteId);
+ assert.equal(again.created, false);
+ assert.equal(space.projects.length, 1);
+ assert.equal(space.sites.length, 1);
+ });
+
+ await check('a name asked for is used, or refused plainly', async () => {
+ const space = fakeSpace({ taken: ['taken-name'] });
+ const pub = createPublisher({ config: config(), fetchImpl: space.fetchImpl, log: quiet });
+ const d = { id: 'des_02', prompt: 'x' };
+ assert.equal((await pub.publish({ design: d, html: '<p>x</p>', subject: PERSON, name: 'my-page' })).url, 'https://my-page.openvibe.space');
+ await assert.rejects(pub.publish({ design: d, html: '<p>x</p>', subject: PERSON, name: 'taken-name' }), (e) => e.code === 'publish.name_taken' && /taken-name\.openvibe\.space is taken/.test(e.detail));
+ await assert.rejects(pub.publish({ design: d, html: '<p>x</p>', subject: PERSON, name: 'Bad_Name' }), (e) => e.code === 'publish.invalid_name');
+ await assert.rejects(pub.publish({ design: d, html: '', subject: PERSON }), (e) => e.code === 'publish.no_frame');
+ await assert.rejects(pub.publish({ design: d, html: '<p>x</p>', subject: null }), (e) => e.code === 'auth.required');
+ });
+
+ await check('without its secret the publisher is off and says so', async () => {
+ const pub = createPublisher({ config: config({ oauth: { clientId: 'design', clientSecret: '' } }), fetchImpl: async () => { throw new Error('no call'); }, log: quiet });
+ assert.equal(pub.enabled, false);
+ await assert.rejects(pub.publish({ design: { id: 'd', prompt: 'p' }, html: '<p></p>', subject: PERSON }), (e) => e.code === 'publish.off');
+ });
+
+ // ── The routes, with an in-process fake publisher ──
+ const published = [];
+ const publisher = { enabled: true, publish: async ({ design, html, subject, name }) => { published.push({ id: design.id, html, subject, name }); return { url: `https://${name || 'from-prompt'}.openvibe.space`, siteId: 'site_9', siteName: name || 'from-prompt', created: true }; } };
+ const ai = {
+ slotPlan: async () => ({ slots: [{ provider: 'alpha', model: 'one' }, { provider: 'beta', model: 'two' }] }),
+ generate: async ({ slot }) => ({ html: '<!doctype html><html><body><h1>Shop</h1></body></html>', provider: slot.provider, model: slot.model, cost_usd: 0.001, ms: 10 }),
+ };
+ const t = await boot({ ai, publisher });
+ const kim = t.network.addUser('kim-pub');
+ const bob = t.network.addUser('bob-pub');
+ const POST = { origin: 'https://openvibe.design' };
+ const formPost = (fields, extra = {}) => ({ method: 'POST', body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...extra });
+ try {
+ const made = await t.get('/api/v1/designs', { ...POST, as: kim, json: { prompt: 'A shop page', kind: 'page' } });
+ assert.equal(made.status, 201, made.text);
+ const id = made.json().design.id;
+ for (let i = 0; i < 80; i++) { if ((await t.get(`/api/v1/designs/${id}`, { as: kim })).json().design.status === 'done') break; await new Promise((r) => setTimeout(r, 20)); }
+
+ await check('nothing publishes before a frame is picked; the canvas says to pick one', async () => {
+ const page = await t.get(`/d/${id}`, { as: kim });
+ assert.match(page.text, /id="dv-publish"/);
+ assert.match(page.text, /Pick a frame first: the working frame is what gets published\./);
+ });
+
+ await check('the owner publishes from the canvas; the canvas then says where it is live', async () => {
+ assert.equal((await t.get(`/api/v1/designs/${id}/pick`, { ...POST, as: kim, json: { variantIndex: 0 } })).status, 201);
+ const form = await t.get(`/d/${id}/publish`, formPost({ name: 'kims-shop' }, { ...POST, as: kim }));
+ assert.equal(form.status, 303, form.text);
+ assert.match(form.headers.get('location'), /published=1#dv-publish$/);
+ assert.deepStrictEqual([published[0].subject, published[0].name], [kim.subject, 'kims-shop']);
+ assert.match(published[0].html, /<h1>Shop<\/h1>/, 'the working frame');
+ const page = await t.get(`/d/${id}?published=1`, { as: kim });
+ assert.match(page.text, /Live at <a href="https:\/\/kims-shop\.openvibe\.space"/);
+ assert.match(page.text, />Publish this version</);
+ const api = (await t.get(`/api/v1/designs/${id}`, { as: kim })).json();
+ assert.equal(api.design.published.url, 'https://kims-shop.openvibe.space');
+ });
+
+ await check('another person cannot publish it; a cross-site form is refused', async () => {
+ const n = published.length;
+ const other = await t.get(`/api/v1/designs/${id}/publish`, { ...POST, as: bob, json: {} });
+ assert.ok([403, 404].includes(other.status), other.text);
+ const cross = await t.get(`/d/${id}/publish`, formPost({}, { origin: 'https://evil.example', as: kim }));
+ assert.equal(cross.status, 403);
+ assert.equal(published.length, n);
+ });
+ } finally { await t.close(); }
+ done();
+})();
@@ -47,7 +47,7 @@ async function boot(opts = {}) {
@@ -47,7 +47,7 @@ async function boot(opts = {}) {
const config = configLib.load(env);
const config = configLib.load(env);
const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });
const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });
const store = opts.store || createStore(testdb.db, { now: opts.now });
const store = opts.store || createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai });
+ const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai, publisher: opts.publisher });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;