openvibers/openvibe.space Commits

Space: the official example spaces (link page, portfolio, community night) (#217)

OP OpenVibers committed 6 days ago · b8e4218b4f · 10 files, +682 −2

* Space: the official example spaces (link page, portfolio, community night)

The plan's §4 Space next step is official example spaces so the gallery has real items.
Three published examples, owned by the official OpenVibe account, adapted from the
Space kit's site-links, site-portfolio and site-event templates:

- ov-links: every OpenVibe site in one page, grouped and searchable.
- ov-portfolio: what the network is made of, with each project linked.
- ov-night: the monthly community night, the next date computed in the page, an .ics
  download and a live RSVP list (OV.items.set, so one answer per visitor).

server/spaces/official.js creates and publishes them through the service's own modules;
scripts/seed-official-spaces.js is the runnable entry. It is idempotent (a second run
says unchanged), --revise repairs drift, and a slug somebody else holds is reported and
left untouched. spaces.create now accepts an explicit slug with the same checks a
generated one gets.

* Space: a revised official example is deployed again

--revise saved changed files but only published a space that had never been
published, so a fix to a live example stayed in the database. Compare the site's
active deploy with the seed (spaces.publishedFiles) and publish when it differs,
which also repairs a run that saved and then died before deploying. The event
page's date no longer repeats the time (the facts row has it) and fits one line
at 390 px.

* Space: the official example seed heals a missing site

Check the site's active deploy on every run, not only with --revise: a space whose
site was removed is published again, and a healthy one still reports unchanged.
Covered by the new check in test/official-spaces.test.js.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 050d1e8..275f6d0 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,7 @@
## Unreleased
++- **Official example spaces** (plan §4 Space: "Next: official example spaces"): three published examples owned by the official OpenVibe account — a link page (`ov-links`), a portfolio (`ov-portfolio`) and a monthly community night with a live RSVP list (`ov-night`) — adapted from the Space kit's `site-links`, `site-portfolio` and `site-event` templates and kept in `seeds/official-spaces/`. `node scripts/seed-official-spaces.js` creates and publishes them through the service's own modules (`server/spaces/official.js`); it is idempotent, `--revise` repairs drift, and a slug somebody else holds is reported and never taken. `spaces.create` now accepts an explicit `slug` (same checks as a generated one). `test/official-spaces.test.js`.
- **Share:** a space's page carries the network's one Share button (`data-ov-share-sheet`) in its actions; it shares the published space's own address (or the page's address for a draft), with its name and description. `test/spaces.test.js`.
- **Guest drafts** (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins"): a visitor who is not signed in makes a draft on openvibe.space (`GET/POST /spaces/guest`) from a website template or a sentence, and edits it with a secret link (`/spaces/<id>/edit?key=…`; the cookie only remembers it, the link alone works). The draft has no owner and of the secret only its sha256 is kept. It is static: never published, no visitor's data, no server functions; in the preview the SDK answers "sign in to use this" where a visitor's account would be needed. A signed-in person who holds the secret claims it (`POST /spaces/<id>/claim`): it becomes theirs, the secret is cleared, and `space.app.claimed` is logged. The limits (per address, per draft, per service) and the seven-day expiry are in `server/hosting-config.js`; the abuse screen (`server/spaces/content-check.js`) refuses a phishing page and any form in a guest draft that posts to another origin.
- Usage readings (plan T5 step 14, Space slice): `server/billing.js` turns the bytes the local object store keeps (`space_blobs`, each blob once per project) into one `platform.usage-sample@1` `gb-month` reading per production project and owner per closed UTC day, queued in `billing_readings` (migration `0006_billing_readings.sql`) and relayed to OpenVibe.Billing's `billing.usage.record`. Off unless `SPACE_BILLING_INTERVAL_MS` > 0; with `SPACE_OBJECT_STORE=media` Space emits none, because Media bills the stored bytes.
ddiff --git a/docs/spaces.md b/docs/spaces.md
iindex c3e0bda..bd4c2ae 100644
---- a/docs/spaces.md
++++ b/docs/spaces.md
@@@ -21,6 +21,25 @@ The preview runs the space in a sandboxed frame (`srcdoc`, an opaque origin with
space's code never acts as openvibe.space. Its SDK calls are postMessages to the editor, which runs them as the
signed-in maker (`POST /spaces/:slug/preview`).
++## Official examples
++
++Three published spaces show what a space can be, owned by the official OpenVibe account: a link page
++(`ov-links.openvibe.website`), a portfolio (`ov-portfolio.openvibe.website`) and a monthly community night with a
++live RSVP list (`ov-night.openvibe.website`). They are adaptations of the kit's `site-links`, `site-portfolio` and
++`site-event` templates, kept in `seeds/official-spaces/` with the list of what to create in `spaces.json`.
++
++They are created through the service's own modules, never by inserting rows:
++
++```bash
++node scripts/seed-official-spaces.js # create what is missing, publish what is not published
++node scripts/seed-official-spaces.js --revise # also rewrite files and settings that differ from the seed
++```
++
++The run is idempotent, uses the service's environment (`DATABASE_URL`, `SPACE_STORAGE_DIR`, …) and writes as
++`SPACE_OFFICIAL_SUBJECT` (default: the owner's account, `SPACE_OFFICIAL_USERNAME` its @name). A slug that belongs to
++someone else is reported and left alone. `server/spaces/official.js` holds the logic; the seed test is
++`test/official-spaces.test.js`.
++
## Publishing
Publishing deploys the files to a site named after the space's slug, in its maker's **Spaces** project. The space is
ddiff --git a/scripts/seed-official-spaces.js b/scripts/seed-official-spaces.js
nnew file mode 100644
iindex 0000000..0c765e1
---- /dev/null
++++ b/scripts/seed-official-spaces.js
@@@ -0,0 +1,50 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * Create and publish the official example spaces (plan §4 Space) through Space's own modules:
++ *
++ * node scripts/seed-official-spaces.js # create what is missing; publish what is not published
++ * node scripts/seed-official-spaces.js --revise # also rewrite files and settings that differ from the seed
++ *
++ * The seeds are seeds/official-spaces/ (spaces.json and one index.html each), adaptations of the Space kit's
++ * site-links, site-portfolio and site-event templates. Ownership follows the official OpenVibe account, the
++ * signed-in staff account the other official content uses; it is never inferred from a request.
++ *
++ * SPACE_OFFICIAL_SUBJECT the account's usr_… subject (default: the owner's account)
++ * SPACE_OFFICIAL_USERNAME its @name (default: goosely)
++ *
++ * Runs with the service's environment (DATABASE_URL, DATABASE_DIRECT_URL, SPACE_STORAGE_DIR, …); in development
++ * without DATABASE_URL it uses the embedded PGlite database. Writes are idempotent: running it twice changes
++ * nothing.
++ */
++require('dotenv').config();
++const express = require('express');
++const config = require('../server/config');
++const { load } = require('../server/hosting-config');
++const { openDb } = require('../server/db');
++const { createHosting } = require('../server/hosting');
++const { seedOfficialSpaces } = require('../server/spaces/official');
++
++const OFFICIAL = {
++ subject: process.env.SPACE_OFFICIAL_SUBJECT || 'usr_01KKT9AC60KM7CRTB3WN1Z8P56',
++ username: process.env.SPACE_OFFICIAL_USERNAME || 'goosely',
++};
++
++(async () => {
++ const hostingConfig = load(process.env);
++ const db = await openDb(config);
++ const app = express();
++ app.locals.metrics = null;
++ createHosting(app, { db, auth: { verify: async () => null, keys: { loaded: () => false } }, config: hostingConfig, log: console });
++ const viewer = { kind: 'user', subject: OFFICIAL.subject, staff: true, user: { username: OFFICIAL.username, display_name: OFFICIAL.username } };
++ try {
++ const results = await seedOfficialSpaces({ spaces: app.locals.ctx.spaces, viewer, log: console, revise: process.argv.includes('--revise') });
++ for (const r of results) console.log(`[official-spaces] ${r.action.padEnd(9)} ${r.slug}${r.url ? ` → ${r.url}` : ''}`);
++ const failed = results.filter((r) => r.action === 'conflict').length;
++ console.log(`[official-spaces] ${results.length} seed(s), ${failed} conflict(s)`);
++ if (failed) process.exitCode = 1;
++ } finally {
++ await db.close();
++ }
++})().catch((err) => { console.error('[official-spaces] failed:', err && err.stack || err); process.exit(1); });
ddiff --git a/seeds/official-spaces/event.html b/seeds/official-spaces/event.html
nnew file mode 100644
iindex 0000000..4b4a4d3
---- /dev/null
++++ b/seeds/official-spaces/event.html
@@@ -0,0 +1,172 @@
++<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>OpenVibe community night · every first Friday</title>
++<meta name="description" content="The monthly OpenVibe community night: what shipped, show and tell, a build table and an open floor. Free, online, first Friday at 19:00 UTC.">
++<style>
++ /* An official example space, built from the Space kit's "Event page" template (site-event.html). */
++ :root { --bg: #f7f8fb; --card: #ffffff; --ink: #141826; --muted: #626b7e; --line: #dde2ec; --accent: #2563eb; --accent-text: #1c4fd0; --on-accent: #fff; --glow: #bfdbfe; }
++ @media (prefers-color-scheme: dark) { :root { --bg: #080b14; --card: #121828; --ink: #eef2fb; --muted: #a3adc2; --line: #232c40; --accent: #7dd3fc; --accent-text: #8ec5ff; --on-accent: #07101f; --glow: #1e3a8a; } }
++ * { box-sizing: border-box; }
++ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }
++ h1, h2 { line-height: 1.08; margin: 0; text-wrap: balance; letter-spacing: -.02em; }
++ .wrap { width: min(980px, 100% - 32px); margin-inline: auto; }
++ .hero { position: relative; overflow: hidden; padding-block: 72px 56px; text-align: center; }
++ .hero::before { content: ""; position: absolute; inset: -40% 10% auto; height: 120%; background: radial-gradient(closest-side, var(--glow), transparent); opacity: .7; z-index: -1; }
++ .lights { display: flex; justify-content: center; gap: 18px; margin-bottom: 22px; }
++ .lights i { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 14px var(--accent); }
++ .lights i:nth-child(2n) { background: #22d3ee; box-shadow: 0 0 14px #22d3ee; }
++ .date { font-weight: 800; font-size: clamp(.72rem, 3.2vw, .95rem); letter-spacing: .06em; line-height: 1.5; text-transform: uppercase; color: var(--accent-text); }
++ .hero h1 { font-size: clamp(2.6rem, 8vw, 5.2rem); margin: 10px 0 14px; }
++ .hero p { color: var(--muted); font-size: 1.15rem; margin: 0 auto 26px; max-width: 50ch; }
++ .facts { display: flex; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 26px; }
++ .facts span { padding: 8px 14px; border-radius: 999px; background: var(--card); border: 1px solid var(--line); font-weight: 600; font-size: .95rem; }
++ .btn { display: inline-flex; align-items: center; justify-content: center; min-height: 48px; padding: 0 22px; border: 0; border-radius: 999px; background: var(--accent-text); color: var(--on-accent); font: 700 1rem system-ui, sans-serif; text-decoration: none; cursor: pointer; }
++ .btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); }
++ .btn:focus-visible, summary:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
++ .btn[disabled] { opacity: .6; cursor: progress; }
++ section { padding-block: 48px; }
++ h2 { font-size: clamp(1.6rem, 4vw, 2.2rem); margin-bottom: 20px; }
++ .timeline { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++ .timeline li { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 16px; padding: 14px 18px; background: var(--card); border: 1px solid var(--line); border-radius: 16px; }
++ .timeline time { font-weight: 800; color: var(--accent-text); font-variant-numeric: tabular-nums; }
++ .timeline b { display: block; }
++ .timeline span { color: var(--muted); font-size: .95rem; }
++ .rsvp { background: var(--card); border: 1px solid var(--line); border-radius: 22px; padding: 24px; display: grid; gap: 16px; }
++ .rsvp-top { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
++ .count { font-size: 1.1rem; font-weight: 700; }
++ .faces { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 0; padding: 0; }
++ .faces li { display: inline-flex; align-items: center; gap: 8px; padding: 4px 12px 4px 4px; border-radius: 999px; border: 1px solid var(--line); font-size: .92rem; }
++ .faces img { width: 28px; height: 28px; border-radius: 50%; background: var(--line); }
++ .muted { color: var(--muted); margin: 0; }
++ details { border-bottom: 1px solid var(--line); padding: 14px 0; }
++ summary { font-weight: 700; cursor: pointer; }
++ details p { margin: 8px 0 0; color: var(--muted); }
++ footer { padding-block: 32px; color: var(--muted); font-size: .9rem; text-align: center; }
++ @media (max-width: 560px) { .timeline li { grid-template-columns: 1fr; gap: 2px; } }
++</style>
++</head>
++<body>
++<main>
++ <div class="hero">
++ <div class="wrap">
++ <div class="lights" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div>
++ <span class="date" id="when">Every first Friday · 19:00 UTC</span>
++ <h1>OpenVibe community night</h1>
++ <p>Once a month, whoever builds on OpenVibe gets together in one room: what shipped, what broke, what's next. Free, online, no recording.</p>
++ <div class="facts"><span>19:00 – 21:00 UTC</span><span id="where">openvibe.chat · #community-night</span><span>Free</span></div>
++ <p><a class="btn ghost" id="calendar" href="#rsvp">Add to calendar</a></p>
++ </div>
++ </div>
++
++ <div class="wrap">
++ <section id="schedule">
++ <h2>The evening</h2>
++ <ol class="timeline">
++ <li><time>19:00</time><div><b>What shipped</b><span>Five minutes per site, from the people who shipped it.</span></div></li>
++ <li><time>19:25</time><div><b>Show and tell</b><span>Two minutes each: something you made on OpenVibe this month.</span></div></li>
++ <li><time>20:00</time><div><b>The build table</b><span>Bring one bug or one idea; we pick two and work them in the open.</span></div></li>
++ <li><time>20:45</time><div><b>Open floor</b><span>Questions, next month's host, and who is taking what away.</span></div></li>
++ </ol>
++ </section>
++
++ <section id="rsvp">
++ <h2>Who's coming</h2>
++ <div class="rsvp">
++ <div class="rsvp-top"><span class="count" id="count">Say you're coming so the host knows how many seats to open.</span><span id="act"><button class="btn" type="button" id="signin">Sign in to say you're coming</button></span></div>
++ <ul class="faces" id="faces"></ul>
++ </div>
++ </section>
++
++ <section id="questions">
++ <h2>Questions</h2>
++ <details><summary>Where does it happen?</summary><p>In the OpenVibe Chat server, in #community-night. The room opens 15 minutes before.</p></details>
++ <details><summary>Do I have to be a developer?</summary><p>No. If you use OpenVibe and have an opinion about it, you belong in the room.</p></details>
++ <details><summary>Is it recorded?</summary><p>No. Notes and decisions are written up afterwards on the blog.</p></details>
++ </section>
++ </div>
++</main>
++<footer>OpenVibe community night · Made with OpenVibe.Space</footer>
++<script src="/_openvibe/sdk.js"></script>
++<script>
++ // The next first Friday at 19:00 UTC, the calendar file for it, and the shared RSVP list
++ // ("rsvps": one item per visitor, so changing your mind replaces your answer).
++ const $ = (s) => document.querySelector(s);
++ function el(tag, props, ...kids) { const n = Object.assign(document.createElement(tag), props || {}); n.append(...kids); return n; }
++
++ function nextNight(from = new Date()) {
++ const d = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), 1, 19, 0, 0));
++ const first = (7 - d.getUTCDay() + 5) % 7; // 0 = Friday
++ d.setUTCDate(1 + first);
++ if (d.getTime() <= from.getTime()) {
++ d.setUTCMonth(d.getUTCMonth() + 1, 1);
++ d.setUTCDate(1 + (7 - d.getUTCDay() + 5) % 7);
++ }
++ return d;
++ }
++ const night = nextNight();
++ const stamp = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
++ const day = new Intl.DateTimeFormat('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
++ $('#when').textContent = day.format(night);
++ const end = new Date(night.getTime() + 2 * 3600 * 1000);
++ const ics = [
++ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//OpenVibe//Community night//EN', 'BEGIN:VEVENT',
++ 'UID:community-night-' + night.toISOString().slice(0, 10) + '@openvibe.space',
++ 'DTSTAMP:' + stamp(new Date()), 'DTSTART:' + stamp(night), 'DTEND:' + stamp(end),
++ 'SUMMARY:OpenVibe community night', 'DESCRIPTION:What shipped, show and tell, the build table and an open floor.',
++ 'LOCATION:https://openvibe.chat/', 'URL:https://openvibe.chat/', 'END:VEVENT', 'END:VCALENDAR',
++ ].join('\r\n');
++ $('#calendar').href = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(ics);
++ $('#calendar').setAttribute('download', 'openvibe-community-night.ics');
++
++ let going = [];
++ function render() {
++ const mine = OV.user && going.find((r) => r.mine);
++ const n = going.length;
++ $('#count').textContent = n ? n + (n === 1 ? ' person is coming' : ' people are coming') : 'Be the first to say you\'re coming.';
++ const act = $('#act'); act.replaceChildren();
++ if (!OV.user) {
++ const b = el('button', { className: 'btn', type: 'button', textContent: 'Sign in to say you\'re coming' });
++ b.onclick = () => OV.signIn();
++ act.append(b);
++ } else if (mine) {
++ act.append(el('span', { className: 'muted', textContent: 'You\'re on the list. See you on Friday!' }));
++ const b = el('button', { className: 'btn ghost', type: 'button', textContent: 'Can\'t make it' });
++ b.onclick = () => save(b, false);
++ act.append(' ', b);
++ } else {
++ const b = el('button', { className: 'btn', type: 'button', textContent: 'I\'m coming' });
++ b.onclick = () => save(b, true);
++ act.append(b);
++ }
++ $('#faces').replaceChildren(...going.slice(0, 60).map((r) => {
++ const img = el('img', { alt: '', loading: 'lazy', src: 'https://openvibe.network/avatar/' + encodeURIComponent(r.author.username) });
++ img.onerror = () => img.removeAttribute('src');
++ return el('li', {}, img, r.author.display_name);
++ }));
++ }
++ async function load() {
++ try {
++ const seen = new Set();
++ going = (await OV.items.list('rsvps', { limit: 200 })).filter((r) => !seen.has(r.author.username) && seen.add(r.author.username));
++ } catch { going = []; }
++ render();
++ }
++ async function save(button, coming) {
++ button.disabled = true;
++ try {
++ if (coming) await OV.items.set('rsvps', { at: new Date().toISOString() });
++ else { const mine = going.find((r) => r.mine); if (mine) await OV.items.remove('rsvps', mine.id); }
++ await load();
++ } catch (e) {
++ button.disabled = false;
++ $('#count').textContent = e.code === 'auth.required' ? 'Sign in first.' : e.message;
++ }
++ }
++ OV.on('user', load);
++ OV.ready.then(load);
++</script>
++</body>
++</html>
ddiff --git a/seeds/official-spaces/links.html b/seeds/official-spaces/links.html
nnew file mode 100644
iindex 0000000..ba0eac4
---- /dev/null
++++ b/seeds/official-spaces/links.html
@@@ -0,0 +1,96 @@
++<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>OpenVibe · links</title>
++<meta name="description" content="Every OpenVibe site in one place: streams, chat, games, tools and the places that write it all down.">
++<style>
++ /* An official example space, built from the Space kit's "Link in bio" template (site-links.html). */
++ :root { --from: #e9f7ff; --to: #eef0ff; --card: rgba(255, 255, 255, .76); --ink: #111827; --muted: #5b6478; --line: rgba(17, 24, 39, .1); --accent: #2563eb; --accent-ink: #0b3ea8; }
++ @media (prefers-color-scheme: dark) { :root { --from: #071a2b; --to: #101433; --card: rgba(255, 255, 255, .07); --ink: #eef4ff; --muted: #a9b6cf; --line: rgba(255, 255, 255, .13); --accent: #7dd3fc; --accent-ink: #a5e4ff; } }
++ * { box-sizing: border-box; }
++ body { margin: 0; min-height: 100vh; background: linear-gradient(160deg, var(--from), var(--to)) fixed; color: var(--ink); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; display: grid; justify-items: center; padding: 44px 16px 28px; }
++ main { width: min(560px, 100%); display: grid; gap: 20px; text-align: center; }
++ .face { width: 96px; height: 96px; margin: 0 auto; border-radius: 28px; display: grid; place-items: center; font: 800 2rem system-ui, sans-serif; letter-spacing: -.04em; color: #fff; background: conic-gradient(from 200deg, #22d3ee, #2563eb, #7c3aed, #22d3ee); box-shadow: 0 0 0 5px var(--card), 0 18px 40px -18px rgba(20, 50, 120, .55); }
++ h1 { margin: 0; font-size: 1.65rem; letter-spacing: -.02em; }
++ .bio { margin: 6px 0 0; color: var(--muted); }
++ .group { margin: 4px 0 0; font: 700 .72rem/1 system-ui, sans-serif; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); text-align: left; }
++ .links { display: grid; gap: 10px; }
++ .links a { display: flex; align-items: center; gap: 14px; min-height: 58px; padding: 10px 18px; border-radius: 18px; background: var(--card); border: 1px solid var(--line); color: inherit; text-decoration: none; font-weight: 650; text-align: left; backdrop-filter: blur(10px); transition: transform .15s ease, border-color .15s ease; }
++ .links a:hover { transform: translateY(-2px); border-color: var(--accent); }
++ .links a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
++ .links svg { width: 22px; height: 22px; flex: none; color: var(--accent); }
++ .links small { display: block; font-weight: 500; color: var(--muted); }
++ .links .tag { margin-left: auto; font-size: .7rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--accent-ink); white-space: nowrap; }
++ .social { display: flex; justify-content: center; gap: 10px; }
++ .social a { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 50%; background: var(--card); border: 1px solid var(--line); color: var(--ink); }
++ .social a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
++ .social svg { width: 20px; height: 20px; }
++ footer { color: var(--muted); font-size: .82rem; display: grid; gap: 4px; }
++ footer a { color: var(--accent-ink); }
++ @media (prefers-reduced-motion: reduce) { .links a { transition: none; } }
++</style>
++</head>
++<body>
++<main>
++ <div>
++ <div class="face" aria-hidden="true">OV</div>
++ <h1>OpenVibe</h1>
++ <p class="bio">One account for every site. Here is where each one lives.</p>
++ </div>
++
++ <nav class="links" aria-label="OpenVibe sites">
++ <p class="group">Watch and listen</p>
++ <a href="https://openvibe.live/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="3"/><path d="m10 9 5 3-5 3z" fill="currentColor"/></svg><span>Live<small>Streams, clips, recordings and The Rundown.</small></span><span class="tag">On air</span></a>
++ <a href="https://openvibe.video/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="m8 9 8 3-8 3z" fill="currentColor"/></svg><span>Video and pictures<small>Uploads, AI edits and every picture.</small></span></a>
++
++ <p class="group">Talk and gather</p>
++ <a href="https://openvibe.chat/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 5h16v11H9l-5 4z"/></svg><span>Chat<small>Servers, rooms, DMs and the global chat.</small></span></a>
++ <a href="https://openvibe.community/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3"/><path d="M3 20c0-3 2.7-5 6-5s6 2 6 5M16 7a3 3 0 0 1 0 6M18 20c0-2-1-4-3-5"/></svg><span>Community<small>Pastes, the forum, comments and Pulse.</small></span></a>
++ <a href="https://openvibe.today/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="16" rx="3"/><path d="M3 10h18M8 3v4M16 3v4"/></svg><span>Today<small>Events across the network, in your time zone.</small></span></a>
++
++ <p class="group">Play</p>
++ <a href="https://openvibe.games/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 8h10a5 5 0 0 1 4.6 7l-1 2a3 3 0 0 1-4.8 1L14 16h-4l-1.8 2a3 3 0 0 1-4.8-1l-1-2A5 5 0 0 1 7 8z"/><path d="M9 11v4M7 13h4"/></svg><span>Games<small>Scraplandia and worlds players build and publish.</small></span><span class="tag">Play</span></a>
++ <a href="https://openvibe.play/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 21h8M12 17v4M7 4h10v5a5 5 0 0 1-10 0z"/><path d="M17 6h3v2a3 3 0 0 1-3 3M7 6H4v2a3 3 0 0 0 3 3"/></svg><span>Play<small>Achievements and leaderboards for every game.</small></span></a>
++ <a href="https://openvibe.quest/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.4 6.8 19.1l1-5.8L3.5 9.2l5.9-.9z"/></svg><span>Quest<small>One quest log and badges across the sites.</small></span></a>
++
++ <p class="group">Build</p>
++ <a href="https://openvibe.space/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V9l8-5 8 5v11"/><path d="M9 20v-6h6v6"/></svg><span>Space<small>Small apps you make here, served at your own address.</small></span><span class="tag">New</span></a>
++ <a href="https://openvibe.tools/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 4a4 4 0 0 0-5 5L4 14v6h6l5-5a4 4 0 0 0 5-5l-3 3-2-2z"/></svg><span>Tools<small>Small tools that run in your browser.</small></span></a>
++ <a href="https://openvibe.services/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 8-4 4 4 4M15 8l4 4-4 4"/></svg><span>Developers<small>Projects, apps, credentials and grants.</small></span></a>
++ <a href="https://openvibe.network/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 4 5.8 4 9s-1.5 6.4-4 9c-2.5-2.6-4-5.8-4-9s1.5-6.4 4-9z"/></svg><span>Your account<small>Identity, notifications and your OpenCoins.</small></span></a>
++
++ <p class="group">Read</p>
++ <a href="https://openvibe.wiki/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 5a3 3 0 0 1 3-2h10a3 3 0 0 1 3 2v16H7a3 3 0 0 1-3-3z"/><path d="M8 7h8M8 11h8"/></svg><span>Wiki<small>One page for every product and library.</small></span></a>
++ <a href="https://openvibe.blog/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 4h14v16H5z"/><path d="M9 8h6M9 12h6M9 16h4"/></svg><span>Blog<small>What shipped, written down.</small></span></a>
++ <a href="https://openvibe.news/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 5h13v14H4z"/><path d="M17 8h3v9a2 2 0 0 1-3 2M7 8h7M7 12h7M7 16h4"/></svg><span>News<small>Stories assembled from collected items, each with its source.</small></span></a>
++ <a href="https://openvibe.help/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.4 2.3c-.6.3-.9.8-.9 1.4v.3M12 17h.01"/></svg><span>Help<small>Answers for every site, and a real ticket when they run out.</small></span></a>
++ </nav>
++
++ <div class="social" aria-label="Elsewhere">
++ <a href="https://github.com/OpenVibers" aria-label="OpenVibers on GitHub"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-4 1.5-4-2.5-6-3m12 6v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1-.3-3.4 1.3a11.6 11.6 0 0 0-6.2 0C6.6 3.8 5.6 4.1 5.6 4.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4.2 10.5c0 4.5 2.7 5.7 5.5 6-.4.4-.6 1.1-.6 1.9V22"/></svg></a>
++ <a href="https://openvibe.network/" aria-label="Your OpenVibe profile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5"/></svg></a>
++ <a href="mailto:[email protected]" aria-label="Email OpenVibe"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg></a>
++ </div>
++
++ <footer>
++ <span id="who">Made with OpenVibe.Space</span>
++ <span><a href="https://openvibe.space/spaces/new?template=site-links">Make a link page like this one</a></span>
++ </footer>
++</main>
++<script src="/_openvibe/sdk.js"></script>
++<script>
++ // The only thing this page asks of the Space SDK is who is reading it.
++ const who = document.getElementById('who');
++ OV.ready.then(() => {
++ if (!OV.user) return;
++ who.replaceChildren();
++ const name = document.createElement('a');
++ name.href = 'https://openvibe.network/';
++ name.textContent = '@' + OV.user.username;
++ who.append('Signed in as ', name, ' · made with OpenVibe.Space');
++ }).catch(() => {});
++</script>
++</body>
++</html>
ddiff --git a/seeds/official-spaces/portfolio.html b/seeds/official-spaces/portfolio.html
nnew file mode 100644
iindex 0000000..fc3716b
---- /dev/null
++++ b/seeds/official-spaces/portfolio.html
@@@ -0,0 +1,80 @@
++<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>OpenVibe · what we build</title>
++<meta name="description" content="The sites, games and platform behind the OpenVibe network, and the libraries every one of them shares.">
++<style>
++ /* An official example space, built from the Space kit's "Portfolio" template (site-portfolio.html). */
++ :root { --bg: #f6f7f4; --ink: #101216; --muted: #62666e; --line: #dcdfd8; --accent: #1d4ed8; }
++ @media (prefers-color-scheme: dark) { :root { --bg: #0b0d11; --ink: #eceef2; --muted: #98a0ad; --line: #262b33; --accent: #86a6ff; } }
++ * { box-sizing: border-box; }
++ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }
++ .mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: .8rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
++ a { color: inherit; }
++ .wrap { width: min(1160px, 100% - 32px); margin-inline: auto; }
++ header { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-block: 22px; border-bottom: 1px solid var(--line); }
++ header b { font-weight: 700; }
++ header nav { display: flex; gap: 20px; }
++ header nav a { text-decoration: none; }
++ header nav a:hover { color: var(--accent); }
++ .intro { padding-block: 72px 56px; }
++ .intro h1 { font-size: clamp(2.2rem, 6.4vw, 5rem); line-height: 1.02; letter-spacing: -.03em; margin: 12px 0 0; max-width: 16ch; text-wrap: balance; font-weight: 650; }
++ .intro h1 em { font-style: normal; color: var(--accent); }
++ .work { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 18px; }
++ .p { grid-column: span 6; display: grid; gap: 10px; text-decoration: none; }
++ .p:nth-child(3n) { grid-column: span 12; }
++ .tile { aspect-ratio: 4 / 3; border-radius: 6px; background: linear-gradient(135deg, var(--a), var(--b)); position: relative; overflow: hidden; }
++ .p:nth-child(3n) .tile { aspect-ratio: 21 / 9; }
++ .tile::before { content: ""; position: absolute; width: 46%; aspect-ratio: 1; right: 8%; bottom: -12%; border-radius: 50%; background: rgba(255, 255, 255, .22); }
++ .tile::after { content: ""; position: absolute; left: 7%; top: 12%; width: 28%; height: 5px; background: rgba(255, 255, 255, .75); box-shadow: 0 14px 0 rgba(255, 255, 255, .5), 0 28px 0 rgba(255, 255, 255, .3); }
++ .p:hover .tile { filter: saturate(1.15); }
++ .p:focus-visible { outline: 3px solid var(--accent); outline-offset: 6px; border-radius: 10px; }
++ .meta { display: flex; justify-content: space-between; gap: 12px; }
++ .meta h2 { font-size: 1.05rem; margin: 0; font-weight: 600; }
++ section { padding-block: 72px; }
++ .about { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 32px; border-top: 1px solid var(--line); }
++ .about p { margin: 0 0 14px; font-size: 1.2rem; line-height: 1.55; max-width: 60ch; }
++ .clients { display: flex; flex-wrap: wrap; gap: 8px 22px; margin-top: 20px; color: var(--muted); }
++ .contact { border-top: 1px solid var(--line); }
++ .contact a.big { display: inline-block; margin-top: 10px; font-size: clamp(1.6rem, 5vw, 3.2rem); font-weight: 650; letter-spacing: -.02em; text-decoration-thickness: 2px; text-underline-offset: 6px; color: var(--accent); word-break: break-word; }
++ footer { padding-block: 28px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
++ @media (max-width: 720px) { .p, .p:nth-child(3n) { grid-column: span 12; } .p:nth-child(3n) .tile { aspect-ratio: 4 / 3; } .about { grid-template-columns: 1fr; } header nav { gap: 14px; } }
++</style>
++</head>
++<body>
++<div class="wrap">
++ <header><b>OpenVibe</b><nav aria-label="Sections"><a href="#work">Work</a><a href="#about">About</a><a href="#contact">Contact</a></nav></header>
++ <main>
++ <div class="intro">
++ <span class="mono">The network · built in the open</span>
++ <h1>A family of sites that share <em>one account</em>.</h1>
++ </div>
++
++ <div class="work" id="work">
++ <a class="p" href="https://openvibe.live/"><div class="tile" style="--a:#0e7490;--b:#22d3ee"></div><div class="meta"><h2>OpenVibe.Live</h2><span class="mono">Streaming · 2026</span></div></a>
++ <a class="p" href="https://openvibe.chat/"><div class="tile" style="--a:#4338ca;--b:#a78bfa"></div><div class="meta"><h2>OpenVibe.Chat</h2><span class="mono">Rooms · 2026</span></div></a>
++ <a class="p" href="https://openvibe.games/"><div class="tile" style="--a:#b45309;--b:#fbbf24"></div><div class="meta"><h2>OpenVibe.Games</h2><span class="mono">A persistent world · 2026</span></div></a>
++ <a class="p" href="https://openvibe.space/"><div class="tile" style="--a:#1d4ed8;--b:#7dd3fc"></div><div class="meta"><h2>OpenVibe.Space</h2><span class="mono">Small apps · 2026</span></div></a>
++ <a class="p" href="https://openvibe.community/"><div class="tile" style="--a:#065f46;--b:#34d399"></div><div class="meta"><h2>OpenVibe.Community</h2><span class="mono">Forum · 2026</span></div></a>
++ </div>
++
++ <section class="about" id="about">
++ <span class="mono">About</span>
++ <div>
++ <p>OpenVibe is a network of small, complete products — streams, chat, games, hosting, a wiki — that sign you in once and know about each other. Each one is built in the open by OpenVibers.</p>
++ <p>Under them sit the parts the sites share: one identity and wallet, an event bus, a source registry, a media service and a small set of contracts that keep every interface honest.</p>
++ <div class="clients mono"><span>Live</span><span>Chat</span><span>Community</span><span>Games</span><span>Space</span><span>Tools</span><span>Wiki</span><span>Blog</span><span>News</span></div>
++ </div>
++ </section>
++
++ <section class="contact" id="contact">
++ <span class="mono">Every repository is public</span><br>
++ <a class="big" href="https://github.com/OpenVibers">github.com/OpenVibers</a>
++ </section>
++ </main>
++ <footer><span class="mono">© OpenVibers</span><span class="mono">Made with OpenVibe.Space</span></footer>
++</div>
++</body>
++</html>
ddiff --git a/seeds/official-spaces/spaces.json b/seeds/official-spaces/spaces.json
nnew file mode 100644
iindex 0000000..19d21a1
---- /dev/null
++++ b/seeds/official-spaces/spaces.json
@@@ -0,0 +1,26 @@
++{
++ "_about": "The official example spaces (plan §4 Space): a link page, a portfolio and an event page, owned by the official OpenVibe account and published from this folder. scripts/seed-official-spaces.js creates and publishes them; running it again changes nothing unless --revise is given. The pages are adaptations of the Space kit's site-links, site-portfolio and site-event templates (templates/spaces/), with real OpenVibe content.",
++ "spaces": [
++ {
++ "slug": "ov-links",
++ "name": "OpenVibe links",
++ "description": "Every OpenVibe site in one place: streams, chat, games, the tools and the places that write it all down.",
++ "capabilities": ["profile"],
++ "files": [{ "path": "index.html", "file": "links.html" }]
++ },
++ {
++ "slug": "ov-portfolio",
++ "name": "OpenVibe portfolio",
++ "description": "What the network is made of: the sites, the games and the platform every one of them shares.",
++ "capabilities": [],
++ "files": [{ "path": "index.html", "file": "portfolio.html" }]
++ },
++ {
++ "slug": "ov-night",
++ "name": "OpenVibe community night",
++ "description": "The monthly OpenVibe community night: what it is, when the next one is, the evening's plan and who is coming.",
++ "capabilities": ["profile", "data"],
++ "files": [{ "path": "index.html", "file": "event.html" }]
++ }
++ ]
++}
ddiff --git a/server/spaces/official.js b/server/spaces/official.js
nnew file mode 100644
iindex 0000000..8594441
---- /dev/null
++++ b/server/spaces/official.js
@@@ -0,0 +1,106 @@
++'use strict';
++/**
++ * The official example spaces (plan §4 Space, "official example spaces"): a link page, a portfolio and an
++ * event page, owned by the official OpenVibe account and published at <slug>.openvibe.website. They are
++ * adaptations of the Space kit's site-links, site-portfolio and site-event templates, so what the gallery
++ * shows is a real page built the way the kit says, not a placeholder.
++ *
++ * seeds/official-spaces/spaces.json is the list; each file is one self-contained index.html. This module is
++ * idempotent: a space whose slug exists and already belongs to the official account is left alone (and
++ * published if its first publish never finished); { revise: true } also rewrites files and settings that
++ * differ from the seed. A slug that belongs to someone else is reported, never taken or changed.
++ */
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++
++const DIR = path.join(__dirname, '..', '..', 'seeds', 'official-spaces');
++
++/** The seed as the seed function takes it: [{ slug, name, description, capabilities, files: [{ path, content }] }]. */
++function loadOfficialSpaces() {
++ const manifest = JSON.parse(fs.readFileSync(path.join(DIR, 'spaces.json'), 'utf8'));
++ return (manifest.spaces || []).map((s) => ({
++ slug: String(s.slug || ''),
++ name: String(s.name || ''),
++ description: String(s.description || ''),
++ capabilities: Array.isArray(s.capabilities) ? s.capabilities : [],
++ files: (s.files || []).map((f) => ({
++ path: String(f.path || ''),
++ content: fs.readFileSync(path.join(DIR, path.basename(String(f.file || ''))), 'utf8'),
++ })),
++ }));
++}
++
++/** Whether the files the site's active deploy serves are exactly the seed's. */
++function servesFiles(deployed, wanted) {
++ if (!deployed || deployed.length !== wanted.length) return false;
++ const byPath = new Map(deployed.map((f) => [f.path, f.sha256]));
++ return wanted.every((f) => byPath.get(f.path) === crypto.createHash('sha256').update(f.content).digest('hex'));
++}
++
++/** Whether the stored files are already exactly the seed's. */
++function sameFiles(stored, wanted) {
++ if (stored.length !== wanted.length) return false;
++ const byPath = new Map(stored.map((f) => [f.path, f.content]));
++ return wanted.every((f) => byPath.get(f.path) === f.content);
++}
++
++/** The settings the seed holds, as a space row carries them. */
++function sameSettings(space, def) {
++ const caps = (v) => {
++ let list = v;
++ if (typeof v === 'string') { try { list = JSON.parse(v); } catch { list = []; } }
++ return [...new Set((Array.isArray(list) ? list : []).map(String))].sort();
++ };
++ return space.name === def.name && space.description === def.description
++ && JSON.stringify(caps(space.capabilities)) === JSON.stringify([...def.capabilities].map(String).sort());
++}
++
++/**
++ * Create and publish the official examples. `spaces` is app.locals.ctx.spaces (the service's own module);
++ * `viewer` is the official account as the module sees a signed-in staff person. Answers one result per
++ * seed: { slug, id, action, url } where action is created | revised | unchanged | published | conflict.
++ */
++async function seedOfficialSpaces({ spaces, viewer, log = console, revise = false }) {
++ if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new Error('seedOfficialSpaces needs the official account as a signed-in viewer');
++ const out = [];
++ for (const def of loadOfficialSpaces()) {
++ let space = await spaces.getBySlug(def.slug);
++ let action = 'unchanged';
++ if (space && space.owner_subject !== viewer.subject) {
++ // Never take a person's address or change their space: report it and move on.
++ log.warn(`[official-spaces] ${def.slug} belongs to ${space.owner_subject || 'someone'}, not the official account: skipped`);
++ out.push({ slug: def.slug, id: space.id, action: 'conflict', url: null });
++ continue;
++ }
++ if (!space) {
++ space = await spaces.create(viewer, { ...def, slug: def.slug });
++ action = 'created';
++ log.info(`[official-spaces] created ${def.slug} (${space.id})`);
++ } else if (revise) {
++ const filesChanged = !sameFiles(await spaces.files(space), def.files);
++ const settingsChanged = !sameSettings(space, def);
++ if (filesChanged) await spaces.saveFiles(viewer, space, def.files);
++ if (settingsChanged) await spaces.updateSettings(viewer, space, {
++ name: def.name, description: def.description, capabilities: def.capabilities,
++ });
++ if (filesChanged || settingsChanged) {
++ action = 'revised';
++ log.info(`[official-spaces] revised ${def.slug} (${space.id})`);
++ }
++ }
++ // Publish when the site does not serve anything (a first publish, a deleted site, or a run that saved the
++ // files and died before deploying). With --revise, also when it serves something other than the seed.
++ const deployed = await spaces.publishedFiles(space);
++ if (!deployed || (revise && !servesFiles(deployed, def.files))) {
++ const published = await spaces.publish(viewer, space);
++ space = published.space;
++ if (action === 'unchanged') action = 'published';
++ log.info(`[official-spaces] published ${def.slug} at ${published.url}`);
++ }
++ out.push({ slug: def.slug, id: space.id, action, url: spaces.urlOf(space) });
++ }
++ return out;
++}
++
++module.exports = { loadOfficialSpaces, seedOfficialSpaces, sameFiles, sameSettings };
ddiff --git a/server/spaces/spaces.js b/server/spaces/spaces.js
iindex 5ebbf1d..d29a764 100644
---- a/server/spaces/spaces.js
++++ b/server/spaces/spaces.js
@@@ -179,6 +179,15 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
throw new ApiError(503, 'space.slug_unavailable', 'could not find a free address for the draft; try again');
}
++ /** A slug chosen by the caller (the official examples): the same checks a generated one gets. */
++ async function fixedSlug(value) {
++ const slug = String(value == null ? '' : value).trim().toLowerCase();
++ if (!SLUG_RE.test(slug)) throw new ApiError(422, 'space.invalid_slug', 'a space address is 3 to 40 characters: a–z, 0–9 and single hyphens, starting and ending with a letter or digit');
++ try { sites.checkName(slug); } catch (err) { throw new ApiError(422, 'space.slug_reserved', err.message); }
++ if (await q.slugUsed.get(slug, slug)) throw new ApiError(409, 'space.slug_taken', `${slug} is already taken`);
++ return slug;
++ }
++
/** A new space owned by the viewer: from given files (a template, a fork, an AI build) and settings. */
async function create(viewer, input = {}) {
const person = personOf(viewer);
@@@ -187,7 +196,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
const fileList = checkFiles(input.files);
const now = store.now();
const id = `spc_${ids.ulid(now)}`;
-- const slug = await uniqueSlug(name);
++ const slug = input.slug ? await fixedSlug(input.slug) : await uniqueSlug(name);
await store.tx(async () => {
await q.insert.run(id, slug, person.subject, person.username, person.name, name, text(input.description, 400), String(input.instructions || '').trim().slice(0, 4000),
JSON.stringify(capsOf(input.capabilities)), input.template ? text(input.template, 40) : null, input.forked_from || null, now, now);
@@@ -370,6 +379,14 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
return { space: await get(space.id), site, deploy, url: urlOf(space) };
}
++ /** What the site's active deploy serves: [{ path, sha256, size, content_type }], or null when not published. */
++ async function publishedFiles(space) {
++ if (!space.site_id) return null;
++ const site = await sites.get(space.site_id);
++ if (!site || site.status !== 'active' || !site.active_deploy_id) return null;
++ return await deploys.filesOf(site.active_deploy_id);
++ }
++
async function unpublish(viewer, space) {
if (!space.site_id) return await get(space.id);
const site = await sites.get(space.site_id);
@@@ -399,7 +416,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
return {
CAPABILITIES, isSpaceId, get, getBySlug, forSite, load, isOwner, files, create, saveFiles, updateSettings, fork,
-- startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, remove, gallery, mine, hostnameOf, urlOf,
++ startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, publishedFiles, remove, gallery, mine, hostnameOf, urlOf,
personOf, capsOf, checkFiles, DAY,
// Guest drafts (openvibe.website, plan 5.9)
isGuest, guestExpired, guestKeyOk, touchGuest, createGuest, claimGuest, spendGuestDraft, spendGuestAi,
ddiff --git a/test/official-spaces.test.js b/test/official-spaces.test.js
nnew file mode 100644
iindex 0000000..adfa990
---- /dev/null
++++ b/test/official-spaces.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++/**
++ * The official example spaces (plan §4 Space): seeds/official-spaces/ creates three real, published spaces
++ * through Space's own modules, owned by the official account, and running it again changes nothing. Also
++ * checks the pages a visitor gets, that a slug is never taken from a person, and that a conflict is reported
++ * rather than overwriting someone else's space.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./stageb/boot');
++const { loadOfficialSpaces, seedOfficialSpaces } = require('../server/spaces/official');
++
++const quiet = { info() {}, warn() {}, error() {} };
++const OFFICIAL = { kind: 'user', subject: 'usr_01JZ0000000000000000000AAA', staff: true, user: { username: 'goosely', display_name: 'goosely' } };
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the seed files are one self-contained page each, on the Space SDK', async () => {
++ const seeds = loadOfficialSpaces();
++ assert.strictEqual(seeds.length, 3);
++ assert.deepStrictEqual(seeds.map((s) => s.slug), ['ov-links', 'ov-portfolio', 'ov-night']);
++ for (const s of seeds) {
++ assert.strictEqual(s.files.length, 1);
++ assert.strictEqual(s.files[0].path, 'index.html');
++ const html = s.files[0].content;
++ assert.ok(/<meta name="viewport"/.test(html), `${s.slug} has no viewport meta`);
++ assert.ok(html.includes('src="/_openvibe/sdk.js"') === (s.capabilities.length > 0), `${s.slug}: the SDK is loaded exactly when it asks for a capability`);
++ assert.ok(/<title>[^<]+<\/title>/.test(html), `${s.slug} has no title`);
++ assert.ok(!/data-ov-share-gate/.test(html), `${s.slug} carries a share gate it has no share API for`);
++ }
++ });
++
++ const first = await seedOfficialSpaces({ spaces: t.ctx.spaces, viewer: OFFICIAL, log: quiet });
++ await check('the first run creates and publishes all three, owned by the official account', async () => {
++ assert.deepStrictEqual(first.map((r) => r.action), ['created', 'created', 'created']);
++ const gallery = await t.ctx.spaces.gallery({ limit: 10 });
++ assert.strictEqual(gallery.length, 3);
++ for (const row of gallery) {
++ assert.strictEqual(row.owner_subject, OFFICIAL.subject);
++ assert.ok(row.site_id, `${row.slug} is not published`);
++ assert.strictEqual(row.visibility, 'public');
++ }
++ });
++
++ await check('each published space serves its page at <slug>.openvibe.website', async () => {
++ const expected = {
++ 'ov-links': 'Every OpenVibe site in one place',
++ 'ov-portfolio': 'A family of sites that share',
++ 'ov-night': 'OpenVibe community night',
++ };
++ for (const [slug, words] of Object.entries(expected)) {
++ const r = await t.get(`${slug}.openvibe.website`, '/');
++ assert.strictEqual(r.status, 200, `${slug} → ${r.status}`);
++ assert.ok(r.text.includes(words), `${slug} page is missing "${words}"`);
++ }
++ });
++
++ await check('a second run changes nothing', async () => {
++ const again = await seedOfficialSpaces({ spaces: t.ctx.spaces, viewer: OFFICIAL, log: quiet });
++ assert.deepStrictEqual(again.map((r) => r.action), ['unchanged', 'unchanged', 'unchanged']);
++ assert.strictEqual((await t.ctx.spaces.gallery({ limit: 10 })).length, 3);
++ });
++
++ await check('a slug a seed owns is refused to anyone else, and the space is untouched', async () => {
++ const person = { kind: 'user', subject: 'usr_01JZ0000000000000000000BBB', staff: false, user: { username: 'alex', display_name: 'Alex' } };
++ const before = await t.ctx.spaces.getBySlug('ov-links');
++ await assert.rejects(
++ t.ctx.spaces.create(person, { name: 'Mine', slug: 'ov-links', files: [{ path: 'index.html', content: '<h1>mine</h1>' }] }),
++ (e) => e.status === 409 && e.code === 'space.slug_taken',
++ );
++ assert.strictEqual((await t.ctx.spaces.getBySlug('ov-links')).id, before.id);
++ });
++
++ await check('--revise repairs drift and deploys it, and a third run is unchanged again', async () => {
++ const space = await t.ctx.spaces.getBySlug('ov-links');
++ await t.ctx.spaces.saveFiles(OFFICIAL, space, [{ path: 'index.html', content: '<h1>drift</h1>' }]);
++ await t.ctx.spaces.publish(OFFICIAL, space);
++ assert.ok((await t.get('ov-links.openvibe.website', '/')).text.includes('drift'), 'the drift is what is live before the revise');
++ const revised = await seedOfficialSpaces({ spaces: t.ctx.spaces, viewer: OFFICIAL, log: quiet, revise: true });
++ assert.deepStrictEqual(revised.map((r) => r.action), ['revised', 'unchanged', 'unchanged']);
++ const r = await t.get('ov-links.openvibe.website', '/');
++ assert.ok(r.text.includes('Every OpenVibe site in one place'), 'the revise was deployed, not only stored');
++ const again = await seedOfficialSpaces({ spaces: t.ctx.spaces, viewer: OFFICIAL, log: quiet, revise: true });
++ assert.deepStrictEqual(again.map((r) => r.action), ['unchanged', 'unchanged', 'unchanged']);
++ });
++
++ await check('a space whose site went away is published again, without --revise', async () => {
++ const space = await t.ctx.spaces.getBySlug('ov-links');
++ await t.ctx.sites.remove(OFFICIAL, space.site_id, { deploys: t.ctx.deploys });
++ const out = await seedOfficialSpaces({ spaces: t.ctx.spaces, viewer: OFFICIAL, log: quiet });
++ assert.deepStrictEqual(out.map((r) => r.action), ['published', 'unchanged', 'unchanged']);
++ assert.strictEqual((await t.get('ov-links.openvibe.website', '/')).status, 200);
++ });
++
++ await check('a slug that belongs to someone else is reported, never created over or changed', async () => {
++ const calls = { create: 0, publish: 0, save: 0 };
++ const fake = {
++ async getBySlug(slug) { return { id: 'spc_other', slug, owner_subject: 'usr_01JZ0000000000000000000CCC', site_id: 'site_other' }; },
++ async create() { calls.create++; },
++ async publish() { calls.publish++; },
++ async saveFiles() { calls.save++; },
++ async files() { return []; },
++ urlOf() { return 'https://someone.openvibe.website'; },
++ };
++ const out = await seedOfficialSpaces({ spaces: fake, viewer: OFFICIAL, log: quiet });
++ assert.deepStrictEqual(out.map((r) => r.action), ['conflict', 'conflict', 'conflict']);
++ assert.deepStrictEqual(calls, { create: 0, publish: 0, save: 0 });
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();