openvibers/openvibe.design Commits

Publish a design on openvibe.space (open item D8) (#137)

OP OpenVibers committed 11 days ago · 62d6fc1e03 · 12 files, +372 −7

The working frame becomes a static site at <name>.openvibe.space, in the person's own Space project
'Designs': the canvas's Publish panel (and POST /api/v1/designs/:id/publish) makes the project once, names
the site from the prompt (or the name typed; a taken name tries another), uploads the frame as index.html
and activates it. Publishing again updates the same site (Space keeps the earlier deploys for a rollback).
Design's service token acts for the person (X-OV-Subject; Network grants space.site.manage and
space.deploy.create), so the site is theirs and under their quotas. The owner only, signed in, after a
frame is picked; a guest's private link is told to sign in.

- server/publish.js; migration 0011 (space_site_id, space_url, published_at; expand)
- config.space (OV_SPACE_INTERNAL_URL, default 127.0.0.1:4940)
- the design's public row says where it is live; account data exports space_url and published_at

Changes

ddiff --git a/migrations/0011_space_publish.sql b/migrations/0011_space_publish.sql
nnew file mode 100644
iindex 0000000..f13e4d5
---- /dev/null
++++ b/migrations/0011_space_publish.sql
@@@ -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;
ddiff --git a/public/css/design.css b/public/css/design.css
iindex a944dff..ddbf958 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -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); }
ddiff --git a/server/app.js b/server/app.js
iindex a0da3fe..25084d4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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,
};
ddiff --git a/server/config.js b/server/config.js
iindex 65a88ee..73ebc2c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/designs.js b/server/designs.js
iindex ec2936f..e605e6e 100644
---- a/server/designs.js
++++ b/server/designs.js
@@@ -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,
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 = 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");
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 };
ddiff --git a/server/http/design-api.js b/server/http/design-api.js
iindex e189e98..dbed7a9 100644
---- a/server/http/design-api.js
++++ b/server/http/design-api.js
@@@ -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,
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 48e34d2..f2c5b59 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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.');
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 45263ff..233004f 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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).
ddiff --git a/server/publish.js b/server/publish.js
nnew file mode 100644
iindex 0000000..7680525
---- /dev/null
++++ b/server/publish.js
@@@ -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 };
ddiff --git a/server/views/design.js b/server/views/design.js
iindex 1f9c5ab..8513a68 100644
---- a/server/views/design.js
++++ b/server/views/design.js
@@@ -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">
ddiff --git a/test/design-publish.test.js b/test/design-publish.test.js
nnew file mode 100644
iindex 0000000..e48314b
---- /dev/null
++++ b/test/design-publish.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex aa557d0..a9873bd 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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}`;