openvibers/openvibe.space Commits

Space: spaces — apps people make on their OpenVibe account: described to the AI builder or started from a template, edited with a live preview, published at <slug>.openvibe.website with the space SDK (sign-in, shared lists, private storage, OpenVibe AI) (#58)

OP OpenVibers committed 28 days ago · cde5878890 · 32 files, +2813 −36

Changes

ddiff --git a/.env.example b/.env.example
iindex 128343a..9d39c68 100644
---- a/.env.example
++++ b/.env.example
@@@ -41,6 +41,12 @@ DATABASE_DIRECT_URL=
# Static hosting engine (Stage B). Public project creation remains off until launch.
SPACE_PUBLIC_HOSTING=off
++# Spaces (server/spaces/, docs/spaces.md): the AI builder and spaces' AI go through OpenVibe.AI (space.* workflows).
++#SPACE_AI=on
++#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
++#SPACE_SPACES_PER_OWNER=25
++#SPACE_AI_BUILDS_PER_DAY=20
++#SPACE_AI_ASKS_PER_SPACE_PER_DAY=500
SPACE_SITES_DOMAIN=openvibe.website
SPACE_STORAGE_DIR=./data/objects
# SPACE_OBJECT_STORE=media
ddiff --git a/README.md b/README.md
iindex d9215c5..7d37f00 100644
---- a/README.md
++++ b/README.md
@@@ -1,17 +1,28 @@
# OpenVibe.Space
--**https://openvibe.space — static hosting for the OpenVibe network.**
++**https://openvibe.space — spaces (apps people make on their OpenVibe account) and static hosting for the OpenVibe network.**
## Purpose and status
Space owns static hosting. The Stage B engine moved here from OpenVibe.Host as code; production had no projects, sites or deploys to migrate. It supports projects and members, immutable content-addressed deploys, activation and one-step rollback, private previews, CI Git ingest, per-site configuration, verified custom domains, takedowns, Media-backed objects, Host-header tenant serving, and a resource index. Tenant names serve at `<name>.openvibe.website`.
--Public hosting remains off by default (`SPACE_PUBLIC_HOSTING=off`). Only OpenVibe staff can create projects. Abuse handling and a designated DMCA agent precede public launch. Dynamic pages and spaces (small apps on a person's OpenVibe account) are planned and not built.
++Public hosting remains off by default (`SPACE_PUBLIC_HOSTING=off`). Only OpenVibe staff can create projects. Abuse handling and a designated DMCA agent precede public launch.
++
++**Spaces** (plan T20 step 2; `server/spaces/`, `docs/spaces.md`) are small web apps people make here: described to the AI
++builder (OpenVibe.AI `space.app.build`), started from a template (guestbook, AI buddy, poll, reaction game, private
++to-dos, blank) or forked, then edited in a browser editor with a live preview. A space uses the visitor's OpenVibe
++account through the space SDK (`/_openvibe/sdk.js`): sign-in that the visitor approves once, shared lists
++(`OV.items`), private storage (`OV.kv`) and the space's own AI (`OV.ai.ask`, OpenVibe.AI `space.app.ask` on behalf of
++the visitor). Making, editing, previewing and the AI builder work for everyone signed in. Publishing deploys a space to
++a site of its maker's Spaces project at `<slug>.openvibe.website`, so it follows the same launch switch: staff until
++`SPACE_PUBLIC_HOSTING=on`.
OpenVibe.Host is the hosting price comparison; its copy of the engine is being removed, and its old hosting URLs will answer 410 with a link here.
## Owns
++- Spaces: their source files and settings, the gallery, the editor, the space SDK and its API, each space's shared lists,
++ its visitors' private storage, the sign-ins visitors approved, and the daily AI allowances of spaces and builders.
- Static hosting: projects and members, sites, immutable content-addressed deploys, activation and rollback,
previews, Git ingest, per-site configuration, custom domains with TXT verification, and takedowns.
- Serving tenant sites by Host header at `<name>.openvibe.website` and on verified custom domains.
@@@ -30,6 +41,8 @@ OpenVibe.Host is the hosting price comparison; its copy of the engine is being r
- **OpenVibe.Media:** tenant objects, when `SPACE_OBJECT_STORE=media`.
- **OpenVibe.Billing:** storage readings, when `SPACE_BILLING_INTERVAL_MS` is on.
- **OpenVibe.Services:** reads the resource index and sends control requests.
++- **OpenVibe.AI:** the space builder and a space's own AI (`space.*` workflows; Network grants `ai.run.create` and
++ `ai.run.read` on `space.*`).
## The forum moved to OpenVibe.Community
@@@ -53,7 +66,12 @@ Nothing that is not a forum path redirects.
| Route | Purpose |
| --- | --- |
--| `/` | Public status and static hosting description |
++| `/` | Spaces and static hosting |
++| `/spaces`, `/spaces/new`, `/spaces/mine`, `/spaces/:slug` | The gallery and templates, making a space (template or AI builder), your spaces, a space's page |
++| `/spaces/:slug/edit` and its JSON calls (`files`, `settings`, `ai`, `publish`, `unpublish`, `delete`, `preview`) | The editor (maker or staff; same origin with the form token in `X-CSRF-Token`) |
++| `/spaces/:slug/fork`, `/spaces/:slug/connect` | Fork a space; the sign-in a visitor approves for a published space |
++| `/_openvibe/sdk.js` | The space SDK: live on a space's own address, preview mode on openvibe.space |
++| `/api/v1/apps/:id/token`, `/api/v1/apps/:id/run`, `/api/v1/apps/:id/items/:collection` | The space SDK's API, from the space's own origin only (CORS for that origin, Bearer space token, never cookies) |
| `/dashboard`, `/projects/:id`, `/sites/:id`, `/deploys/:id` | Signed-in hosting dashboard and forms |
| `/preview/:deployId/*` | Private, sandboxed deploy preview |
| `/api/v1/projects`, `/api/v1/sites`, `/api/v1/deploys`, `/api/v1/domains` | Hosting API (Bearer tokens, no cookie auth) |
@@@ -78,13 +96,17 @@ The transactional outbox emits `space.deploy.created`, `space.deploy.activated`,
`migrations/0002_initial.sql` through `0005_git_sources.sql` create the hosting tables, `0006_billing_readings.sql` the usage-reading outbox,, indexes and immutable-artifact triggers from Host Stage B with `space_` names. They run through `openvibe-sdk/db` at boot; tables are never created by a request. `0001_initial.sql` remains the historical forum schema, which this release does not drop. This is a code move, with no data copy.
++`migrations/0009_spaces.sql` creates the spaces tables (`space_apps`, `space_app_files`, `space_app_projects`, `space_app_items`, `space_app_kv`, `space_app_grants`, `space_app_codes`, `space_app_tokens`, `space_app_counters`). Only hashes of sign-in codes and tokens are kept.
++
Request telemetry is aggregated by route template, method and status class, never by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape), which `migrations/0008_telemetry.sql` creates; rows older than 14 days are deleted daily. `/shared` files, static assets, health, readiness and `/metrics` are not counted. `server/app.js` mounts openvibe-sdk's `telemetryMiddleware` (a no-op until init); `server/index.js` calls `telemetry.init()` at production start, runs the 14-day retention and flushes on stop.
Copy `.env.example` to `.env`. Production uses `/etc/openvibe/space.env` (0600). Important values:
| Variable | Default | Purpose |
| --- | --- | --- |
--| `SPACE_PUBLIC_HOSTING` | `off` | Staff-only project creation until launch |
++| `SPACE_PUBLIC_HOSTING` | `off` | Staff-only project creation (and publishing spaces) until launch |
++| `SPACE_AI`, `OV_AI_INTERNAL_URL` | `on`, `http://127.0.0.1:4700` | The space builder and spaces' AI through OpenVibe.AI |
++| `SPACE_SPACES_PER_OWNER`, `SPACE_AI_BUILDS_PER_DAY`, `SPACE_AI_ASKS_PER_SPACE_PER_DAY` | 25, 20, 500 | Spaces per person, AI builds per person a day, AI answers per space a day (more `SPACE_SPACE_*` limits in `server/hosting-config.js`) |
| `SPACE_SITES_DOMAIN` | `openvibe.website` | Tenant names |
| `SPACE_STORAGE_DIR` | `./data/objects` | Content-addressed local object store |
| `SPACE_OBJECT_STORE` | `local` | Set `media` for Media write-through and cache |
ddiff --git a/docs/spaces.md b/docs/spaces.md
nnew file mode 100644
iindex 0000000..b7709f9
---- /dev/null
++++ b/docs/spaces.md
@@@ -0,0 +1,93 @@
++# Spaces
++
++A **space** is a small web app someone makes on openvibe.space that uses the visitor's OpenVibe account. The owner asked
++for it on 2026-10-08: "a system where people can make spaces that interact with your openvibe account and the network
++and interconnected with sdk and such, compete with openai chatgpt spaces type of product but better". This is plan T20
++step 2.
++
++## Making one
++
++- **Describe it:** `/spaces/new` sends the description to OpenVibe.AI's `space.app.build`. The run is on behalf of the
++ person building, who gets 20 builds a day by default. The editor waits for the run and loads the result: name,
++ description, the instructions its AI follows, the SDK features it uses, and its files.
++- **Start from a template:** guestbook, AI buddy, quick poll, reaction game, private to-dos, or blank
++ (`templates/spaces/`). Each is one self-contained `index.html` and an example of the SDK.
++- **Fork** any space you can open; the copy is your own draft.
++
++The editor (`/spaces/:slug/edit`) has the files in tabs, a live preview, "Ask AI to change it", settings and Publish.
++On a phone the preview and the code take turns, with the preview first and the AI box above both.
++
++The preview runs the space in a sandboxed frame (`srcdoc`, an opaque origin with no `allow-same-origin`), so the
++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`).
++
++## Publishing
++
++Publishing deploys the files to a site named after the space's slug, in its maker's **Spaces** project. The space is
++then served at `https://<slug>.openvibe.website` by the hosting engine, with immutable deploys and staff takedowns.
++Publishing follows hosting's launch switch: until `SPACE_PUBLIC_HOSTING=on`, only OpenVibe staff publish; everyone
++else makes, edits and previews.
++
++A published space is served with its own policy: inline scripts, pinned CDN scripts, styles and fonts, and
++`connect-src` to its own origin and openvibe.space. It runs on its own registrable domain, apart from every network
++site, and openvibe.space may embed it on the space's page.
++
++## The SDK
++
++Load it before your own script:
++
++```html
++<script src="/_openvibe/sdk.js"></script>
++```
++
++| Call | What it does |
++| --- | --- |
++| `await OV.ready` | Resolves once the SDK knows whether the visitor is signed in. |
++| `OV.user` | `null`, or `{ username, display_name, avatar_url }`. `OV.on('user', fn)` hears changes. |
++| `OV.signIn()`, `OV.signOut()` | Sends the visitor to openvibe.space to approve the space and back; forgets them here. |
++| `OV.items.list(collection, { limit, before })` | The shared list, newest first, readable by anyone: `[{ id, value, author, created_at, mine }]`. |
++| `OV.items.add(collection, value)` | Adds the signed-in visitor's item (any JSON up to 8 KB). |
++| `OV.items.set(collection, value)` | The visitor's one item in that list (a vote, a best score), replaced each time. |
++| `OV.items.remove(collection, id)` | Removes the visitor's own item (the space's maker may remove any). |
++| `OV.kv.get(key)`, `OV.kv.set(key, value)`, `OV.kv.remove(key)` | The visitor's private storage in this space (up to 16 KB a value, 200 keys). |
++| `OV.ai.ask(text \| { messages })` | An answer from OpenVibe AI that follows the space's instructions, on behalf of the visitor. |
++
++Every call returns a Promise. A failure is an `Error` with `.code`: `auth.required` (call `OV.signIn()`), `quota`
++(try later), `space.capability` (the space did not ask for it), and so on. Collection names and keys are 1-64
++characters of letters, digits, `.`, `-` and `_`.
++
++## Sign-in, step by step
++
++1. `OV.signIn()` makes a PKCE verifier and state, keeps them in `sessionStorage`, and goes to
++ `https://openvibe.space/spaces/:slug/connect?challenge=…&state=…&return=…`.
++2. The visitor, signed in to openvibe.space, sees the space's name, maker and what it may do (its capabilities:
++ profile, data, kv, ai). Approving stores the grant. The next sign-in skips the page unless the space asks for more.
++3. openvibe.space redirects to the space's own address with `#ov_code=…&ov_state=…`. The return path is always a path
++ on that host.
++4. The SDK posts the code and verifier to `POST /api/v1/apps/:id/token` from the space's origin. CORS answers that one
++ origin; a request naming another origin, or none, is refused. The code works once, within two minutes.
++5. The token is a day-long Bearer token for that space only, kept in the space's `sessionStorage`. Space stores only
++ its hash, and `DELETE /api/v1/apps/:id/token` signs the visitor out.
++
++## Limits
++
++These are per person or per space, set in `server/hosting-config.js`:
++
++- 25 spaces per person, 20 files and 1 MB per space.
++- 20,000 list items per space, 30 writes a minute per person and space.
++- 20 AI builds per person a day, 6 AI questions a minute per person and space, 500 AI answers per space a day.
++- Each OpenVibe AI run also counts against the person's own daily allowance in OpenVibe.AI.
++
++## Code
++
++| Path | What it is |
++| --- | --- |
++| `server/spaces/spaces.js` | The spaces, their files, builds, forks and publishing. |
++| `server/spaces/data.js` | Lists, storage, the AI call and the sign-in codes and tokens; `run()` is the one entry point. |
++| `server/spaces/api.js` | The API a space's page calls. |
++| `server/spaces/pages.js` and `render.js` | The pages, the editor's calls and the connect flow. |
++| `server/spaces/sdk.js` | The SDK. |
++| `server/spaces/ai.js` | The OpenVibe.AI client. |
++| `server/spaces/templates.js` | The templates. |
++| `public/js/space-editor.js`, `public/css/spaces.css` | The editor and the styles. |
++| `test/spaces.test.js` | The tests. |
ddiff --git a/migrations/0009_spaces.sql b/migrations/0009_spaces.sql
nnew file mode 100644
iindex 0000000..07c8a4c
---- /dev/null
++++ b/migrations/0009_spaces.sql
@@@ -0,0 +1,121 @@
++-- phase: expand
++-- Spaces (plan T20 step 2; owner 2026-10-08: "a system where people can make spaces that interact with your openvibe
++-- account and the network and interconnected with sdk and such"): small web apps a person makes on openvibe.space and
++-- publishes at <slug>.openvibe.website, where the space SDK gives them the visitor's sign-in, shared lists, private
++-- storage and OpenVibe AI (server/spaces/). The forum's old `spaces` table (migration 0001) is unrelated and untouched;
++-- these tables are space_app*. Times are epoch milliseconds like the hosting tables.
++
++-- A space: its source lives in space_app_files; a published space is also a site (site_id) in its owner's Spaces
++-- project, served by the hosting engine.
++CREATE TABLE space_apps (
++ id text COLLATE "C" PRIMARY KEY, -- spc_<ULID>
++ slug text COLLATE "C" NOT NULL, -- the site name it publishes under
++ owner_subject text COLLATE "C" NOT NULL, -- usr_…
++ owner_username text COLLATE "C" NOT NULL DEFAULT '',
++ owner_name text NOT NULL DEFAULT '',
++ name text NOT NULL,
++ description text NOT NULL DEFAULT '',
++ instructions text NOT NULL DEFAULT '', -- what the space's AI is told (OV.ai.ask)
++ capabilities text NOT NULL DEFAULT '[]', -- JSON: profile, data, kv, ai
++ template text COLLATE "C",
++ forked_from text COLLATE "C",
++ visibility text COLLATE "C" NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','unlisted')),
++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
++ site_id text COLLATE "C", -- set while published
++ building_run_id text COLLATE "C", -- an AI build or change in progress
++ building_prompt text,
++ forks integer NOT NULL DEFAULT 0,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ published_at bigint,
++ deleted_at bigint
++);
++CREATE UNIQUE INDEX space_apps_slug ON space_apps (slug) WHERE status = 'active';
++CREATE INDEX space_apps_owner ON space_apps (owner_subject, status);
++CREATE INDEX space_apps_gallery ON space_apps (published_at DESC) WHERE status = 'active' AND site_id IS NOT NULL;
++CREATE UNIQUE INDEX space_apps_site ON space_apps (site_id) WHERE site_id IS NOT NULL;
++
++CREATE TABLE space_app_files (
++ space_id text COLLATE "C" NOT NULL REFERENCES space_apps(id) ON DELETE CASCADE,
++ path text COLLATE "C" NOT NULL,
++ content text NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (space_id, path)
++);
++
++-- The Spaces project of each person who has published one (created on their first publish).
++CREATE TABLE space_app_projects (
++ owner_subject text COLLATE "C" PRIMARY KEY,
++ project_id text COLLATE "C" NOT NULL REFERENCES space_projects(id),
++ created_at bigint NOT NULL
++);
++
++-- OV.items: shared lists, readable by anyone who can open the space. `mine` marks the one item a person keeps with
++-- OV.items.set (a vote, a best score).
++CREATE TABLE space_app_items (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ space_id text COLLATE "C" NOT NULL REFERENCES space_apps(id) ON DELETE CASCADE,
++ collection text COLLATE "C" NOT NULL,
++ author_subject text COLLATE "C" NOT NULL,
++ author_username text COLLATE "C" NOT NULL DEFAULT '',
++ author_name text NOT NULL DEFAULT '',
++ value text NOT NULL,
++ mine boolean NOT NULL DEFAULT false,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX space_app_items_list ON space_app_items (space_id, collection, id DESC);
++CREATE UNIQUE INDEX space_app_items_mine ON space_app_items (space_id, collection, author_subject) WHERE mine;
++CREATE INDEX space_app_items_author ON space_app_items (author_subject);
++
++-- OV.kv: each signed-in person's private storage in a space.
++CREATE TABLE space_app_kv (
++ space_id text COLLATE "C" NOT NULL REFERENCES space_apps(id) ON DELETE CASCADE,
++ subject text COLLATE "C" NOT NULL,
++ key text COLLATE "C" NOT NULL,
++ value text NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (space_id, subject, key)
++);
++CREATE INDEX space_app_kv_subject ON space_app_kv (subject);
++
++-- What a person approved for a space (the connect page asks once per set of capabilities).
++CREATE TABLE space_app_grants (
++ space_id text COLLATE "C" NOT NULL REFERENCES space_apps(id) ON DELETE CASCADE,
++ subject text COLLATE "C" NOT NULL,
++ capabilities text NOT NULL,
++ granted_at bigint NOT NULL,
++ PRIMARY KEY (space_id, subject)
++);
++
++-- The connect flow: a one-time code (PKCE, two minutes) and the token it becomes (a day). Only hashes are kept.
++CREATE TABLE space_app_codes (
++ code_hash text COLLATE "C" PRIMARY KEY,
++ space_id text COLLATE "C" NOT NULL REFERENCES space_apps(id) ON DELETE CASCADE,
++ subject text COLLATE "C" NOT NULL,
++ username text COLLATE "C" NOT NULL DEFAULT '',
++ display_name text NOT NULL DEFAULT '',
++ capabilities text NOT NULL,
++ challenge text COLLATE "C" NOT NULL,
++ expires_at bigint NOT NULL
++);
++CREATE TABLE space_app_tokens (
++ token_hash text COLLATE "C" PRIMARY KEY,
++ space_id text COLLATE "C" NOT NULL REFERENCES space_apps(id) ON DELETE CASCADE,
++ subject text COLLATE "C" NOT NULL,
++ username text COLLATE "C" NOT NULL DEFAULT '',
++ display_name text NOT NULL DEFAULT '',
++ capabilities text NOT NULL,
++ created_at bigint NOT NULL,
++ expires_at bigint NOT NULL
++);
++CREATE INDEX space_app_tokens_expiry ON space_app_tokens (expires_at);
++CREATE INDEX space_app_tokens_subject ON space_app_tokens (subject);
++
++-- Daily counters: AI builds per person ('build:<usr_…>'), a space's AI answers ('ask:<spc_…>').
++CREATE TABLE space_app_counters (
++ scope text COLLATE "C" NOT NULL,
++ day text COLLATE "C" NOT NULL,
++ n integer NOT NULL DEFAULT 0,
++ PRIMARY KEY (scope, day)
++);
ddiff --git a/public/css/spaces.css b/public/css/spaces.css
nnew file mode 100644
iindex 0000000..5fe9351
---- /dev/null
++++ b/public/css/spaces.css
@@@ -0,0 +1,150 @@
++/* Spaces (server/spaces/render.js): the gallery, making a space, a space's page, the editor and the sign-in a visitor
++ approves. Colours come from the shared Frame's theme variables; filled controls use --accent-strong for contrast. */
++
++.sp-wrap { max-width: 1080px; margin: 0 auto; padding: 40px 16px 0; }
++.sp-section { max-width: 1080px; margin: 0 auto; padding: 40px 16px 0; }
++.sp-title { font-size: clamp(28px, 4vw, 40px); line-height: 1.1; letter-spacing: -0.02em; margin: 8px 0 12px; }
++.sp-subtitle { font-size: 1.2rem; margin: 36px 0 12px; }
++.sp-lede { font-size: 1.1rem; color: var(--text-primary, #e6edf7); margin: 0 0 8px; max-width: 70ch; }
++.sp-muted { color: var(--text-secondary, #9fb0c8); font-size: 0.92rem; }
++.sp-muted a { color: inherit; }
++.sp-note { color: var(--text-secondary, #9fb0c8); max-width: 75ch; margin-top: 18px; }
++.sp-empty { color: var(--text-secondary, #9fb0c8); padding: 28px; border: 1px dashed var(--border, #2c3d5c); border-radius: 14px; text-align: center; }
++.sp-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
++.sp-head h2 { margin: 0; font-size: 1.35rem; }
++.sp-head p { margin: 0; color: var(--text-secondary, #9fb0c8); }
++.sp-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
++.sp-actions { display: flex; gap: 8px; flex-wrap: wrap; }
++.sp-actions form { margin: 0; }
++.sp-flash { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #2c3d5c); background: var(--bg-secondary, #101828); }
++.sp-flash.is-bad { border-color: #ef4444; color: #fecaca; background: color-mix(in srgb, #ef4444 12%, transparent); }
++/* The hidden attribute wins over every display rule here (the AI overlay, Unpublish). */
++.sp-editor [hidden], .sp-wrap [hidden], .sp-section [hidden] { display: none !important; }
++.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++
++.sp-btn { display: inline-flex; align-items: center; gap: 8px; border: 1px solid transparent; border-radius: 10px; padding: 9px 16px; font: 600 0.95rem/1.2 inherit; cursor: pointer; text-decoration: none;
++ background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
++.sp-btn:hover { filter: brightness(1.08); }
++.sp-btn:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent, #60a5fa) 55%, transparent); outline-offset: 2px; }
++.sp-btn[disabled] { opacity: 0.55; cursor: not-allowed; filter: none; }
++.sp-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7); border-color: var(--border, #2c3d5c); }
++.sp-btn-danger { background: transparent; color: #f87171; border-color: color-mix(in srgb, #f87171 50%, transparent); }
++.sp-btn-sm { padding: 6px 11px; font-size: 0.85rem; }
++
++/* Gallery cards */
++.sp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 14px; }
++.sp-card { display: flex; flex-direction: column; border: 1px solid var(--border, #2c3d5c); border-radius: 16px; background: var(--bg-secondary, #101828); overflow: hidden; transition: transform .15s, border-color .15s; }
++.sp-card:hover { transform: translateY(-2px); border-color: var(--accent, #60a5fa); }
++.sp-card-main { flex: 1; padding: 16px 16px 10px; color: inherit; text-decoration: none; display: block; }
++.sp-card h3 { margin: 8px 0 6px; font-size: 1.08rem; }
++.sp-card p { margin: 0; color: var(--text-secondary, #9fb0c8); font-size: 0.92rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
++.sp-card footer { display: flex; justify-content: space-between; padding: 10px 16px 14px; color: var(--text-secondary, #9fb0c8); font-size: 0.82rem; }
++.sp-card-badge { display: inline-block; font-size: 0.72rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border, #2c3d5c); color: var(--text-secondary, #9fb0c8); }
++.sp-card-badge.is-live { color: #4ade80; border-color: color-mix(in srgb, #4ade80 45%, transparent); }
++
++.sp-search { display: flex; gap: 8px; flex-wrap: wrap; }
++.sp-search input, .sp-search select { border: 1px solid var(--border, #2c3d5c); background: var(--bg-primary, #0b1220); color: inherit; border-radius: 10px; padding: 8px 12px; font: inherit; min-width: 0; }
++.sp-search input { width: min(260px, 60vw); }
++
++/* Templates */
++.sp-templates { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
++.sp-template { display: flex; flex-direction: column; gap: 6px; padding: 16px; border-radius: 16px; border: 1px solid var(--border, #2c3d5c);
++ background: linear-gradient(160deg, color-mix(in srgb, var(--tpl) 16%, var(--bg-secondary, #101828)), var(--bg-secondary, #101828) 60%); }
++.sp-template h3 { margin: 4px 0 0; font-size: 1.05rem; }
++.sp-template p { margin: 0 0 8px; color: var(--text-secondary, #9fb0c8); font-size: 0.9rem; flex: 1; }
++.sp-template form { margin: 0; }
++.sp-template-icon { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; font-size: 18px; color: #fff; background: var(--tpl); }
++.sp-templates.is-compact { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
++
++.sp-how-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 12px; }
++.sp-how-grid > div { padding: 16px; border-radius: 14px; border: 1px solid var(--border, #2c3d5c); background: var(--bg-secondary, #101828); }
++.sp-how-grid i { color: var(--accent, #60a5fa); font-size: 1.2rem; }
++.sp-how-grid h3 { margin: 8px 0 4px; font-size: 1rem; }
++.sp-how-grid p { margin: 0; color: var(--text-secondary, #9fb0c8); font-size: 0.9rem; }
++
++/* New space */
++.sp-ai-form { display: grid; gap: 10px; padding: 18px; border-radius: 18px; border: 1px solid var(--border, #2c3d5c); background: var(--bg-secondary, #101828); }
++.sp-ai-form label { font-weight: 700; }
++.sp-ai-form textarea { width: 100%; border: 1px solid var(--border, #2c3d5c); background: var(--bg-primary, #0b1220); color: inherit; border-radius: 12px; padding: 12px 14px; font: inherit; resize: vertical; }
++.sp-ideas { display: flex; gap: 8px; flex-wrap: wrap; }
++.sp-chip { border: 1px solid var(--border, #2c3d5c); background: transparent; color: var(--text-secondary, #9fb0c8); border-radius: 999px; padding: 5px 12px; font: inherit; font-size: 0.85rem; cursor: pointer; }
++.sp-chip:hover { color: var(--text-primary, #e6edf7); border-color: var(--accent, #60a5fa); }
++
++/* A space's page */
++.sp-frame { margin: 20px 0 8px; border: 1px solid var(--border, #2c3d5c); border-radius: 16px; overflow: hidden; background: #fff; }
++.sp-frame iframe { display: block; width: 100%; height: min(70vh, 640px); border: 0; }
++.sp-caps { list-style: none; padding: 0; margin: 8px 0 16px; display: grid; gap: 8px; }
++.sp-caps li { display: flex; gap: 10px; align-items: baseline; }
++.sp-caps i { color: var(--accent, #60a5fa); width: 18px; text-align: center; }
++
++/* Connect */
++.sp-connect { display: grid; place-items: center; min-height: 60vh; }
++.sp-connect-card { width: 100%; max-width: 520px; padding: 24px; border-radius: 20px; border: 1px solid var(--border, #2c3d5c); background: var(--bg-secondary, #101828); }
++.sp-connect-card .sp-title { font-size: 1.8rem; }
++
++/* Editor: a full-height workspace under the navbar. */
++body[data-page="spaces"] .page:has(.sp-editor) { padding-bottom: 0; }
++.sp-editor { display: grid; grid-template-rows: auto auto 1fr; height: calc(100vh - 64px); min-height: 520px; position: relative; }
++.sp-ed-bar { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border, #2c3d5c); flex-wrap: wrap; }
++.sp-ed-back { color: var(--text-secondary, #9fb0c8); padding: 6px; }
++.sp-ed-name { font-size: 1.1rem; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 40vw; }
++.sp-ed-status { color: var(--text-secondary, #9fb0c8); font-size: 0.85rem; }
++.sp-ed-actions { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
++.sp-ed-ai { padding: 10px 16px; border-bottom: 1px solid var(--border, #2c3d5c); background: color-mix(in srgb, var(--accent, #60a5fa) 6%, transparent); }
++.sp-ed-ai form { display: flex; gap: 8px; align-items: center; }
++.sp-ed-ai i { color: var(--accent, #60a5fa); }
++.sp-ed-ai input { flex: 1; min-width: 0; border: 1px solid var(--border, #2c3d5c); background: var(--bg-primary, #0b1220); color: inherit; border-radius: 10px; padding: 9px 12px; font: inherit; }
++.sp-ed-main { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; }
++.sp-ed-code { display: grid; grid-template-rows: auto 1fr; min-height: 0; border-right: 1px solid var(--border, #2c3d5c); }
++.sp-ed-tabs { display: flex; gap: 2px; padding: 6px 8px 0; overflow-x: auto; border-bottom: 1px solid var(--border, #2c3d5c); }
++.sp-ed-tab { display: inline-flex; align-items: center; gap: 6px; border: 1px solid transparent; border-bottom: 0; border-radius: 8px 8px 0 0; background: transparent; color: var(--text-secondary, #9fb0c8); padding: 6px 10px; font: inherit; font-size: 0.85rem; cursor: pointer; white-space: nowrap; }
++.sp-ed-tab[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #101828); border-color: var(--border, #2c3d5c); }
++.sp-ed-tab .sp-ed-x { opacity: .6; padding: 0 2px; }
++.sp-ed-tab .sp-ed-x:hover { opacity: 1; color: #f87171; }
++.sp-ed-code textarea { width: 100%; height: 100%; border: 0; resize: none; padding: 12px; background: var(--bg-primary, #0b1220); color: inherit; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
++.sp-ed-code .CodeMirror { height: 100%; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--bg-primary, #0b1220); color: var(--text-primary, #e6edf7); }
++.sp-ed-code .CodeMirror-gutters { background: var(--bg-secondary, #101828); border-right: 1px solid var(--border, #2c3d5c); }
++.sp-ed-code .CodeMirror-linenumber { color: var(--text-secondary, #6b7a93); }
++.sp-ed-code .CodeMirror-cursor { border-left-color: var(--text-primary, #e6edf7); }
++.sp-ed-code .CodeMirror-selected, .sp-ed-code .CodeMirror-focused .CodeMirror-selected { background: color-mix(in srgb, var(--accent, #60a5fa) 25%, transparent); }
++.sp-ed-code .cm-s-default .cm-tag { color: #f472b6; } .sp-ed-code .cm-s-default .cm-attribute { color: #fbbf24; } .sp-ed-code .cm-s-default .cm-string, .sp-ed-code .cm-s-default .cm-string-2 { color: #4ade80; }
++.sp-ed-code .cm-s-default .cm-keyword { color: #c084fc; } .sp-ed-code .cm-s-default .cm-comment { color: #7b8aa3; font-style: italic; } .sp-ed-code .cm-s-default .cm-number { color: #fb923c; }
++.sp-ed-code .cm-s-default .cm-def, .sp-ed-code .cm-s-default .cm-property { color: #60a5fa; } .sp-ed-code .cm-s-default .cm-variable { color: var(--text-primary, #e6edf7); }
++.sp-ed-code .cm-s-default .cm-variable-2, .sp-ed-code .cm-s-default .cm-variable-3, .sp-ed-code .cm-s-default .cm-type { color: #93c5fd; }
++.sp-ed-code .cm-s-default .cm-atom, .sp-ed-code .cm-s-default .cm-qualifier, .sp-ed-code .cm-s-default .cm-builtin { color: #fcd34d; }
++.sp-ed-code .cm-s-default .cm-meta, .sp-ed-code .cm-s-default .cm-bracket, .sp-ed-code .cm-s-default .cm-operator { color: #a5b4c8; }
++.sp-ed-code .cm-s-default .cm-error { color: #f87171; }
++.sp-ed-code .CodeMirror-matchingbracket { color: #4ade80 !important; outline: 1px solid color-mix(in srgb, #4ade80 50%, transparent); }
++.sp-ed-preview { display: grid; grid-template-rows: auto 1fr; min-height: 0; }
++.sp-ed-preview-bar { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid var(--border, #2c3d5c); font-size: 0.88rem; }
++.sp-ed-preview iframe { width: 100%; height: 100%; border: 0; background: #fff; }
++.sp-ed-building { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; background: color-mix(in srgb, var(--bg-primary, #0b1220) 88%, transparent); backdrop-filter: blur(4px); z-index: 5; padding: 24px; }
++.sp-ed-building i { font-size: 2.2rem; color: var(--accent, #60a5fa); }
++.sp-ed-building h2 { margin: 12px 0 6px; }
++.sp-ed-building p { max-width: 520px; margin: 4px auto; }
++.sp-ed-dialog { border: 1px solid var(--border, #2c3d5c); border-radius: 18px; background: var(--bg-secondary, #101828); color: var(--text-primary, #e6edf7); width: min(560px, calc(100vw - 32px)); padding: 20px; }
++.sp-ed-dialog::backdrop { background: rgba(0, 0, 0, .55); }
++.sp-ed-dialog h2 { margin: 0 0 12px; }
++.sp-ed-dialog form > label { display: grid; gap: 4px; margin-bottom: 12px; font-weight: 600; font-size: 0.92rem; }
++.sp-ed-dialog input:not([type=checkbox]):not([type=radio]), .sp-ed-dialog textarea { border: 1px solid var(--border, #2c3d5c); background: var(--bg-primary, #0b1220); color: inherit; border-radius: 10px; padding: 8px 11px; font: inherit; font-weight: 400; }
++.sp-ed-dialog fieldset { border: 1px solid var(--border, #2c3d5c); border-radius: 12px; padding: 10px 12px; margin: 0 0 12px; }
++.sp-ed-dialog legend { font-weight: 600; font-size: 0.92rem; padding: 0 4px; }
++.sp-check { display: flex; gap: 8px; align-items: center; padding: 3px 0; font-weight: 400; }
++.sp-ed-dialog hr { border: 0; border-top: 1px solid var(--border, #2c3d5c); margin: 16px 0; }
++.sp-dialog-actions { justify-content: flex-start; }
++
++/* On a phone the code and the preview take turns on the whole screen (a switch under the AI box), so a change and
++ its result are never in different places; the preview is what opens first. */
++.sp-ed-switch { display: none; }
++@media (max-width: 860px) {
++ .sp-editor { grid-template-rows: auto auto auto 1fr; height: calc(100dvh - 64px); }
++ .sp-ed-bar { padding: 8px 12px; gap: 6px; } .sp-ed-actions { margin-left: 0; width: 100%; } .sp-ed-actions .sp-btn { flex: 1; justify-content: center; padding: 8px 10px; }
++ .sp-ed-ai { padding: 8px 12px; }
++ .sp-ed-switch { display: flex; gap: 4px; padding: 6px 12px; border-bottom: 1px solid var(--border, #2c3d5c); }
++ .sp-ed-switch button { flex: 1; border: 1px solid var(--border, #2c3d5c); background: transparent; color: var(--text-secondary, #9fb0c8); border-radius: 10px; padding: 7px; font: inherit; font-weight: 600; }
++ .sp-ed-switch button[aria-selected="true"] { background: var(--bg-secondary, #101828); color: var(--text-primary, #e6edf7); border-color: var(--accent, #60a5fa); }
++ .sp-ed-main { grid-template-columns: 1fr; }
++ .sp-ed-code { border-right: 0; }
++ .sp-editor[data-view="preview"] .sp-ed-code, .sp-editor[data-view="code"] .sp-ed-preview { display: none; }
++ .sp-ed-name { max-width: 70vw; }
++}
ddiff --git a/public/js/space-editor.js b/public/js/space-editor.js
nnew file mode 100644
iindex 0000000..b442902
---- /dev/null
++++ b/public/js/space-editor.js
@@@ -0,0 +1,188 @@
++/* The space editor (server/spaces/render.js editorPage): files in tabs (CodeMirror when it loaded, a textarea when not),
++ a live preview in a sandboxed frame (an opaque origin: the space's code never touches openvibe.space), the preview's
++ SDK calls run through this page as the signed-in maker (POST /spaces/:slug/preview), AI changes (POST, then poll
++ GET /spaces/:slug/ai), settings, publishing and deleting. Every call carries the form token in X-CSRF-Token. */
++(function () {
++ 'use strict';
++ var boot = JSON.parse(document.getElementById('sp-boot').textContent);
++ var space = boot.space;
++ var files = boot.files.map(function (f) { return { path: f.path, content: f.content }; });
++ var current = files.some(function (f) { return f.path === 'index.html'; }) ? 'index.html' : (files[0] && files[0].path);
++ var dirty = false;
++ var $ = function (id) { return document.getElementById(id); };
++ var statusEl = $('ed-status'), frame = $('ed-frame'), tabs = $('ed-tabs'), area = $('ed-code');
++ var base = '/spaces/' + encodeURIComponent(space.slug);
++
++ function status(text, kind) { statusEl.textContent = text || ''; statusEl.dataset.kind = kind || ''; }
++ function call(method, path, body) {
++ return fetch(base + path, { method: method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': boot.csrf }, body: body ? JSON.stringify(body) : undefined })
++ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) {
++ if (!r.ok) { var e = new Error((j.error && j.error.message) || ('Request failed (' + r.status + ')')); e.code = j.error && j.error.code; e.status = r.status; throw e; }
++ return j;
++ }); });
++ }
++
++ // ── The code editor ──
++ var cm = null;
++ function modeOf(path) { return /\.css$/i.test(path) ? 'css' : /\.(m?js|json)$/i.test(path) ? 'javascript' : 'htmlmixed'; }
++ if (window.CodeMirror) {
++ cm = window.CodeMirror.fromTextArea(area, { lineNumbers: true, lineWrapping: true, tabSize: 2, indentUnit: 2, mode: 'htmlmixed', extraKeys: { 'Ctrl-S': function () { save(); }, 'Cmd-S': function () { save(); } } });
++ cm.on('change', function (_, ch) { if (ch.origin !== 'setValue') edited(); });
++ } else {
++ area.addEventListener('input', edited);
++ }
++ function getText() { return cm ? cm.getValue() : area.value; }
++ function setText(t, path) { if (cm) { cm.setOption('mode', modeOf(path)); cm.setValue(t); cm.clearHistory(); } else area.value = t; }
++ function fileOf(path) { return files.find(function (f) { return f.path === path; }); }
++ function stash() { var f = fileOf(current); if (f) f.content = getText(); }
++
++ function renderTabs() {
++ tabs.replaceChildren();
++ files.forEach(function (f) {
++ var b = document.createElement('button');
++ b.type = 'button'; b.className = 'sp-ed-tab'; b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', f.path === current ? 'true' : 'false');
++ b.append(f.path);
++ if (f.path !== 'index.html') {
++ var x = document.createElement('span'); x.className = 'sp-ed-x'; x.textContent = '×'; x.title = 'Delete ' + f.path; x.setAttribute('role', 'button'); x.setAttribute('aria-label', 'Delete ' + f.path);
++ x.addEventListener('click', function (e) { e.stopPropagation(); if (!confirm('Delete ' + f.path + '?')) return; stash(); files = files.filter(function (o) { return o !== f; }); if (current === f.path) open('index.html'); else renderTabs(); edited(); });
++ b.append(x);
++ }
++ b.addEventListener('click', function () { open(f.path); });
++ tabs.append(b);
++ });
++ var add = document.createElement('button');
++ add.type = 'button'; add.className = 'sp-ed-tab'; add.textContent = '+ File'; add.title = 'Add a file';
++ add.addEventListener('click', function () {
++ var name = (prompt('File name (for example style.css or game.js)') || '').trim().replace(/^\/+/, '');
++ if (!name) return;
++ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*){0,3}$/.test(name)) { alert('Use letters, digits, ".", "-" and "_" (up to four folders).'); return; }
++ if (fileOf(name)) { open(name); return; }
++ stash(); files.push({ path: name, content: '' }); open(name); edited();
++ });
++ tabs.append(add);
++ }
++ function open(path) { stash(); current = path; var f = fileOf(path); setText(f ? f.content : '', path); renderTabs(); if (cm) cm.focus(); }
++
++ // ── The preview ──
++ var runTimer = null;
++ function edited() { dirty = true; status('Unsaved changes'); clearTimeout(runTimer); runTimer = setTimeout(run, 1200); }
++ /** index.html with the space's own scripts and stylesheets inlined (a srcdoc frame has no address to load them from). */
++ function previewDoc() {
++ stash();
++ var index = (fileOf('index.html') || { content: '' }).content;
++ var esc = function (s) { return s.replace(/<\/(script)/gi, '<\\/$1'); };
++ return index
++ .replace(/<script([^>]*?)\ssrc=["']\.?\/?([^"':]+)["']([^>]*)>\s*<\/script>/gi, function (m, a, src, b) {
++ var f = fileOf(src); return f ? '<script' + a + b + '>' + esc(f.content) + '</script>' : m;
++ })
++ .replace(/<link([^>]*?)\shref=["']\.?\/?([^"':]+\.css)["']([^>]*)>/gi, function (m, a, href, b) {
++ var f = fileOf(href); return f && /stylesheet/i.test(a + b) ? '<style>' + f.content + '</style>' : m;
++ });
++ }
++ function run() { frame.srcdoc = previewDoc(); }
++ $('ed-run').addEventListener('click', run);
++
++ // The preview's SDK calls: only from our frame, answered to it.
++ window.addEventListener('message', function (ev) {
++ var d = ev.data;
++ if (ev.source !== frame.contentWindow || !d || d.ovSpacePreview !== 1 || typeof d.op !== 'string') return;
++ var reply = function (msg) { try { frame.contentWindow.postMessage(Object.assign({ ovSpacePreview: 1, id: d.id }, msg), '*'); } catch (e) { /* the frame reloaded */ } };
++ call('POST', '/preview', { op: d.op, args: d.args || {} })
++ .then(function (j) { reply({ ok: true, result: j.result }); }, function (e) { reply({ ok: false, code: e.code || 'error', message: e.message }); });
++ });
++
++ // ── Saving ──
++ function save() {
++ stash();
++ status('Saving…');
++ return call('PUT', '/files', { files: files }).then(function () { dirty = false; status('Saved'); run(); }, function (e) { status(e.message, 'bad'); throw e; });
++ }
++ $('ed-save').addEventListener('click', function () { save().catch(function () {}); });
++ document.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save().catch(function () {}); } });
++ window.addEventListener('beforeunload', function (e) { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
++
++ // ── AI changes ──
++ var overlay = $('ed-building');
++ function building(on, text) { overlay.hidden = !on; $('ed-building-text').textContent = text ? '“' + text + '”' : ''; }
++ function poll() {
++ call('GET', '/ai').then(function (j) {
++ if (j.status === 'running') return setTimeout(poll, 3000);
++ building(false);
++ if (j.status === 'done') {
++ files = j.files.map(function (f) { return { path: f.path, content: f.content }; });
++ space = Object.assign(space, j.space);
++ $('ed-name').textContent = space.name; document.title = 'Edit ' + space.name;
++ current = 'index.html'; setText(fileOf('index.html').content, 'index.html'); renderTabs();
++ dirty = false; status('The AI changed your space'); run();
++ } else if (j.status === 'failed') status(j.message || 'The AI could not do that', 'bad');
++ }, function () { setTimeout(poll, 5000); });
++ }
++ $('ed-ai-form').addEventListener('submit', function (e) {
++ e.preventDefault();
++ var input = $('ed-ai'), prompt = input.value.trim();
++ if (!prompt) return;
++ var go = function () {
++ return call('POST', '/ai', { prompt: prompt }).then(function () { input.value = ''; building(true, prompt); setTimeout(poll, 3000); }, function (err) { status(err.message, 'bad'); });
++ };
++ if (dirty) save().then(go, function () {}); else go();
++ });
++ if (space.building) { building(true, space.building_prompt); setTimeout(poll, 1500); }
++
++ // ── Settings ──
++ var dialog = $('ed-dialog'), form = $('ed-settings-form');
++ $('ed-settings').addEventListener('click', function () {
++ form.elements.name.value = space.name || '';
++ form.elements.description.value = space.description || '';
++ form.elements.instructions.value = space.instructions || '';
++ Array.prototype.forEach.call(form.querySelectorAll('input[name=cap]'), function (c) { c.checked = space.capabilities.indexOf(c.value) >= 0; });
++ Array.prototype.forEach.call(form.querySelectorAll('input[name=visibility]'), function (r) { r.checked = r.value === (space.visibility || 'public'); });
++ $('ed-unpublish').hidden = !space.published;
++ dialog.showModal();
++ });
++ dialog.addEventListener('close', function () {
++ if (dialog.returnValue !== 'save') return;
++ var caps = Array.prototype.filter.call(form.querySelectorAll('input[name=cap]'), function (c) { return c.checked; }).map(function (c) { return c.value; });
++ var vis = form.querySelector('input[name=visibility]:checked');
++ call('PUT', '/settings', { name: form.elements.name.value, description: form.elements.description.value, instructions: form.elements.instructions.value, capabilities: caps, visibility: vis ? vis.value : 'public' })
++ .then(function (j) { space = Object.assign(space, j.space); $('ed-name').textContent = space.name; status('Settings saved'); }, function (e) { status(e.message, 'bad'); });
++ });
++
++ // ── Publishing ──
++ var publishBtn = $('ed-publish');
++ function publishLabel() { publishBtn.lastChild.textContent = space.published ? ' Update' : ' Publish'; }
++ publishBtn.addEventListener('click', function () {
++ var go = function () {
++ status('Publishing…');
++ return call('POST', '/publish').then(function (j) {
++ space = Object.assign(space, j.space); publishLabel();
++ statusEl.replaceChildren('Live at ');
++ var a = document.createElement('a'); a.href = j.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = j.url.replace(/^https?:\/\//, ''); statusEl.append(a);
++ }, function (e) { status('', ''); alert(e.message); });
++ };
++ if (dirty) save().then(go, function () {}); else go();
++ });
++ $('ed-unpublish').addEventListener('click', function () {
++ if (!confirm('Unpublish? Its address stops working until you publish again.')) return;
++ call('POST', '/unpublish').then(function (j) { space = Object.assign(space, j.space); publishLabel(); dialog.close(); status('Unpublished'); }, function (e) { alert(e.message); });
++ });
++ $('ed-delete').addEventListener('click', function () {
++ if (!confirm('Delete "' + space.name + '" for good? Its files, lists and everyone\'s saved data in it go too.')) return;
++ call('POST', '/delete').then(function () { dirty = false; location.href = '/spaces/mine'; }, function (e) { alert(e.message); });
++ });
++ if (!boot.canPublish) publishBtn.title = 'Publishing at its own address opens with OpenVibe.Space\'s public hosting; the preview runs it now.';
++
++ // Phone: the Preview / Code switch (desktop shows both side by side and hides it).
++ var editorEl = $('sp-editor');
++ Array.prototype.forEach.call(document.querySelectorAll('.sp-ed-switch button'), function (b) {
++ b.addEventListener('click', function () {
++ editorEl.dataset.view = b.dataset.view;
++ Array.prototype.forEach.call(document.querySelectorAll('.sp-ed-switch button'), function (o) { o.setAttribute('aria-selected', o === b ? 'true' : 'false'); });
++ if (b.dataset.view === 'code' && cm) setTimeout(function () { cm.refresh(); cm.focus(); }, 0);
++ if (b.dataset.view === 'preview' && dirty) run();
++ });
++ });
++
++ setText((fileOf(current) || { content: '' }).content, current);
++ renderTabs();
++ run();
++})();
ddiff --git a/server/app.js b/server/app.js
iindex df31719..b635d6f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -84,7 +84,7 @@ async function createApp(opts = {}) {
app.locals.auth = auth;
app.locals.config = config;
app.locals.db = db;
-- const hosting = require('./hosting').createHosting(app, { db, auth, config: opts.hostingConfig, now: opts.now, resolver: opts.resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow, log: opts.log, storageDir: opts.storageDir, forumRedirect });
++ const hosting = require('./hosting').createHosting(app, { db, auth, config: opts.hostingConfig, now: opts.now, resolver: opts.resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow, log: opts.log, storageDir: opts.storageDir, forumRedirect, spacesAiClient: opts.spacesAiClient || null });
app.locals.hosting = hosting;
hosting.mountDispatch(app);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
@@@ -158,7 +158,7 @@ async function createApp(opts = {}) {
.send(seo.llmsFull({ site: SITE_NAME, summary: discovery.SUMMARY, base: config.baseUrl, sections: await discovery.llmsFullSections(), maxBytes: 512 * 1024 }))));
app.get('/sitemap.xml', async (_req, res) => {
let rows;
-- try { rows = await discovery.sitemapRows(release.full().released_at === release.full().booted_at ? undefined : release.full().released_at); } catch { return res.status(503).end(); }
++ try { rows = await discovery.sitemapRows(release.full().released_at === release.full().booted_at ? undefined : release.full().released_at, db); } catch { return res.status(503).end(); }
res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(rows));
});
ddiff --git a/server/discovery.js b/server/discovery.js
iindex 9e7dc67..5b9ac53 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -5,22 +5,25 @@
* /llms-full.txt sections and the sitemap rows. Only data lives here; openvibe-shared/seo writes
* every format (server/app.js).
*
-- * Space serves static hosting for staff; dynamic pages and spaces are planned. The forum is
++ * Space is where people make spaces (small apps on their OpenVibe account, server/spaces/) and serves static hosting
++ * (staff until the public launch). The forum is
* OpenVibe.Community's (owner decision, 2026-10-08), so nothing forum
* shaped appears here: no /s entries, no feeds.
*/
const config = require('./config');
const { abs } = require('./render/layout');
--const ROBOTS_DISALLOW = ['/api/', '/auth/', '/dashboard', '/projects/', '/sites/', '/deploys/', '/domains/', '/preview/', '/*?sso='];
--const SUMMARY = 'OpenVibe.Space serves tested static hosting for OpenVibe staff: immutable deploys, rollback, previews, Git ingest and verified custom domains. Dynamic pages and spaces are planned. The forum moved to OpenVibe.Community.';
++const ROBOTS_DISALLOW = ['/api/', '/auth/', '/dashboard', '/projects/', '/sites/', '/deploys/', '/domains/', '/preview/', '/*?sso=', '/spaces/new', '/spaces/mine', '/spaces/*/edit', '/spaces/*/connect', '/_openvibe/'];
++const SUMMARY = 'OpenVibe.Space is where people make spaces: small apps on their OpenVibe account, built by OpenVibe AI from a description or started from a template and changed in a browser editor, that sign visitors in with OpenVibe, keep shared lists and private data, and talk to OpenVibe AI. It also serves tested static hosting (immutable deploys, rollback, previews, Git ingest, verified custom domains), open to OpenVibe staff until the public launch. The forum that used to live here is on OpenVibe.Community.';
// ── llms.txt / llms-full.txt (llmstxt.org) ───────────────────
function llmsSections() {
const u = config.baseUrl;
return [
{ title: 'This site', links: [
-- { title: 'Home', url: `${u}/`, note: 'static hosting for staff; dynamic pages and spaces planned' },
++ { title: 'Home', url: `${u}/`, note: 'spaces and static hosting' },
++ { title: 'Spaces', url: `${u}/spaces`, note: 'the gallery of published spaces and the templates' },
++ { title: 'Make a space', url: `${u}/spaces/new`, note: 'describe it to the AI builder, or start from a template (sign-in required)' },
] },
{ title: 'The network', links: [
{ title: 'The forum', url: `${config.communityUrl}/s`, note: 'OpenVibe.Community hosts the forum' },
@@@ -34,13 +37,24 @@ function llmsSections() {
}
async function llmsFullSections() {
-- return [{ title: 'OpenVibe.Space', pages: [{ title: 'What Space will host', url: '/', text: SUMMARY }] }];
++ return [{ title: 'OpenVibe.Space', pages: [{ title: 'What Space does', url: '/', text: SUMMARY }] }];
}
// ── sitemap.xml rows ─────────────────────────────────────────
--/** Space's only page is the home page. */
--async function sitemapRows(releaseLastmod) {
-- return [{ loc: abs('/'), lastmod: releaseLastmod, changefreq: 'weekly', priority: '1.0' }];
++/** The home page, the spaces gallery and every published public space's page. */
++async function sitemapRows(releaseLastmod, db = null) {
++ const rows = [
++ { loc: abs('/'), lastmod: releaseLastmod, changefreq: 'weekly', priority: '1.0' },
++ { loc: abs('/spaces'), lastmod: releaseLastmod, changefreq: 'daily', priority: '0.9' },
++ ];
++ let spaces = [];
++ try {
++ spaces = await (db || require('./db').getDb()).prepare(`SELECT a.slug, a.updated_at FROM space_apps a JOIN space_sites s ON s.id = a.site_id
++ WHERE a.status = 'active' AND a.visibility = 'public' AND s.status = 'active' AND NOT EXISTS (SELECT 1 FROM space_takedowns t WHERE t.lifted_at IS NULL AND ((t.target_kind = 'site' AND t.target_id = s.id) OR (t.target_kind = 'project' AND t.target_id = s.project_id)))
++ ORDER BY a.published_at DESC LIMIT 5000`).all();
++ } catch { spaces = []; }
++ for (const r of spaces) rows.push({ loc: abs(`/spaces/${r.slug}`), lastmod: new Date(Number(r.updated_at)).toISOString(), changefreq: 'weekly', priority: '0.6' });
++ return rows;
}
module.exports = { ROBOTS_DISALLOW, SUMMARY, llmsSections, llmsFullSections, sitemapRows };
ddiff --git a/server/hosting-config.js b/server/hosting-config.js
iindex 2da7708..7db4858 100644
---- a/server/hosting-config.js
++++ b/server/hosting-config.js
@@@ -147,6 +147,28 @@ function load(env = process.env) {
sites: { extraReservedNames: list(env.SPACE_RESERVED_SITE_NAMES).map((d) => d.toLowerCase()) },
++ // Spaces (server/spaces/): apps people make here. Making, editing, previewing and the AI builder are open to
++ // everyone signed in; publishing follows publicHosting (or staff). The limits keep one person or one space from
++ // filling the database or spending everyone's AI.
++ spaces: {
++ maxPerOwner: int(env.SPACE_SPACES_PER_OWNER, 25),
++ maxFiles: int(env.SPACE_SPACE_MAX_FILES, 20),
++ maxBytes: int(env.SPACE_SPACE_MAX_BYTES, 1 * MiB),
++ itemsPerSpace: int(env.SPACE_SPACE_ITEMS, 20000),
++ itemBytes: int(env.SPACE_SPACE_ITEM_BYTES, 8 * 1024),
++ kvKeysPerPerson: int(env.SPACE_SPACE_KV_KEYS, 200),
++ kvBytes: int(env.SPACE_SPACE_KV_BYTES, 16 * 1024),
++ writesPerMinute: int(env.SPACE_SPACE_WRITES_PER_MINUTE, 30),
++ apiPerMinute: int(env.SPACE_SPACE_API_PER_MINUTE, 300),
++ aiBuildsPerDay: int(env.SPACE_AI_BUILDS_PER_DAY, 20),
++ aiAsksPerMinute: int(env.SPACE_AI_ASKS_PER_MINUTE, 6),
++ aiAsksPerSpacePerDay: int(env.SPACE_AI_ASKS_PER_SPACE_PER_DAY, 500),
++ ai: {
++ enabled: String(env.SPACE_AI || 'on').trim().toLowerCase() !== 'off',
++ url: trim(env.OV_AI_INTERNAL_URL || env.OV_AI_URL || 'http://127.0.0.1:4700'),
++ },
++ },
++
worker: {
enabled: env.SPACE_WORKER !== 'off',
id: env.SPACE_WORKER_ID || `space-${process.pid}`,
ddiff --git a/server/hosting.js b/server/hosting.js
iindex 8ba0ee9..75b3cad 100644
---- a/server/hosting.js
++++ b/server/hosting.js
@@@ -25,7 +25,7 @@ const { createWorker } = require('./worker');
const { createSpaceBilling } = require('./billing');
const { createStore } = require('./db');
--function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fetchImpl, indexnow: injectedIndexNow, log = console, storageDir, valkey, forumRedirect } = {}) {
++function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fetchImpl, indexnow: injectedIndexNow, log = console, storageDir, valkey, forumRedirect, spacesAiClient = null } = {}) {
const config = suppliedConfig || load();
const store = createStore(db, { now });
const media = config.objectStore.mode === 'media' ? {
@@@ -56,13 +56,35 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
const siteConfig = createSiteConfig({ store });
const siteSources = createSiteSources({ store, sites });
const viewers = createViewerResolver({ auth, config, log });
-- const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
++ const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, apps: () => app.locals.ctx && app.locals.ctx.apps, log });
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const worker = createWorker({ config, store, domains, blobs, outbox, log });
const billing = createSpaceBilling({ db, config, now: store.now, ...(fetchImpl ? { fetchImpl } : {}), log });
const ctx = { config, store, blobs, outbox, billing, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, auth, viewers, tenant, worker, uploadGate, indexnow, log, valkey: valkey || null };
ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry: app.locals.metrics, valkey: ctx.valkey });
app.locals.ctx = ctx;
++
++ // Spaces (server/spaces/): apps people make here and publish as sites of their Spaces project.
++ ctx.spaces = require('./spaces/spaces').createSpaces({ store, config, projects, sites, deploys, takedowns, log });
++ ctx.spacesAi = require('./spaces/ai').createSpacesAi({ config, client: spacesAiClient, log });
++ ctx.spaceData = require('./spaces/data').createSpaceData({ store, config, spaces: ctx.spaces, ai: ctx.spacesAi });
++ {
++ const { sdkSource } = require('./spaces/sdk');
++ const bySite = new Map();
++ ctx.apps = {
++ // The space a site serves (cached briefly: every request to a tenant asks).
++ async forSite(siteId) {
++ const hit = bySite.get(siteId);
++ if (hit && store.now() - hit.at < 15000) return hit.space;
++ const space = await ctx.spaces.forSite(siteId);
++ bySite.set(siteId, { space, at: store.now() });
++ if (bySite.size > 2000) bySite.clear();
++ return space;
++ },
++ forget(siteId) { bySite.delete(siteId); },
++ sdk: (space) => sdkSource({ mode: 'live', space, api: new URL(config.baseUrl).origin, avatar: `${config.networkUrl}/avatar/{username}` }),
++ };
++ }
if (app.locals.metrics && typeof app.locals.metrics.gauge === 'function') {
app.locals.metrics.gauge({
name: 'space_sites', help: 'Active tenant sites',
@@@ -94,8 +116,14 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
if (forumRedirect) app.use(forumRedirect);
app.use(contracts.http.middleware());
app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), require('./registry/resource-control').router({ guard: guard('space.resource.control') }), resourceIndex.router({ guard: guard('space.resource.read') }));
++ app.use('/api/v1/apps', require('./spaces/api').createSpacesApi({ config, spaces: ctx.spaces, data: ctx.spaceData, sites, takedowns, log }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));
++ {
++ const spacesPages = require('./spaces/pages').createSpacesPages(ctx);
++ const spacesLimit = rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false });
++ app.use((req, res, next) => (req.path === '/spaces' || req.path.startsWith('/spaces/') || req.path === '/_openvibe/sdk.js' ? spacesLimit(req, res, () => spacesPages(req, res, next)) : next()));
++ }
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
require('./limits').mountLimits(app, config);
};
ddiff --git a/server/http/tenant.js b/server/http/tenant.js
iindex 20aa2ad..efbb7e3 100644
---- a/server/http/tenant.js
++++ b/server/http/tenant.js
@@@ -52,6 +52,29 @@ const TENANT_CSP = [
'upgrade-insecure-requests',
].join('; ');
++/**
++ * A published space (server/spaces/) is an app, not only a static site: its own inline scripts, pinned CDN libraries,
++ * fonts, and calls to Space's API for the space SDK. It still runs only on its own registrable origin, so the network's
++ * cookies and pages stay out of its reach; the details page on openvibe.space may embed it.
++ */
++function appCsp(config) {
++ const api = new URL(config.baseUrl).origin;
++ return [
++ "default-src 'self'",
++ "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com",
++ "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com https://fonts.googleapis.com",
++ "img-src 'self' data: blob: https:",
++ "font-src 'self' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.gstatic.com",
++ "media-src 'self' data: blob: https:",
++ `connect-src 'self' ${api}`,
++ "object-src 'none'",
++ "base-uri 'self'",
++ `form-action 'self' ${api}`,
++ `frame-ancestors 'self' ${api}`,
++ 'upgrade-insecure-requests',
++ ].join('; ');
++}
++
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
/**
@@@ -123,7 +146,7 @@ function applyConfiguredHeaders(res, headers) {
}
}
--function createTenantServer({ store, config, blobs, takedowns = null, siteConfig = null, access = null, log = console }) {
++function createTenantServer({ store, config, blobs, takedowns = null, siteConfig = null, access = null, apps = null, log = console }) {
const { db } = store;
const q = {
siteByName: db.prepare("SELECT s.*, p.environment FROM space_sites s JOIN space_projects p ON p.id = s.project_id WHERE s.name = ? AND s.status = 'active' AND p.status = 'active'"),
@@@ -318,6 +341,9 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
// tenant can never weaken CSP/HSTS/nosniff or set a cookie.
if (cfg) applyConfiguredHeaders(res, cfg.headers);
baseHeaders(res, { sandbox: site.environment === 'sandbox' });
++ // A published space: the app policy, and the space SDK at /_openvibe/sdk.js (never one of its files).
++ const space = apps && apps() ? await apps().forSite(site.id) : null;
++ if (space) res.setHeader('Content-Security-Policy', appCsp(config));
if (req.method !== 'GET' && req.method !== 'HEAD') {
return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
}
@@@ -338,6 +364,12 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
const q0 = target.indexOf('?');
const rawPath = q0 >= 0 ? target.slice(0, q0) : target;
const search = q0 >= 0 ? target.slice(q0) : '';
++ if (space && rawPath === '/_openvibe/sdk.js') {
++ res.statusCode = 200;
++ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
++ res.setHeader('Cache-Control', 'no-cache');
++ return res.end(req.method === 'HEAD' ? undefined : apps().sdk(space));
++ }
// A configured redirect: an exact path to a local path (validated on write, never an open redirect).
// It does not need an active deploy, and it is checked before anything is read from one.
if (cfg && cfg.redirects.length) {
@@@ -436,4 +468,4 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
return { resolve, handle, preview, unknownHost, normaliseHost, candidates, TENANT_CSP };
}
--module.exports = { createTenantServer, normaliseHost, TENANT_CSP };
++module.exports = { createTenantServer, normaliseHost, TENANT_CSP, appCsp };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex facc0d4..da2d301 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -66,6 +66,7 @@ function navbarInit(opts) {
// decision, 2026-10-08), so its link points there rather than at a /s that redirects.
links: [
{ label: 'Home', href: '/', active: opts.active === 'home' },
++ { label: 'Spaces', href: '/spaces', active: opts.active === 'spaces' },
{ label: 'Hosting', href: '/dashboard' },
{ label: 'The forum', href: `${config.communityUrl}/s` },
],
@@@ -122,7 +123,7 @@ function renderPage(o) {
// with the navbar init, the noscript nav and the SSR footer; the rest of the head is this site's own.
const html = shell.page({
name: SITE_NAME, service: 'space', lang: 'en',
-- title: o.title || `${SITE_NAME} — static hosting`,
++ title: o.title || `${SITE_NAME} — spaces and static hosting`,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
siteName: SITE_NAME, description, canonical, robots: o.robots || 'index,follow',
type: o.ogType || 'website', image: o.ogImage || DEFAULT_OG_IMAGE, imageAlt: o.imageAlt,
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex f5faa83..ee0c4ab 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -29,15 +29,22 @@ function homePage() {
const q = defaultQuotas();
const row = (label, key, fmt = String) => ({ label, values: [fmt(q.production[key]), fmt(q.sandbox[key])] });
const body = sc.hero({
-- eyebrow: 'OpenVibe.Space · static hosting',
-- title: 'Publish a static site.',
-- accent: 'Keep every deploy.',
-- lede: 'Static site hosting is built and tested: immutable, content-addressed deploys, one-step rollback, previews, Git ingest and verified custom domains. It is open to OpenVibe staff only until public launch.',
++ eyebrow: 'OpenVibe.Space · spaces',
++ title: 'Make a space.',
++ accent: 'An app on your OpenVibe account.',
++ lede: 'Describe what you want and OpenVibe AI builds it, or start from a template and change anything in the editor. A space signs visitors in with OpenVibe, keeps shared lists and private data, and talks to OpenVibe AI.',
actions: [
-- { label: 'Hosting dashboard', href: '/dashboard', primary: true, icon: 'fa-globe' },
-- { label: 'How it works', href: '#how', icon: 'fa-route' },
++ { label: 'Make a space', href: '/spaces/new', primary: true, icon: 'fa-wand-magic-sparkles' },
++ { label: 'Browse spaces', href: '/spaces', icon: 'fa-shapes' },
++ ],
++ note: 'The builder, the editor and the preview work for everyone signed in. Publishing a space at its own address opens with public hosting.',
++ }) + sc.cta({
++ title: 'Static hosting: publish a site, keep every deploy.',
++ text: 'Static site hosting is built and tested: immutable, content-addressed deploys, one-step rollback, previews, Git ingest and verified custom domains. It is open to OpenVibe staff only until public launch. Public hosting follows abuse handling and a designated DMCA agent. Until then only OpenVibe staff can create projects.',
++ actions: [
++ { label: 'Hosting dashboard', href: '/dashboard', primary: true },
++ { label: 'How it works', href: '#how' },
],
-- note: 'Public hosting follows abuse handling and a designated DMCA agent. Until then only OpenVibe staff can create projects.',
}) + sc.steps({
id: 'how',
title: 'How a deploy works',
ddiff --git a/server/spaces/ai.js b/server/spaces/ai.js
nnew file mode 100644
iindex 0000000..f57c1ce
---- /dev/null
++++ b/server/spaces/ai.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * Space → OpenVibe.AI for spaces (namespace space; Network grants Space ai.run.create / ai.run.read on space.*):
++ *
++ * build(person, { prompt, name, current }) space.app.build, the AI builder: starts a run on behalf of the person
++ * building and answers its id (a whole app takes longer than one request
++ * may wait, so the editor polls result(id)).
++ * result(runId) { status: 'running' | 'succeeded' | 'failed', output, error }
++ * ask(space, person, { transcript, message }) space.app.ask, a space's own AI, on behalf of the visitor: the text.
++ *
++ * Space's own service token (client space, audience openvibe.ai) through openvibe-sdk/ai. Without
++ * OV_OAUTH_CLIENT_SECRET or with SPACE_AI=off there is no AI, and every call says so.
++ */
++const { ApiError } = require('../http/errors');
++
++function createSpacesAi({ config, client: injected = null, log = console }) {
++ let ai = injected;
++ const enabled = () => Boolean(injected || (config.spaces.ai.enabled && config.oauth.clientSecret));
++
++ function client() {
++ if (ai) return ai;
++ const { createClient } = require('openvibe-sdk');
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ const { createAiClient } = require('openvibe-sdk/ai');
++ const tokens = createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read',
++ });
++ ai = createAiClient(createClient({ tokenProvider: tokens, timeoutMs: 70000 }), { baseUrl: config.spaces.ai.url });
++ return ai;
++ }
++ const unavailable = () => new ApiError(503, 'space.ai_unavailable', 'OpenVibe AI is not available here right now');
++ const subjectRef = (person) => ({ type: 'user', id: person.subject });
++
++ async function build(person, { prompt, name, current, spaceId }) {
++ if (!enabled()) throw unavailable();
++ const input = { prompt: String(prompt || '').slice(0, 4000), ...(name ? { name: String(name).slice(0, 40) } : {}), ...(current && current.length ? { current } : {}) };
++ let body;
++ try {
++ body = await client().runs.create('space.app.build', input, {
++ wait: 0, onBehalfOf: subjectRef(person),
++ ...(spaceId ? { attribution: { service: 'space', type: 'space', id: spaceId } } : {}),
++ });
++ } catch (err) {
++ log.warn(`[Spaces AI] build: ${err && err.message}`);
++ if (err && (err.status === 429 || err.code === 'quota.exceeded')) throw new ApiError(429, 'quota', 'your OpenVibe AI allowance for today is used up; try again tomorrow');
++ throw unavailable();
++ }
++ const run = body && (body.run || body);
++ if (!run || !run.id) throw unavailable();
++ return run.id;
++ }
++
++ async function result(runId) {
++ if (!enabled()) throw unavailable();
++ let run;
++ try { const r = await client().runs.get(runId); run = r && (r.run || r); } catch (err) {
++ log.warn(`[Spaces AI] result ${runId}: ${err && err.message}`);
++ return { status: 'running' };
++ }
++ if (!run) return { status: 'running' };
++ if (run.status === 'succeeded' && run.output) return { status: 'succeeded', output: run.output, synthetic: Boolean(run.synthetic) };
++ if (['failed', 'cancelled', 'expired'].includes(run.status) || run.status === 'succeeded') {
++ return { status: 'failed', error: (run.error && run.error.code) || 'failed' };
++ }
++ return { status: 'running' };
++ }
++
++ async function ask(space, person, { transcript, message }) {
++ if (!enabled()) throw unavailable();
++ let body;
++ try {
++ body = await client().runs.create('space.app.ask', {
++ space: String(space.name || 'Space').slice(0, 40), instructions: String(space.instructions || '').slice(0, 4000),
++ transcript: String(transcript || '').slice(-16000), message: String(message || '').slice(0, 4000),
++ }, { wait: 30000, onBehalfOf: subjectRef(person), attribution: { service: 'space', type: 'space', id: space.id } });
++ } catch (err) {
++ if (err && (err.status === 429 || err.code === 'quota.exceeded')) throw new ApiError(429, 'quota', 'the OpenVibe AI allowance for today is used up; try again tomorrow');
++ log.warn(`[Spaces AI] ask: ${err && err.message}`);
++ throw unavailable();
++ }
++ const run = body && (body.run || body);
++ if (!run || run.status !== 'succeeded' || !run.output || typeof run.output.text !== 'string') throw unavailable();
++ return run.output.text.trim();
++ }
++
++ return { enabled, build, result, ask };
++}
++
++module.exports = { createSpacesAi };
ddiff --git a/server/spaces/api.js b/server/spaces/api.js
nnew file mode 100644
iindex 0000000..c11ed58
---- /dev/null
++++ b/server/spaces/api.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * The API a published space's page calls (the space SDK, server/spaces/sdk.js), on the dashboard host:
++ *
++ * POST /api/v1/apps/:id/token { code, code_verifier } → { token, expires_in, user, capabilities }
++ * DELETE /api/v1/apps/:id/token signs the visitor out of the space (the Bearer token is dropped)
++ * POST /api/v1/apps/:id/run { op, args } → { result }: me, items.list|add|set|remove, kv.get|set|remove, ai.ask
++ * GET /api/v1/apps/:id/items/:collection?limit=&before= the shared list, for anyone (no token needed)
++ *
++ * Only the space's own origin (https://<slug>.openvibe.website) may call it from a browser: CORS answers that one
++ * origin and never credentials; a request that names another Origin is refused. The token is a Bearer header the
++ * space's page holds; cookies are never read here. A space must be published (and not taken down) to be called.
++ */
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const { ApiError } = require('../http/errors');
++
++function createSpacesApi({ config, spaces, data, sites, takedowns, log = console }) {
++ const router = express.Router();
++ const json = express.json({ limit: '64kb' });
++ router.use(rateLimit({ windowMs: 60_000, max: config.spaces.apiPerMinute, standardHeaders: true, legacyHeaders: false }));
++
++ const send = (res, status, body) => res.status(status).set('Cache-Control', 'no-store').json(body);
++ const sendError = (res, err) => {
++ if (err instanceof ApiError || (err && Number.isInteger(err.status) && err.code)) return send(res, err.status, { error: { code: err.code, message: err.message } });
++ log.error('[Spaces API]', err && err.stack ? err.stack : err);
++ return send(res, 500, { error: { code: 'internal', message: 'Something went wrong on our side' } });
++ };
++
++ /** The published space at :id, its origin checked; CORS for that origin only. */
++ async function spaceOf(req, res) {
++ const space = await spaces.get(req.params.id);
++ const notFound = new ApiError(404, 'space.not_found', 'no such space');
++ if (!space || !space.site_id) throw notFound;
++ const site = await sites.get(space.site_id);
++ if (!site || site.status !== 'active') throw notFound;
++ if (takedowns && await takedowns.ofSite(site)) throw new ApiError(451, 'space.taken_down', 'this space is unavailable');
++ const own = spaces.urlOf(space);
++ const origin = req.get('origin');
++ if (origin) {
++ if (origin !== own) throw new ApiError(403, 'space.origin', 'only the space\'s own page may call this');
++ res.set('Access-Control-Allow-Origin', own);
++ res.vary('Origin');
++ }
++ return space;
++ }
++ const bearer = (req) => {
++ const h = req.get('authorization') || '';
++ const m = /^Bearer\s+([A-Za-z0-9_-]{20,100})$/.exec(h);
++ return m ? m[1] : null;
++ };
++ const handle = (fn) => async (req, res) => {
++ try {
++ const space = await spaceOf(req, res);
++ const out = await fn(req, res, space);
++ if (!res.headersSent) send(res, 200, out);
++ } catch (err) { sendError(res, err); }
++ };
++
++ // Preflight: answered for the space's own origin only.
++ router.options('/:id/*', async (req, res) => {
++ try {
++ await spaceOf(req, res);
++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
++ res.set('Access-Control-Allow-Methods', 'GET, POST, DELETE');
++ res.set('Access-Control-Max-Age', '600');
++ res.status(204).end();
++ } catch (err) { res.status(err.status || 404).end(); }
++ });
++
++ router.post('/:id/token', json, handle(async (req, res, space) => {
++ if (!req.get('origin')) throw new ApiError(403, 'space.origin', 'the sign-in code is traded from the space\'s own page');
++ const b = req.body || {};
++ return await data.exchange(space, { code: b.code, verifier: b.code_verifier });
++ }));
++ router.delete('/:id/token', handle(async (req) => { await data.revoke(bearer(req)); return { signed_out: true }; }));
++
++ router.post('/:id/run', json, handle(async (req, res, space) => {
++ const b = req.body || {};
++ const token = bearer(req);
++ const who = token ? await data.verify(space, token) : null;
++ if (token && !who && b.op !== 'items.list') throw new ApiError(401, 'auth.required', 'the sign-in for this space expired; sign in again');
++ const ctx = { space, person: who ? who.person : null, capabilities: who ? who.capabilities : [], owner: Boolean(who && who.person.subject === space.owner_subject) };
++ return { result: await data.run(String(b.op || ''), b.args || {}, ctx) };
++ }));
++
++ router.get('/:id/items/:collection', handle(async (req, res, space) => {
++ const items = await data.listItems(space, req.params.collection, { limit: req.query.limit, before: req.query.before });
++ return { items: items.map((i) => ({ ...i, mine: false })) };
++ }));
++
++ return router;
++}
++
++module.exports = { createSpacesApi };
ddiff --git a/server/spaces/data.js b/server/spaces/data.js
nnew file mode 100644
iindex 0000000..2020864
---- /dev/null
++++ b/server/spaces/data.js
@@@ -0,0 +1,227 @@
++'use strict';
++/**
++ * What a space's code can do through the SDK, for one person:
++ *
++ * OV.items shared lists in a space, readable by anyone who can open it; a signed-in visitor adds items, keeps
++ * one item of their own per list (set: a vote, a best score) and removes their own (the maker any).
++ * OV.kv each signed-in visitor's private storage in a space.
++ * OV.ai.ask the space's own AI (server/spaces/ai.js), on behalf of the visitor.
++ *
++ * and the sign-in that connects a visitor to a space: the connect page (server/spaces/pages.js) records what they
++ * approved and hands the space a one-time code (two minutes, PKCE S256); the space's page trades it, from its own
++ * origin, for a token bound to that space (a day). Only hashes of codes and tokens are stored.
++ *
++ * run(op, args, ctx) is the one entry point: the app-facing API (server/spaces/api.js) and the editor's preview
++ * (the maker, as themselves) both use it, so the rules are the same everywhere.
++ */
++const crypto = require('crypto');
++const { ApiError } = require('../http/errors');
++
++const NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
++const CODE_TTL = 2 * 60 * 1000;
++const TOKEN_TTL = 24 * 3600 * 1000;
++const sha = (v) => crypto.createHash('sha256').update(String(v)).digest('hex');
++const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
++
++function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
++ const { db } = store;
++ const limits = config.spaces;
++ const now = clock || store.now;
++ const q = {
++ list: db.prepare(`SELECT id, author_subject, author_username, author_name, value, created_at, updated_at FROM space_app_items
++ WHERE space_id = ? AND collection = ? AND (? = 0 OR id < ?) ORDER BY id DESC LIMIT ?`),
++ count: db.prepare('SELECT COUNT(*) AS n FROM space_app_items WHERE space_id = ?'),
++ add: db.prepare(`INSERT INTO space_app_items (space_id, collection, author_subject, author_username, author_name, value, mine, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, false, ?, ?) RETURNING id`),
++ setMine: db.prepare(`INSERT INTO space_app_items (space_id, collection, author_subject, author_username, author_name, value, mine, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, true, ?, ?)
++ ON CONFLICT (space_id, collection, author_subject) WHERE mine DO UPDATE SET value = excluded.value, author_username = excluded.author_username,
++ author_name = excluded.author_name, updated_at = excluded.updated_at RETURNING id, created_at`),
++ item: db.prepare('SELECT * FROM space_app_items WHERE id = ? AND space_id = ? AND collection = ?'),
++ removeItem: db.prepare('DELETE FROM space_app_items WHERE id = ?'),
++ kvGet: db.prepare('SELECT value FROM space_app_kv WHERE space_id = ? AND subject = ? AND key = ?'),
++ kvCount: db.prepare('SELECT COUNT(*) AS n FROM space_app_kv WHERE space_id = ? AND subject = ?'),
++ kvSet: db.prepare(`INSERT INTO space_app_kv (space_id, subject, key, value, updated_at) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT (space_id, subject, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`),
++ kvRemove: db.prepare('DELETE FROM space_app_kv WHERE space_id = ? AND subject = ? AND key = ?'),
++ grant: db.prepare('SELECT capabilities FROM space_app_grants WHERE space_id = ? AND subject = ?'),
++ setGrant: db.prepare(`INSERT INTO space_app_grants (space_id, subject, capabilities, granted_at) VALUES (?, ?, ?, ?)
++ ON CONFLICT (space_id, subject) DO UPDATE SET capabilities = excluded.capabilities, granted_at = excluded.granted_at`),
++ addCode: db.prepare(`INSERT INTO space_app_codes (code_hash, space_id, subject, username, display_name, capabilities, challenge, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
++ takeCode: db.prepare('DELETE FROM space_app_codes WHERE code_hash = ? RETURNING *'),
++ dropCodes: db.prepare('DELETE FROM space_app_codes WHERE expires_at < ?'),
++ addToken: db.prepare(`INSERT INTO space_app_tokens (token_hash, space_id, subject, username, display_name, capabilities, created_at, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
++ token: db.prepare('SELECT * FROM space_app_tokens WHERE token_hash = ?'),
++ dropToken: db.prepare('DELETE FROM space_app_tokens WHERE token_hash = ?'),
++ dropTokens: db.prepare('DELETE FROM space_app_tokens WHERE expires_at < ?'),
++ };
++
++ // Per person and space, per minute: writes and AI questions (one process serves Space).
++ const windows = new Map();
++ function throttle(key, max) {
++ const t = now();
++ const list = (windows.get(key) || []).filter((x) => t - x < 60000);
++ if (list.length >= max) throw new ApiError(429, 'quota', 'that is a lot at once; wait a minute and try again');
++ list.push(t);
++ windows.set(key, list);
++ if (windows.size > 5000) for (const [k, v] of windows) if (!v.some((x) => t - x < 60000)) windows.delete(k);
++ }
++
++ const checkName = (kind, v) => {
++ if (typeof v !== 'string' || !NAME_RE.test(v)) throw new ApiError(422, 'space.invalid_name', `${kind} names are 1-64 characters: letters, digits, ".", "-" and "_"`);
++ return v;
++ };
++ const jsonOf = (value, max) => {
++ if (value === undefined) throw new ApiError(422, 'space.value_required', 'a value is required');
++ let s;
++ try { s = JSON.stringify(value); } catch { throw new ApiError(422, 'space.invalid_value', 'the value must be JSON'); }
++ if (Buffer.byteLength(s) > max) throw new ApiError(413, 'space.value_too_large', `a value is at most ${Math.round(max / 1024)} KB`);
++ return s;
++ };
++ const parse = (s) => { try { return JSON.parse(s); } catch { return null; } };
++ const iso = (ms) => new Date(Number(ms)).toISOString();
++ const itemOut = (r, person) => ({
++ id: String(r.id), value: parse(r.value), author: { username: r.author_username, display_name: r.author_name || r.author_username },
++ created_at: iso(r.created_at), updated_at: iso(r.updated_at || r.created_at), mine: Boolean(person && r.author_subject === person.subject),
++ });
++ const need = (ctx, cap) => {
++ if (!ctx.person) throw new ApiError(401, 'auth.required', 'sign in with OpenVibe to do this (OV.signIn())');
++ if (cap && !ctx.owner && !ctx.capabilities.includes(cap)) throw new ApiError(403, 'space.capability', `this space did not ask for "${cap}" when you signed in`);
++ };
++
++ // ── Items ──
++ async function listItems(space, collection, { limit = 50, before = 0 } = {}, person = null) {
++ checkName('collection', collection);
++ const lim = Math.min(100, Math.max(1, Number(limit) || 50));
++ const b = Math.max(0, Number(before) || 0);
++ return (await q.list.all(space.id, collection, b, b, lim)).map((r) => itemOut(r, person));
++ }
++ async function addItem(space, person, collection, value) {
++ checkName('collection', collection);
++ const v = jsonOf(value, limits.itemBytes);
++ throttle(`w:${space.id}:${person.subject}`, limits.writesPerMinute);
++ if ((await q.count.get(space.id)).n >= limits.itemsPerSpace) throw new ApiError(429, 'quota', `this space holds ${limits.itemsPerSpace} items at most`);
++ const t = now();
++ const r = await q.add.get(space.id, collection, person.subject, person.username, person.name, v, t, t);
++ return itemOut({ id: r.id, author_subject: person.subject, author_username: person.username, author_name: person.name, value: v, created_at: t, updated_at: t }, person);
++ }
++ async function setItem(space, person, collection, value) {
++ checkName('collection', collection);
++ const v = jsonOf(value, limits.itemBytes);
++ throttle(`w:${space.id}:${person.subject}`, limits.writesPerMinute);
++ const t = now();
++ const r = await q.setMine.get(space.id, collection, person.subject, person.username, person.name, v, t, t);
++ return itemOut({ id: r.id, author_subject: person.subject, author_username: person.username, author_name: person.name, value: v, created_at: r.created_at, updated_at: t }, person);
++ }
++ async function removeItem(space, person, collection, id, owner) {
++ checkName('collection', collection);
++ const n = Number(id);
++ const row = Number.isSafeInteger(n) && n > 0 ? await q.item.get(n, space.id, collection) : null;
++ if (!row) throw new ApiError(404, 'space.item_not_found', 'no such item');
++ if (row.author_subject !== person.subject && !owner) throw new ApiError(403, 'space.not_author', 'only its author (or the space\'s maker) removes an item');
++ await q.removeItem.run(n);
++ return { removed: true };
++ }
++
++ // ── KV ──
++ async function kvGet(space, person, key) {
++ const row = await q.kvGet.get(space.id, person.subject, checkName('key', key));
++ return row ? parse(row.value) : null;
++ }
++ async function kvSet(space, person, key, value) {
++ checkName('key', key);
++ const v = jsonOf(value, limits.kvBytes);
++ throttle(`w:${space.id}:${person.subject}`, limits.writesPerMinute);
++ if (!(await q.kvGet.get(space.id, person.subject, key)) && (await q.kvCount.get(space.id, person.subject)).n >= limits.kvKeysPerPerson) {
++ throw new ApiError(429, 'quota', `a person keeps ${limits.kvKeysPerPerson} keys at most in one space`);
++ }
++ await q.kvSet.run(space.id, person.subject, key, v, now());
++ return true;
++ }
++ async function kvRemove(space, person, key) { await q.kvRemove.run(space.id, person.subject, checkName('key', key)); return true; }
++
++ // ── AI ──
++ async function ask(space, person, args) {
++ let message = '';
++ let transcript = '';
++ if (typeof args === 'string') message = args;
++ else if (args && Array.isArray(args.messages)) {
++ const msgs = args.messages.slice(-24).filter((m) => m && ['user', 'assistant'].includes(m.role) && typeof m.content === 'string');
++ const last = msgs.length && msgs[msgs.length - 1].role === 'user' ? msgs.pop() : null;
++ if (!last) throw new ApiError(422, 'space.invalid_messages', 'the last message must be the visitor\'s (role "user")');
++ message = last.content;
++ transcript = msgs.map((m) => `${m.role === 'user' ? 'visitor' : 'ai'}: ${m.content.replace(/\s+/g, ' ').slice(0, 2000)}`).join('\n');
++ } else if (args && typeof args.prompt === 'string') message = args.prompt;
++ message = String(message || '').trim();
++ if (!message) throw new ApiError(422, 'space.message_required', 'ask something');
++ throttle(`ai:${space.id}:${person.subject}`, limits.aiAsksPerMinute);
++ if (!(await spaces.spend(`ask:${space.id}`, limits.aiAsksPerSpacePerDay))) throw new ApiError(429, 'quota', 'this space has used its OpenVibe AI for today; try again tomorrow');
++ return await ai.ask(space, person, { transcript, message: message.slice(0, 4000) });
++ }
++
++ /**
++ * One SDK operation. ctx: { space, person (null when signed out), capabilities (what the person approved), owner
++ * (the maker, or staff: every capability, and may remove any item) }.
++ */
++ async function run(op, args = {}, ctx) {
++ const { space, person } = ctx;
++ const a = args && typeof args === 'object' ? args : {};
++ switch (op) {
++ case 'me': return person ? { username: person.username, display_name: person.name, capabilities: ctx.owner ? spaces.capsOf(space.capabilities) : ctx.capabilities } : null;
++ case 'items.list': return await listItems(space, a.collection, { limit: a.limit, before: a.before }, person);
++ case 'items.add': need(ctx, 'data'); return await addItem(space, person, a.collection, a.value);
++ case 'items.set': need(ctx, 'data'); return await setItem(space, person, a.collection, a.value);
++ case 'items.remove': need(ctx, 'data'); return await removeItem(space, person, a.collection, a.id, ctx.owner);
++ case 'kv.get': need(ctx, 'kv'); return await kvGet(space, person, a.key);
++ case 'kv.set': need(ctx, 'kv'); return await kvSet(space, person, a.key, a.value);
++ case 'kv.remove': need(ctx, 'kv'); return await kvRemove(space, person, a.key);
++ case 'ai.ask': need(ctx, 'ai'); return { text: await ask(space, person, a.input !== undefined ? a.input : a) };
++ default: throw new ApiError(400, 'space.unknown_op', `unknown operation "${String(op).slice(0, 40)}"`);
++ }
++ }
++
++ // ── Connecting a visitor ──
++ async function grantedFor(space, subject) {
++ const row = await q.grant.get(space.id, subject);
++ return row ? spaces.capsOf(row.capabilities) : null;
++ }
++ /** The visitor approved the space's capabilities: a one-time code for the space's page. */
++ async function approve(space, person, { challenge }) {
++ if (typeof challenge !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(challenge)) throw new ApiError(400, 'space.invalid_challenge', 'the sign-in link is incomplete; go back to the space and try again');
++ const caps = spaces.capsOf(space.capabilities);
++ const t = now();
++ await q.dropCodes.run(t);
++ await q.setGrant.run(space.id, person.subject, JSON.stringify(caps), t);
++ const code = b64url(crypto.randomBytes(32));
++ await q.addCode.run(sha(code), space.id, person.subject, person.username, person.name, JSON.stringify(caps), challenge, t + CODE_TTL);
++ return code;
++ }
++ /** The space's page trades the code (with its PKCE verifier) for a token bound to the space. */
++ async function exchange(space, { code, verifier }) {
++ if (typeof code !== 'string' || typeof verifier !== 'string' || verifier.length < 43 || verifier.length > 128) throw new ApiError(400, 'space.invalid_grant', 'the sign-in code is missing or malformed');
++ const row = await q.takeCode.get(sha(code));
++ const bad = new ApiError(400, 'space.invalid_grant', 'the sign-in code expired or was already used; sign in again');
++ if (!row || row.space_id !== space.id || Number(row.expires_at) < now()) throw bad;
++ const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
++ if (challenge !== row.challenge) throw bad;
++ const token = b64url(crypto.randomBytes(32));
++ const t = now();
++ await q.dropTokens.run(t);
++ await q.addToken.run(sha(token), space.id, row.subject, row.username, row.display_name, row.capabilities, t, t + TOKEN_TTL);
++ return { token, expires_in: Math.floor(TOKEN_TTL / 1000), user: { username: row.username, display_name: row.display_name || row.username }, capabilities: spaces.capsOf(row.capabilities) };
++ }
++ /** A Bearer token → the person it stands for in this space, or null. */
++ async function verify(space, bearer) {
++ if (typeof bearer !== 'string' || bearer.length < 20 || bearer.length > 100) return null;
++ const row = await q.token.get(sha(bearer));
++ if (!row || row.space_id !== space.id || Number(row.expires_at) < now()) return null;
++ return { person: { subject: row.subject, username: row.username, name: row.display_name || row.username }, capabilities: spaces.capsOf(row.capabilities) };
++ }
++ async function revoke(bearer) { if (typeof bearer === 'string') await q.dropToken.run(sha(bearer)); }
++
++ return { run, listItems, grantedFor, approve, exchange, verify, revoke, CODE_TTL, TOKEN_TTL };
++}
++
++module.exports = { createSpaceData, NAME_RE };
ddiff --git a/server/spaces/pages.js b/server/spaces/pages.js
nnew file mode 100644
iindex 0000000..39045cd
---- /dev/null
++++ b/server/spaces/pages.js
@@@ -0,0 +1,239 @@
++'use strict';
++/**
++ * Spaces on the dashboard host (openvibe.space), with the visitor's Space session:
++ *
++ * GET /spaces the gallery (published public spaces) and the templates
++ * GET /spaces/new · POST make one: from a template, or described to the AI builder
++ * GET /spaces/mine your spaces
++ * GET /spaces/:slug a space's page (a draft only for its maker)
++ * GET /spaces/:slug/edit the editor (maker or staff)
++ * POST /spaces/:slug/fork a copy of a space you can open, as your own draft
++ * GET /spaces/:slug/connect · POST the sign-in a visitor approves for a published space; back to the space with a code
++ * GET /_openvibe/sdk.js the SDK in preview mode (the editor's sandboxed frame loads it from here)
++ *
++ * The editor's JSON calls (same origin, the form token in X-CSRF-Token):
++ * PUT /spaces/:slug/files · PUT /spaces/:slug/settings · POST /spaces/:slug/ai · GET /spaces/:slug/ai
++ * POST /spaces/:slug/publish · POST /spaces/:slug/unpublish · POST /spaces/:slug/delete
++ * POST /spaces/:slug/preview { op, args }: an SDK call from the preview, run as the maker
++ */
++const express = require('express');
++const { ApiError } = require('../http/errors');
++const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
++const templates = require('./templates');
++const render = require('./render');
++const { sdkSource } = require('./sdk');
++
++const BUILD_STALE = 10 * 60 * 1000;
++const STATE_RE = /^[A-Za-z0-9_-]{8,100}$/;
++const CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;
++
++/** A path on the space's own host to come back to: absolute-path only, no scheme, host, backslash or control character. */
++function cleanReturn(v) {
++ const s = String(v || '/').split('#')[0];
++ if (s.length > 500 || !s.startsWith('/') || s.startsWith('//') || /[\\\u0000-\u001f\u007f\s]/.test(s)) return '/';
++ return s;
++}
++
++function createSpacesPages(ctx) {
++ const { config, viewers, spaces, spaceData: data, spacesAi: ai, log = console } = ctx;
++ const router = express.Router();
++ const urlencoded = express.urlencoded({ extended: false, limit: '16kb' });
++ const json = express.json({ limit: Math.ceil(config.spaces.maxBytes * 1.5) + 65536 });
++ const limits = config.spaces;
++
++ router.use(viewers.middleware('dashboard'));
++ if (ctx.actorLimits) router.use(ctx.actorLimits);
++ router.use((req, res, next) => {
++ res.set('Cache-Control', 'private, no-store');
++ // The editor's preview frame (srcdoc) inherits this policy, so a space's pinned CDN scripts and fonts run in
++ // it as they will on the space's own address; a space's page embeds the published space.
++ const sites = `https://*.${config.sitesDomain}`;
++ res.set('Content-Security-Policy', [
++ "default-src 'self'",
++ "script-src 'self' 'unsafe-inline' https://openvibe.network https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com https://static.cloudflareinsights.com",
++ "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com https://fonts.googleapis.com https://openvibe.network",
++ "font-src 'self' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.gstatic.com",
++ "img-src 'self' data: blob: https:", "media-src 'self' data: blob: https:",
++ "connect-src 'self' https://openvibe.network https://openvibe.events https://cloudflareinsights.com",
++ `frame-src 'self' https://openvibe.network ${sites}`, "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'",
++ "form-action 'self' https://openvibe.network", "script-src-attr 'unsafe-inline'",
++ ].join('; '));
++ next();
++ });
++
++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user';
++ const csrfOf = (req) => (signedIn(req) ? csrfToken(config, req.viewer) : '');
++ const html = (res, status, body) => res.status(status).type('html').send(body);
++ function failPage(req, res, err) {
++ if (err && err.status === 401) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ if (!(err instanceof ApiError) && !(err && Number.isInteger(err.status) && err.code)) log.error('[Spaces]', err && err.stack ? err.stack : err);
++ const status = err && Number.isInteger(err.status) ? err.status : 500;
++ return html(res, status, render.errorBody(status, status === 500 ? 'Something went wrong on our side. Try again in a moment.' : err.message));
++ }
++ const view = (fn) => async (req, res) => { try { await fn(req, res); } catch (err) { failPage(req, res, err); } };
++ /** A form POST: signed in, from this site, with the form token. */
++ const action = (fn) => [urlencoded, view(async (req, res) => {
++ if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) throw new ApiError(403, 'form.invalid', 'this form expired or did not come from this site; reload the page and try again');
++ await fn(req, res);
++ })];
++ /** An editor JSON call: signed in, same origin, the form token in X-CSRF-Token. */
++ const api = (fn) => [json, async (req, res) => {
++ try {
++ if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in again');
++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.get('x-csrf-token'))) throw new ApiError(403, 'form.invalid', 'this page expired; reload it');
++ res.json(await fn(req, res));
++ } catch (err) {
++ if (!(err instanceof ApiError) && !(err && Number.isInteger(err.status) && err.code)) log.error('[Spaces editor]', err && err.stack ? err.stack : err);
++ const status = err && Number.isInteger(err.status) ? err.status : 500;
++ res.status(status).json({ error: { code: (err && err.code) || 'internal', message: status === 500 ? 'Something went wrong on our side' : err.message } });
++ }
++ }];
++ const buildsLeft = async (req) => (signedIn(req) ? Math.max(0, limits.aiBuildsPerDay - await spaces.used(`build:${req.viewer.subject}`)) : 0);
++ const settingsOut = (s) => ({ id: s.id, slug: s.slug, name: s.name, description: s.description, instructions: s.instructions, capabilities: spaces.capsOf(s.capabilities), visibility: s.visibility, published: Boolean(s.site_id), url: spaces.urlOf(s), building: Boolean(s.building_run_id) });
++
++ router.get('/_openvibe/sdk.js', (req, res) => {
++ res.set('Cache-Control', 'no-cache').type('application/javascript').send(sdkSource({ mode: 'preview', avatar: `${config.networkUrl}/avatar/{username}` }));
++ });
++
++ router.get('/spaces', view(async (req, res) => {
++ const q = typeof req.query.q === 'string' ? req.query.q : '';
++ const sort = req.query.sort === 'popular' ? 'popular' : 'new';
++ html(res, 200, render.galleryPage({ viewer: signedIn(req) ? req.viewer : null, spaces: await spaces.gallery({ q, sort }), templates: templates.list(), q, sort, csrf: csrfOf(req) }));
++ }));
++
++ router.get('/spaces/new', view(async (req, res) => {
++ if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
++ html(res, 200, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: await buildsLeft(req) }));
++ }));
++ router.post('/spaces/new', ...action(async (req, res) => {
++ const body = req.body || {};
++ const prompt = String(body.prompt || '').trim();
++ if (body.template) {
++ const t = templates.get(String(body.template));
++ if (!t) throw new ApiError(404, 'space.template_not_found', 'no such template');
++ const space = await spaces.create(req.viewer, { name: t.name, description: t.description, instructions: t.instructions || '', capabilities: t.capabilities, files: templates.files(t.id), template: t.id });
++ return res.redirect(303, `/spaces/${space.slug}/edit`);
++ }
++ const again = (error) => html(res, 422, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: 0, error, prompt }));
++ if (prompt.length < 8) return again('Describe your space in a sentence or two (what it does, who uses it, how it should look).');
++ if (!ai.enabled()) return again('OpenVibe AI is not available right now. Start from a template instead.');
++ if (!(await spaces.spend(`build:${req.viewer.subject}`, limits.aiBuildsPerDay))) return again(`You have used today's ${limits.aiBuildsPerDay} AI builds. Start from a template, or try again tomorrow.`);
++ const t = templates.get('blank');
++ const space = await spaces.create(req.viewer, { name: 'New space', description: '', capabilities: t.capabilities, files: templates.files('blank'), template: null });
++ const runId = await ai.build(spaces.personOf(req.viewer), { prompt, spaceId: space.id });
++ await spaces.startBuild(space, runId, prompt);
++ res.redirect(303, `/spaces/${space.slug}/edit`);
++ }));
++
++ router.get('/spaces/mine', view(async (req, res) => {
++ if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
++ html(res, 200, render.minePage({ spaces: await spaces.mine(req.viewer) }));
++ }));
++
++ router.get('/spaces/:slug', view(async (req, res) => {
++ const { space, owner } = await spaces.load(req.viewer, req.params.slug, 'read');
++ const forkedFrom = space.forked_from ? await spaces.get(space.forked_from) : null;
++ html(res, 200, render.spacePage({ viewer: signedIn(req) ? req.viewer : null, space, owner, url: spaces.urlOf(space), csrf: csrfOf(req), forkedFrom: forkedFrom && forkedFrom.site_id ? forkedFrom : null }));
++ }));
++
++ router.get('/spaces/:slug/edit', view(async (req, res) => {
++ if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
++ const { space } = await spaces.load(req.viewer, req.params.slug, 'own');
++ html(res, 200, render.editorPage({ space, files: await spaces.files(space), csrf: csrfOf(req), canPublish: spaces.canPublish(req.viewer), url: spaces.urlOf(space), aiOn: ai.enabled(), buildsLeft: await buildsLeft(req), templates: templates.list() }));
++ }));
++
++ router.post('/spaces/:slug/fork', ...action(async (req, res) => {
++ const { space } = await spaces.load(req.viewer, req.params.slug, 'read');
++ const copy = await spaces.fork(req.viewer, space);
++ res.redirect(303, `/spaces/${copy.slug}/edit`);
++ }));
++
++ // ── The editor's JSON calls ──
++ const own = async (req) => (await spaces.load(req.viewer, req.params.slug, 'own')).space;
++ router.put('/spaces/:slug/files', ...api(async (req) => {
++ const space = await own(req);
++ if (space.building_run_id) throw new ApiError(409, 'space.building', 'the AI is changing this space; wait for it to finish');
++ return { files: await spaces.saveFiles(req.viewer, space, (req.body || {}).files) };
++ }));
++ router.put('/spaces/:slug/settings', ...api(async (req) => ({ space: settingsOut(await spaces.updateSettings(req.viewer, await own(req), req.body || {})) })));
++ router.post('/spaces/:slug/ai', ...api(async (req) => {
++ const space = await own(req);
++ const prompt = String((req.body || {}).prompt || '').trim();
++ if (prompt.length < 3) throw new ApiError(422, 'space.prompt_required', 'say what to change');
++ if (space.building_run_id) throw new ApiError(409, 'space.building', 'the AI is already changing this space');
++ if (!(await spaces.spend(`build:${req.viewer.subject}`, limits.aiBuildsPerDay))) throw new ApiError(429, 'quota', `you have used today's ${limits.aiBuildsPerDay} AI builds; try again tomorrow`);
++ const runId = await ai.build(spaces.personOf(req.viewer), { prompt, name: space.name, current: await spaces.files(space), spaceId: space.id });
++ await spaces.startBuild(space, runId, prompt);
++ return { building: true };
++ }));
++ router.get('/spaces/:slug/ai', ...api(async (req) => {
++ const space = await own(req);
++ if (!space.building_run_id) return { status: 'idle' };
++ const runId = space.building_run_id;
++ const r = await ai.result(runId);
++ if (r.status === 'running') {
++ if (Number(space.updated_at) < ctx.store.now() - BUILD_STALE) { await spaces.failBuild(space, runId); return { status: 'failed', message: 'The AI took too long. Try again, perhaps with a smaller change.' }; }
++ return { status: 'running' };
++ }
++ if (r.status === 'succeeded') {
++ try {
++ await spaces.finishBuild(space, runId, r.output);
++ } catch (err) {
++ await spaces.failBuild(space, runId);
++ return { status: 'failed', message: `The AI's answer was not a working space (${err.message}). Try again.` };
++ }
++ const fresh = await spaces.get(space.id);
++ return { status: 'done', space: settingsOut(fresh), files: await spaces.files(fresh) };
++ }
++ await spaces.failBuild(space, runId);
++ return { status: 'failed', message: r.error === 'quota.exceeded' ? 'Your OpenVibe AI allowance for today is used up.' : 'The AI could not build that. Try again, or describe it differently.' };
++ }));
++ router.post('/spaces/:slug/publish', ...api(async (req) => {
++ const out = await spaces.publish(req.viewer, await own(req), { traceparent: req.ov ? req.ov.traceparent : undefined });
++ return { space: settingsOut(out.space), url: out.url };
++ }));
++ router.post('/spaces/:slug/unpublish', ...api(async (req) => ({ space: settingsOut(await spaces.unpublish(req.viewer, await own(req))) })));
++ router.post('/spaces/:slug/delete', ...api(async (req) => { await spaces.remove(req.viewer, await own(req)); return { deleted: true }; }));
++ router.post('/spaces/:slug/preview', ...api(async (req) => {
++ const space = await own(req);
++ const b = req.body || {};
++ const person = spaces.personOf(req.viewer);
++ return { result: await data.run(String(b.op || ''), b.args || {}, { space, person, capabilities: spaces.CAPABILITIES, owner: true }) };
++ }));
++
++ // ── A visitor signs in to a published space ──
++ async function connectTarget(req) {
++ const space = await spaces.getBySlug(req.params.slug);
++ if (!space || !space.site_id) throw new ApiError(404, 'space.not_found', 'no such space, or it is not published');
++ const src = req.method === 'POST' ? (req.body || {}) : req.query;
++ const challenge = String(src.challenge || '');
++ const state = String(src.state || '');
++ if (!CHALLENGE_RE.test(challenge) || !STATE_RE.test(state)) throw new ApiError(400, 'space.invalid_connect', 'this sign-in link is incomplete; go back to the space and press its sign-in button again');
++ return { space, challenge, state, returnPath: cleanReturn(src.return) };
++ }
++ const backTo = (space, returnPath, code, state) => `${spaces.urlOf(space)}${returnPath}${code ? `#ov_code=${encodeURIComponent(code)}&ov_state=${encodeURIComponent(state)}` : ''}`;
++ router.get('/spaces/:slug/connect', view(async (req, res) => {
++ const t = await connectTarget(req);
++ if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
++ const person = spaces.personOf(req.viewer);
++ const granted = await data.grantedFor(t.space, person.subject);
++ const wanted = spaces.capsOf(t.space.capabilities);
++ if (granted && wanted.every((c) => granted.includes(c))) {
++ const code = await data.approve(t.space, person, { challenge: t.challenge });
++ return res.redirect(303, backTo(t.space, t.returnPath, code, t.state));
++ }
++ res.set('X-Frame-Options', 'DENY');
++ html(res, 200, render.connectPage({ viewer: req.viewer, space: t.space, csrf: csrfOf(req), challenge: t.challenge, state: t.state, returnPath: t.returnPath }));
++ }));
++ router.post('/spaces/:slug/connect', ...action(async (req, res) => {
++ const t = await connectTarget(req);
++ if (req.body.decision !== 'allow') return res.redirect(303, backTo(t.space, t.returnPath));
++ const code = await data.approve(t.space, spaces.personOf(req.viewer), { challenge: t.challenge });
++ res.redirect(303, backTo(t.space, t.returnPath, code, t.state));
++ }));
++
++ return router;
++}
++
++module.exports = { createSpacesPages, cleanReturn };
ddiff --git a/server/spaces/render.js b/server/spaces/render.js
nnew file mode 100644
iindex 0000000..bbe8e0c
---- /dev/null
++++ b/server/spaces/render.js
@@@ -0,0 +1,241 @@
++'use strict';
++/**
++ * Pages of spaces on openvibe.space: the gallery (/spaces), making one (/spaces/new), your spaces (/spaces/mine), a
++ * space's page (/spaces/:slug), its editor (/spaces/:slug/edit) and the sign-in a visitor approves (/spaces/:slug/connect).
++ * Plain template strings, every value escaped; the editor's behaviour is public/js/space-editor.js.
++ */
++const sc = require('openvibe-shared/showcase');
++const { renderPage, escapeHtml: esc, asset } = require('../render/layout');
++
++const CAP_TEXT = {
++ profile: { icon: 'fa-user', text: 'Know who you are: your username and display name' },
++ data: { icon: 'fa-list', text: 'Add things you write to its shared lists, under your name' },
++ kv: { icon: 'fa-box-archive', text: 'Keep your own private data in this space' },
++ ai: { icon: 'fa-wand-magic-sparkles', text: 'Ask OpenVibe AI for you (it counts against your daily AI allowance)' },
++};
++const caps = (s) => { try { return JSON.parse(s.capabilities || '[]'); } catch { return []; } };
++const ago = (ms) => {
++ const s = Math.max(1, (Date.now() - Number(ms)) / 1000);
++ const u = [[86400 * 365, 'y'], [86400 * 30, 'mo'], [86400, 'd'], [3600, 'h'], [60, 'm']].find(([n]) => s >= n);
++ return u ? `${Math.floor(s / u[0])}${u[1]} ago` : 'just now';
++};
++
++function page(o) {
++ return renderPage({
++ title: o.title, description: o.description, canonicalPath: o.path, robots: o.robots || 'index,follow',
++ active: 'spaces', styles: o.showcase ? [sc.STYLESHEET] : [], localStyles: ['css/spaces.css'], body: o.body, jsonLd: o.jsonLd,
++ });
++}
++
++const signInHref = (next) => `/auth/login?next=${encodeURIComponent(next)}`;
++const capList = (list) => `<ul class="sp-caps">${list.filter((c) => CAP_TEXT[c]).map((c) => `<li><i class="fa-solid ${CAP_TEXT[c].icon}" aria-hidden="true"></i> ${esc(CAP_TEXT[c].text)}</li>`).join('')}</ul>`;
++
++function card(s, { mine = false } = {}) {
++ const live = Boolean(s.site_id);
++ return `<article class="sp-card">
++ <a class="sp-card-main" href="/spaces/${esc(s.slug)}">
++ <span class="sp-card-badge ${live ? 'is-live' : ''}">${live ? (s.visibility === 'unlisted' ? 'Unlisted' : 'Published') : 'Draft'}</span>
++ <h3>${esc(s.name)}</h3>
++ <p>${esc(s.description || 'No description yet.')}</p>
++ </a>
++ <footer><span>${mine ? `edited ${esc(ago(s.updated_at))}` : `by @${esc(s.owner_username || 'someone')}`}</span>${s.forks ? `<span><i class="fa-solid fa-code-fork" aria-hidden="true"></i> ${Number(s.forks)}</span>` : ''}</footer>
++</article>`;
++}
++
++function templateCards(templates, { viewer, csrf, compact = false }) {
++ return `<div class="sp-templates ${compact ? 'is-compact' : ''}">${templates.map((t) => `<div class="sp-template" style="--tpl:${esc(t.accent)}">
++ <span class="sp-template-icon"><i class="fa-solid ${esc(t.icon)}" aria-hidden="true"></i></span>
++ <h3>${esc(t.name)}</h3><p>${esc(t.description)}</p>
++ ${viewer ? `<form method="post" action="/spaces/new"><input type="hidden" name="csrf" value="${esc(csrf)}"><input type="hidden" name="template" value="${esc(t.id)}"><button class="sp-btn">Start from this</button></form>`
++ : `<a class="sp-btn" href="${signInHref('/spaces/new')}">Sign in to start</a>`}
++</div>`).join('')}</div>`;
++}
++
++function galleryPage({ viewer, spaces, templates, q, sort, csrf }) {
++ const hero = sc.hero({
++ eyebrow: 'OpenVibe.Space · spaces',
++ title: 'Make a space.',
++ accent: 'An app on your OpenVibe account.',
++ lede: 'Describe it and OpenVibe AI builds it, or start from a template and change anything in the editor. Spaces sign visitors in with OpenVibe, keep shared lists and private data, and can talk to OpenVibe AI.',
++ actions: [
++ { label: 'Make a space', href: viewer ? '/spaces/new' : signInHref('/spaces/new'), primary: true, icon: 'fa-wand-magic-sparkles' },
++ ...(viewer ? [{ label: 'Your spaces', href: '/spaces/mine', icon: 'fa-shapes' }] : []),
++ ],
++ });
++ const list = spaces.length ? `<div class="sp-grid">${spaces.map((s) => card(s)).join('')}</div>`
++ : `<p class="sp-empty">${q ? `No published space matches “${esc(q)}”.` : 'Published spaces appear here. Start one from a template below and run it in the editor right away.'}</p>`;
++ const body = `${hero}
++<section class="sp-section" aria-labelledby="sp-gallery">
++ <div class="sp-head">
++ <h2 id="sp-gallery">Spaces people made</h2>
++ <form class="sp-search" method="get" action="/spaces" role="search">
++ <input name="q" value="${esc(q || '')}" placeholder="Search spaces" aria-label="Search spaces" maxlength="80">
++ <select name="sort" aria-label="Sort"><option value="new"${sort !== 'popular' ? ' selected' : ''}>Newest</option><option value="popular"${sort === 'popular' ? ' selected' : ''}>Most forked</option></select>
++ <button class="sp-btn sp-btn-quiet">Search</button>
++ </form>
++ </div>
++ ${list}
++</section>
++<section class="sp-section" aria-labelledby="sp-starters">
++ <div class="sp-head"><h2 id="sp-starters">Start from a template</h2><p>Each one is a working app you can change line by line.</p></div>
++ ${templateCards(templates, { viewer, csrf })}
++</section>
++<section class="sp-section sp-how" aria-labelledby="sp-how">
++ <h2 id="sp-how">What a space can do</h2>
++ <div class="sp-how-grid">
++ <div><i class="fa-solid fa-right-to-bracket" aria-hidden="true"></i><h3>Sign in with OpenVibe</h3><p>Visitors sign in with their OpenVibe account and approve what the space may do, once.</p></div>
++ <div><i class="fa-solid fa-list" aria-hidden="true"></i><h3>Shared lists</h3><p>Guestbooks, polls, leaderboards: things visitors add, under their name, that everyone can read.</p></div>
++ <div><i class="fa-solid fa-box-archive" aria-hidden="true"></i><h3>Private storage</h3><p>Each visitor's own data in your space: to-dos, saves, settings.</p></div>
++ <div><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><h3>OpenVibe AI</h3><p>Give your space an AI with your own instructions; it answers your visitors.</p></div>
++ </div>
++ <p class="sp-note">Spaces are plain HTML, CSS and JavaScript with the space SDK (<code>/_openvibe/sdk.js</code>). The AI builder, the editor and the preview work for everyone signed in. Publishing a space at its own address opens together with OpenVibe.Space's public hosting.</p>
++</section>`;
++ return page({ title: 'Spaces', description: 'Make a space: a small app on your OpenVibe account, built by OpenVibe AI or from a template, with sign-in, shared lists, private storage and AI.', path: '/spaces', showcase: true, body });
++}
++
++function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft }) {
++ const body = `<div class="sp-wrap">
++ <p class="eyebrow">New space</p>
++ <h1 class="sp-title">What do you want to make?</h1>
++ ${error ? `<p class="sp-flash is-bad" role="alert">${esc(error)}</p>` : ''}
++ <form class="sp-ai-form" method="post" action="/spaces/new">
++ <input type="hidden" name="csrf" value="${esc(csrf)}">
++ <label for="sp-prompt">Describe it and OpenVibe AI builds it</label>
++ <textarea id="sp-prompt" name="prompt" rows="4" maxlength="4000" placeholder="A trivia quiz about 90s cartoons with a leaderboard…" ${aiOn ? '' : 'disabled'}>${esc(prompt || '')}</textarea>
++ <div class="sp-row"><button class="sp-btn" ${aiOn ? '' : 'disabled'}><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Build it</button>
++ <span class="sp-muted">${aiOn ? `${Number(buildsLeft)} AI builds left today. It takes about a minute; you can change everything after.` : 'OpenVibe AI is not available right now; start from a template instead.'}</span></div>
++ <div class="sp-ideas" aria-label="Ideas">${['A daily mood check-in with a shared chart', 'A pixel art board everyone can paint on', 'A recipe idea generator that asks what is in my fridge', 'A study flashcard deck I can quiz myself with'].map((i) => `<button type="button" class="sp-chip" data-idea="${esc(i)}">${esc(i)}</button>`).join('')}</div>
++ </form>
++ <h2 class="sp-subtitle">Or start from a template</h2>
++ ${templateCards(templates, { viewer, csrf, compact: true })}
++</div>
++<script>document.querySelectorAll('.sp-chip').forEach(function (b) { b.addEventListener('click', function () { var t = document.getElementById('sp-prompt'); t.value = b.getAttribute('data-idea'); t.focus(); }); });</script>`;
++ return page({ title: 'New space', path: '/spaces/new', robots: 'noindex,nofollow', body });
++}
++
++function minePage({ spaces }) {
++ const body = `<div class="sp-wrap">
++ <div class="sp-head"><h1 class="sp-title">Your spaces</h1><a class="sp-btn" href="/spaces/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New space</a></div>
++ ${spaces.length ? `<div class="sp-grid">${spaces.map((s) => card(s, { mine: true })).join('')}</div>` : '<p class="sp-empty">You have not made a space yet. <a href="/spaces/new">Make your first one</a>: describe it, or start from a template.</p>'}
++</div>`;
++ return page({ title: 'Your spaces', path: '/spaces/mine', robots: 'noindex,nofollow', body });
++}
++
++function spacePage({ viewer, space, owner, url, csrf, forkedFrom }) {
++ const live = Boolean(space.site_id);
++ const actions = [
++ live ? `<a class="sp-btn" href="${esc(url)}" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open</a>` : '',
++ owner ? `<a class="sp-btn ${live ? 'sp-btn-quiet' : ''}" href="/spaces/${esc(space.slug)}/edit"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>` : '',
++ viewer ? `<form method="post" action="/spaces/${esc(space.slug)}/fork"><input type="hidden" name="csrf" value="${esc(csrf)}"><button class="sp-btn sp-btn-quiet"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</button></form>`
++ : `<a class="sp-btn sp-btn-quiet" href="${signInHref(`/spaces/${space.slug}`)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Sign in to fork</a>`,
++ ].join('');
++ const body = `<div class="sp-wrap">
++ <p class="eyebrow">${live ? 'Space' : 'Draft space'}</p>
++ <div class="sp-head"><h1 class="sp-title">${esc(space.name)}</h1><div class="sp-actions">${actions}</div></div>
++ <p class="sp-lede">${esc(space.description || 'No description yet.')}</p>
++ <p class="sp-muted">by <a href="https://openvibe.network/@${esc(encodeURIComponent(space.owner_username))}">@${esc(space.owner_username)}</a>${forkedFrom ? ` · forked from <a href="/spaces/${esc(forkedFrom.slug)}">${esc(forkedFrom.name)}</a>` : ''}${space.forks ? ` · ${Number(space.forks)} fork${space.forks === 1 ? '' : 's'}` : ''}${live ? ` · <a href="${esc(url)}">${esc(url.replace(/^https?:\/\//, ''))}</a>` : ''}</p>
++ ${live ? `<div class="sp-frame"><iframe src="${esc(url)}" title="${esc(space.name)}" loading="lazy" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation-by-user-activation"></iframe></div>`
++ : `<p class="sp-flash">This space is a draft: only its maker can open it, in the editor.</p>`}
++ <h2 class="sp-subtitle">What it can do when you sign in</h2>
++ ${capList(caps(space)) || '<p class="sp-muted">Nothing: it does not use your account.</p>'}
++</div>`;
++ return page({
++ title: space.name, description: space.description || `${space.name}: a space on OpenVibe.Space by @${space.owner_username}.`, path: `/spaces/${space.slug}`,
++ robots: live && space.visibility === 'public' ? 'index,follow' : 'noindex,nofollow', body,
++ jsonLd: live ? { '@context': 'https://schema.org', '@type': 'WebApplication', name: space.name, url, description: space.description || undefined, applicationCategory: 'WebApplication', author: { '@type': 'Person', name: space.owner_username } } : undefined,
++ });
++}
++
++function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, templates }) {
++ const boot = {
++ space: { id: space.id, slug: space.slug, name: space.name, description: space.description, instructions: space.instructions, capabilities: caps(space), visibility: space.visibility, published: Boolean(space.site_id), url, building: Boolean(space.building_run_id), building_prompt: space.building_prompt || '' },
++ files, csrf, canPublish, aiOn, buildsLeft, templates: templates.map((t) => ({ id: t.id, name: t.name })),
++ };
++ const body = `<div class="sp-editor" id="sp-editor" data-view="preview">
++ <header class="sp-ed-bar">
++ <a class="sp-ed-back" href="/spaces/${esc(space.slug)}" aria-label="Back to the space's page"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i></a>
++ <h1 class="sp-ed-name" id="ed-name">${esc(space.name)}</h1>
++ <span class="sp-ed-status" id="ed-status" role="status"></span>
++ <div class="sp-ed-actions">
++ <button class="sp-btn sp-btn-quiet" id="ed-settings" type="button"><i class="fa-solid fa-sliders" aria-hidden="true"></i> Settings</button>
++ <button class="sp-btn sp-btn-quiet" id="ed-save" type="button"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save</button>
++ <button class="sp-btn" id="ed-publish" type="button"><i class="fa-solid fa-globe" aria-hidden="true"></i> ${space.site_id ? 'Update' : 'Publish'}</button>
++ </div>
++ </header>
++ <div class="sp-ed-ai">
++ <form id="ed-ai-form"><label class="sr-only" for="ed-ai">Ask OpenVibe AI to change this space</label>
++ <i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
++ <input id="ed-ai" maxlength="4000" autocomplete="off" placeholder="${aiOn ? 'Ask AI to change it: “make the buttons bigger and add a dark mode toggle”' : 'OpenVibe AI is not available right now'}" ${aiOn ? '' : 'disabled'}>
++ <button class="sp-btn" ${aiOn ? '' : 'disabled'}>Change</button></form>
++ </div>
++ <div class="sp-ed-switch" role="tablist" aria-label="Show"><button type="button" role="tab" data-view="preview" aria-selected="true"><i class="fa-solid fa-eye" aria-hidden="true"></i> Preview</button><button type="button" role="tab" data-view="code" aria-selected="false"><i class="fa-solid fa-code" aria-hidden="true"></i> Code</button></div>
++ <div class="sp-ed-main">
++ <section class="sp-ed-code" aria-label="Files">
++ <div class="sp-ed-tabs" id="ed-tabs" role="tablist"></div>
++ <textarea id="ed-code" spellcheck="false" aria-label="File contents"></textarea>
++ </section>
++ <section class="sp-ed-preview" aria-label="Preview">
++ <div class="sp-ed-preview-bar"><span><i class="fa-solid fa-eye" aria-hidden="true"></i> Preview <span class="sp-muted">(runs as you)</span></span><button class="sp-btn sp-btn-quiet sp-btn-sm" id="ed-run" type="button"><i class="fa-solid fa-rotate-right" aria-hidden="true"></i> Run</button></div>
++ <iframe id="ed-frame" title="Preview of the space" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock"></iframe>
++ </section>
++ </div>
++ <div class="sp-ed-building" id="ed-building" hidden><div><i class="fa-solid fa-wand-magic-sparkles fa-bounce" aria-hidden="true"></i><h2>OpenVibe AI is building…</h2><p id="ed-building-text"></p><p class="sp-muted">This usually takes under a minute or two. You can leave and come back.</p></div></div>
++ <dialog class="sp-ed-dialog" id="ed-dialog">
++ <form method="dialog" id="ed-settings-form">
++ <h2>Settings</h2>
++ <label>Name<input name="name" maxlength="40" required></label>
++ <label>Description<textarea name="description" rows="2" maxlength="400" placeholder="What it does, for the gallery"></textarea></label>
++ <label>Its AI's instructions<textarea name="instructions" rows="4" maxlength="4000" placeholder="Who the space's AI is and how it answers (used by OV.ai.ask)"></textarea></label>
++ <fieldset><legend>What it may ask visitors for</legend>
++ <label class="sp-check"><input type="checkbox" name="cap" value="profile"> Their username</label>
++ <label class="sp-check"><input type="checkbox" name="cap" value="data"> Adding to shared lists (OV.items)</label>
++ <label class="sp-check"><input type="checkbox" name="cap" value="kv"> Their private storage (OV.kv)</label>
++ <label class="sp-check"><input type="checkbox" name="cap" value="ai"> OpenVibe AI for them (OV.ai)</label>
++ </fieldset>
++ <fieldset><legend>Who can find it</legend>
++ <label class="sp-check"><input type="radio" name="visibility" value="public"> Public: in the gallery</label>
++ <label class="sp-check"><input type="radio" name="visibility" value="unlisted"> Unlisted: only with the link</label>
++ </fieldset>
++ <div class="sp-row sp-dialog-actions"><button class="sp-btn" value="save">Save settings</button><button class="sp-btn sp-btn-quiet" value="cancel" formnovalidate>Cancel</button></div>
++ <hr>
++ <div class="sp-row"><button class="sp-btn sp-btn-quiet sp-btn-sm" id="ed-unpublish" type="button" ${space.site_id ? '' : 'hidden'}>Unpublish</button><button class="sp-btn sp-btn-danger sp-btn-sm" id="ed-delete" type="button">Delete this space</button></div>
++ </form>
++ </dialog>
++</div>
++<script type="application/json" id="sp-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
++<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
++<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
++<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/xml/xml.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
++<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
++<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/css/css.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
++<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
++<script src="${asset('js/space-editor.js')}" defer></script>`;
++ return page({ title: `Edit ${space.name}`, path: `/spaces/${space.slug}/edit`, robots: 'noindex,nofollow', body });
++}
++
++function connectPage({ viewer, space, csrf, challenge, state, returnPath }) {
++ const u = viewer.user || {};
++ const body = `<div class="sp-wrap sp-connect">
++ <div class="sp-connect-card">
++ <p class="eyebrow">Sign in to a space</p>
++ <h1 class="sp-title">${esc(space.name)}</h1>
++ <p class="sp-muted">by @${esc(space.owner_username)} · ${esc(`${space.slug}`)}.openvibe.website</p>
++ <p>Signed in as <strong>${esc(u.display_name || u.username || 'you')}</strong>. If you continue, this space may:</p>
++ ${capList(caps(space).length ? caps(space) : ['profile'])}
++ <p class="sp-muted">It never sees your password or your OpenVibe session, and the sign-in lasts a day. Spaces are made by people on OpenVibe, not by OpenVibe.</p>
++ <form method="post" action="/spaces/${esc(space.slug)}/connect" class="sp-row">
++ <input type="hidden" name="csrf" value="${esc(csrf)}"><input type="hidden" name="challenge" value="${esc(challenge)}">
++ <input type="hidden" name="state" value="${esc(state)}"><input type="hidden" name="return" value="${esc(returnPath)}">
++ <button class="sp-btn" name="decision" value="allow">Continue</button>
++ <button class="sp-btn sp-btn-quiet" name="decision" value="deny">Cancel</button>
++ </form>
++ </div>
++</div>`;
++ return page({ title: `Sign in to ${space.name}`, path: `/spaces/${space.slug}/connect`, robots: 'noindex,nofollow', body });
++}
++
++function errorBody(status, message) {
++ return page({ title: status === 404 ? 'Space not found' : 'Spaces', path: '/spaces', robots: 'noindex,nofollow', body: `<div class="sp-wrap"><p class="eyebrow">Error ${Number(status)}</p><h1 class="sp-title">${status === 404 ? 'No such space' : 'That did not work'}</h1><p class="sp-lede">${esc(message)}</p><p><a class="sp-btn" href="/spaces">Back to spaces</a></p></div>` });
++}
++
++module.exports = { galleryPage, newPage, minePage, spacePage, editorPage, connectPage, errorBody, CAP_TEXT };
ddiff --git a/server/spaces/sdk.js b/server/spaces/sdk.js
nnew file mode 100644
iindex 0000000..2c981a1
---- /dev/null
++++ b/server/spaces/sdk.js
@@@ -0,0 +1,126 @@
++'use strict';
++/**
++ * The space SDK (window.OV), served at /_openvibe/sdk.js:
++ *
++ * on a published space's own address "live": the visitor signs in through openvibe.space (a redirect to the
++ * connect page and back with a one-time code, PKCE S256), the token is kept
++ * in sessionStorage on the space's origin, and calls go to Space's API
++ * (POST /api/v1/apps/:id/run) from that origin.
++ * on openvibe.space itself "preview": the editor runs the space's files in a sandboxed frame (an opaque
++ * origin with no storage), so every call is a postMessage to the editor,
++ * which runs it as the signed-in maker.
++ *
++ * The API is the one the AI builder is told about (OpenVibe.AI server/workflows/space.js SDK_REFERENCE).
++ */
++const SDK = String.raw`(function () {
++ 'use strict';
++ var CFG = __CONFIG__;
++ if (window.OV && window.OV.__space) return;
++ var listeners = [];
++ var OV = { __space: true, version: 1, space: { id: CFG.id, slug: CFG.slug, name: CFG.name }, user: null, preview: CFG.mode === 'preview' };
++ function emit() { for (var i = 0; i < listeners.length; i++) { try { listeners[i](OV.user); } catch (e) { setTimeout(function () { throw e; }); } } }
++ function setUser(u) { OV.user = u ? { username: u.username, display_name: u.display_name || u.username, avatar_url: CFG.avatar.replace('{username}', encodeURIComponent(u.username)) } : null; emit(); }
++ function fail(code, message) { var e = new Error(message || code); e.code = code; return e; }
++ OV.on = function (name, fn) { if (name === 'user' && typeof fn === 'function') listeners.push(fn); return OV; };
++
++ var call;
++ if (CFG.mode === 'preview') {
++ var seq = 0, waiting = {};
++ window.addEventListener('message', function (ev) {
++ var d = ev.data;
++ if (ev.source !== window.parent || !d || d.ovSpacePreview !== 1 || !waiting[d.id]) return;
++ var w = waiting[d.id]; delete waiting[d.id];
++ if (d.ok) w.resolve(d.result); else w.reject(fail(d.code || 'error', d.message));
++ });
++ call = function (op, args) {
++ return new Promise(function (resolve, reject) {
++ var id = ++seq; waiting[id] = { resolve: resolve, reject: reject };
++ window.parent.postMessage({ ovSpacePreview: 1, id: id, op: op, args: args || {} }, '*');
++ });
++ };
++ OV.signIn = function () { return call('me').then(function (u) { setUser(u); return u; }); };
++ OV.signOut = function () { setUser(null); return Promise.resolve(); };
++ OV.ready = call('me').then(function (u) { setUser(u); return u; }, function () { return null; });
++ } else {
++ var KEY = 'ov_space_' + CFG.id;
++ var store = { get: function (k) { try { return sessionStorage.getItem(KEY + k); } catch (e) { return null; } },
++ set: function (k, v) { try { sessionStorage.setItem(KEY + k, v); } catch (e) { /* storage blocked: signed in for this page only */ } },
++ del: function (k) { try { sessionStorage.removeItem(KEY + k); } catch (e) { /* */ } } };
++ var memToken = null;
++ function token() { return memToken || store.get(':token'); }
++ function b64url(bytes) { var s = ''; for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
++ function random() { var a = new Uint8Array(32); crypto.getRandomValues(a); return b64url(a); }
++ function api(path, method, body) {
++ var headers = { 'Content-Type': 'application/json' };
++ var t = token(); if (t) headers.Authorization = 'Bearer ' + t;
++ return fetch(CFG.api + '/api/v1/apps/' + CFG.id + path, { method: method, headers: headers, body: body ? JSON.stringify(body) : undefined, credentials: 'omit', mode: 'cors' })
++ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) {
++ if (r.ok) return j;
++ var err = (j && j.error) || {};
++ var code = err.code || (r.status === 401 ? 'auth.required' : r.status === 429 ? 'quota' : 'error');
++ if (r.status === 401 && t) { memToken = null; store.del(':token'); store.del(':user'); setUser(null); }
++ throw fail(code, err.message || ('Request failed (' + r.status + ')'));
++ }); }, function () { throw fail('network', 'Could not reach OpenVibe. Check the connection and try again.'); });
++ }
++ call = function (op, args) { return api('/run', 'POST', { op: op, args: args || {} }).then(function (j) { return j.result; }); };
++ OV.signIn = function () {
++ var verifier = random(), state = random();
++ store.set(':verifier', verifier); store.set(':state', state);
++ return crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)).then(function (d) {
++ var back = location.pathname + location.search;
++ location.href = CFG.api + '/spaces/' + CFG.slug + '/connect?challenge=' + b64url(new Uint8Array(d)) + '&state=' + encodeURIComponent(state) + '&return=' + encodeURIComponent(back);
++ return new Promise(function () {});
++ });
++ };
++ OV.signOut = function () {
++ var p = token() ? api('/token', 'DELETE').catch(function () {}) : Promise.resolve();
++ memToken = null; store.del(':token'); store.del(':user'); setUser(null);
++ return p.then(function () {});
++ };
++ OV.ready = (function () {
++ var m = /[#&]ov_code=([A-Za-z0-9_-]+)&ov_state=([A-Za-z0-9_%-]+)/.exec(location.hash);
++ if (m) {
++ history.replaceState(null, '', location.pathname + location.search + location.hash.replace(/[#&]?ov_code=[^&]*&ov_state=[^&]*/, '').replace(/^#?$/, ''));
++ var verifier = store.get(':verifier'), state = store.get(':state');
++ store.del(':verifier'); store.del(':state');
++ if (verifier && state && decodeURIComponent(m[2]) === state) {
++ return api('/token', 'POST', { code: m[1], code_verifier: verifier }).then(function (j) {
++ memToken = j.token; store.set(':token', j.token); store.set(':user', JSON.stringify(j.user)); setUser(j.user); return OV.user;
++ }, function () { setUser(null); return null; });
++ }
++ }
++ var saved = store.get(':user');
++ if (token() && saved) { try { setUser(JSON.parse(saved)); } catch (e) { setUser(null); } }
++ return Promise.resolve(OV.user);
++ })();
++ }
++
++ function named(kind, v) { if (typeof v !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(v)) throw fail('space.invalid_name', kind + ' names are 1-64 characters: letters, digits, ".", "-" and "_"'); return v; }
++ function guard(fn) { return function () { try { return fn.apply(null, arguments); } catch (e) { return Promise.reject(e); } }; }
++ OV.items = {
++ list: guard(function (c, o) { o = o || {}; return call('items.list', { collection: named('collection', c), limit: o.limit, before: o.before }); }),
++ add: guard(function (c, value) { return call('items.add', { collection: named('collection', c), value: value }); }),
++ set: guard(function (c, value) { return call('items.set', { collection: named('collection', c), value: value }); }),
++ remove: guard(function (c, id) { return call('items.remove', { collection: named('collection', c), id: String(id) }); }),
++ };
++ OV.kv = {
++ get: guard(function (k) { return call('kv.get', { key: named('key', k) }); }),
++ set: guard(function (k, value) { return call('kv.set', { key: named('key', k), value: value }); }),
++ remove: guard(function (k) { return call('kv.remove', { key: named('key', k) }); }),
++ };
++ OV.ai = { ask: function (input) { return call('ai.ask', { input: input }).then(function (r) { return r.text; }); } };
++ OV.me = function () { return call('me'); };
++ window.OV = OV;
++})();
++`;
++
++/** The SDK for a space (live) or for the editor's preview frame. */
++function sdkSource({ mode = 'live', space = null, api = '', avatar = '' } = {}) {
++ const cfg = {
++ mode, api, avatar,
++ id: space ? space.id : 'preview', slug: space ? space.slug : 'preview', name: space ? space.name : 'Preview',
++ };
++ return SDK.replace('__CONFIG__', JSON.stringify(cfg).replace(/</g, '\\u003c'));
++}
++
++module.exports = { sdkSource };
ddiff --git a/server/spaces/spaces.js b/server/spaces/spaces.js
nnew file mode 100644
iindex 0000000..f038dc9
---- /dev/null
++++ b/server/spaces/spaces.js
@@@ -0,0 +1,284 @@
++'use strict';
++/**
++ * Spaces (plan T20 step 2; owner 2026-10-08: "a system where people can make spaces that interact with your openvibe
++ * account and the network and interconnected with sdk and such, compete with openai chatgpt spaces type of product but
++ * better"): small web apps a person makes here — from a template, from a sentence through the AI builder, or by forking
++ * someone else's — edited and run in the browser, and published at <slug>.openvibe.website, where the space SDK
++ * (/_openvibe/sdk.js, server/spaces/sdk.js) gives them the visitor's sign-in, shared lists, private storage and AI.
++ *
++ * A space is a row in space_apps (spc_<ULID>) with its source files in space_app_files. Publishing deploys those files
++ * to a site of the same name in the owner's Spaces project, so the hosting engine serves it (immutable deploys,
++ * takedowns, quotas) and publishing opens with hosting (SPACE_PUBLIC_HOSTING, or OpenVibe staff). Making, editing,
++ * previewing and the AI builder need no hosting: the preview runs in a sandboxed frame on the editor's own page.
++ */
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++const { ApiError } = require('../http/errors');
++
++const SPACE_RE = /^spc_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SLUG_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$/;
++const CAPABILITIES = ['profile', 'data', 'kv', 'ai'];
++const DAY = 24 * 3600 * 1000;
++
++/** A file path a space may have: up to four short segments of safe characters, relative, never "..". */
++function cleanPath(p) {
++ const s = String(p || '').trim().replace(/^\.?\/+/, '');
++ if (!s || s.length > 80 || !/^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*){0,3}$/.test(s) || s.split('/').includes('..')) return null;
++ return s;
++}
++
++const text = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
++const capsOf = (v) => {
++ let list = v;
++ if (typeof v === 'string') { try { list = JSON.parse(v); } catch { list = []; } }
++ return [...new Set((Array.isArray(list) ? list : []).map(String).filter((c) => CAPABILITIES.includes(c)))];
++};
++
++/** "My Pizza Poll!" → "my-pizza-poll" (a DNS label), padded so it is at least three characters. */
++function slugBase(name) {
++ const s = String(name || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
++ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30).replace(/-+$/g, '');
++ return s.length >= 3 ? s : `space-${s}`.replace(/-+$/, '');
++}
++
++function createSpaces({ store, config, projects, sites, deploys, takedowns, log = console }) {
++ const { db } = store;
++ const limits = config.spaces;
++ const q = {
++ byId: db.prepare("SELECT * FROM space_apps WHERE id = ? AND status = 'active'"),
++ bySlug: db.prepare("SELECT * FROM space_apps WHERE slug = ? AND status = 'active'"),
++ bySite: db.prepare("SELECT * FROM space_apps WHERE site_id = ? AND status = 'active'"),
++ slugUsed: db.prepare("SELECT 1 FROM space_apps WHERE slug = ? AND status = 'active' UNION ALL SELECT 1 FROM space_sites WHERE name = ? AND status = 'active'"),
++ ownedCount: db.prepare("SELECT COUNT(*) AS n FROM space_apps WHERE owner_subject = ? AND status = 'active'"),
++ mine: db.prepare("SELECT * FROM space_apps WHERE owner_subject = ? AND status = 'active' ORDER BY updated_at DESC LIMIT 100"),
++ insert: db.prepare(`INSERT INTO space_apps (id, slug, owner_subject, owner_username, owner_name, name, description, instructions, capabilities,
++ template, forked_from, visibility, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'public', ?, ?)`),
++ files: db.prepare('SELECT path, content FROM space_app_files WHERE space_id = ? ORDER BY path'),
++ clearFiles: db.prepare('DELETE FROM space_app_files WHERE space_id = ?'),
++ addFile: db.prepare('INSERT INTO space_app_files (space_id, path, content, updated_at) VALUES (?, ?, ?, ?)'),
++ touch: db.prepare('UPDATE space_apps SET updated_at = ? WHERE id = ?'),
++ settings: db.prepare('UPDATE space_apps SET name = ?, description = ?, instructions = ?, capabilities = ?, visibility = ?, updated_at = ? WHERE id = ?'),
++ building: db.prepare('UPDATE space_apps SET building_run_id = ?, building_prompt = ?, updated_at = ? WHERE id = ?'),
++ built: db.prepare('UPDATE space_apps SET building_run_id = NULL, building_prompt = NULL, name = ?, description = ?, instructions = ?, capabilities = ?, updated_at = ? WHERE id = ? AND building_run_id = ?'),
++ buildFailed: db.prepare('UPDATE space_apps SET building_run_id = NULL, building_prompt = NULL, updated_at = ? WHERE id = ? AND building_run_id = ?'),
++ forked: db.prepare('UPDATE space_apps SET forks = forks + 1 WHERE id = ?'),
++ published: db.prepare('UPDATE space_apps SET site_id = ?, published_at = ?, updated_at = ? WHERE id = ?'),
++ unpublished: db.prepare('UPDATE space_apps SET site_id = NULL, published_at = NULL, updated_at = ? WHERE id = ?'),
++ remove: db.prepare("UPDATE space_apps SET status = 'deleted', site_id = NULL, deleted_at = ?, updated_at = ? WHERE id = ?"),
++ project: db.prepare('SELECT project_id FROM space_app_projects WHERE owner_subject = ?'),
++ addProject: db.prepare('INSERT INTO space_app_projects (owner_subject, project_id, created_at) VALUES (?, ?, ?) ON CONFLICT (owner_subject) DO NOTHING'),
++ gallery: db.prepare(`SELECT * FROM space_apps WHERE status = 'active' AND site_id IS NOT NULL AND visibility = 'public'
++ AND (? = '' OR name ILIKE ? OR description ILIKE ? OR owner_username ILIKE ?)
++ ORDER BY CASE WHEN ? = 'popular' THEN forks ELSE 0 END DESC, published_at DESC LIMIT ?`),
++ counter: db.prepare(`INSERT INTO space_app_counters (scope, day, n) VALUES (?, ?, 1)
++ ON CONFLICT (scope, day) DO UPDATE SET n = space_app_counters.n + 1 RETURNING n`),
++ counterGet: db.prepare('SELECT n FROM space_app_counters WHERE scope = ? AND day = ?'),
++ };
++
++ const isSpaceId = (v) => typeof v === 'string' && SPACE_RE.test(v);
++ const hostnameOf = (space) => `${space.slug}.${config.sitesDomain}`;
++ const urlOf = (space) => {
++ const u = new URL(config.baseUrl);
++ return `${u.protocol}//${hostnameOf(space)}${u.port ? `:${u.port}` : ''}`;
++ };
++ const today = () => new Date(store.now()).toISOString().slice(0, 10);
++ const personOf = (viewer) => {
++ if (!viewer || viewer.kind !== 'user' || !ids.isSubjectId('user', viewer.subject)) throw new ApiError(401, 'auth.required', 'sign in with OpenVibe first');
++ const u = viewer.user || {};
++ return { subject: viewer.subject, username: text(u.username || u.preferred_username, 40), name: text(u.display_name || u.name || u.username, 80) };
++ };
++
++ async function get(id) { return isSpaceId(id) ? (await q.byId.get(id)) || null : null; }
++ async function getBySlug(slug) { return typeof slug === 'string' && SLUG_RE.test(slug) ? (await q.bySlug.get(slug)) || null : null; }
++ async function forSite(siteId) { return siteId ? (await q.bySite.get(siteId)) || null : null; }
++
++ const isOwner = (viewer, space) => Boolean(viewer && viewer.kind === 'user' && viewer.subject === space.owner_subject);
++ /** read: a published space anyone can open, or the owner's (and staff's) own drafts. own: the owner or staff. */
++ async function load(viewer, slug, need = 'read') {
++ const space = await getBySlug(slug);
++ const notFound = new ApiError(404, 'space.not_found', 'no such space');
++ if (!space) throw notFound;
++ const owner = isOwner(viewer, space) || Boolean(viewer && viewer.staff);
++ if (need === 'own' && !owner) throw isOwner(viewer, space) || space.site_id ? new ApiError(403, 'space.not_owner', 'only the space\'s maker can change it') : notFound;
++ if (need === 'read' && !owner && !space.site_id) throw notFound;
++ return { space, owner };
++ }
++
++ async function files(space) { return await q.files.all(space.id); }
++
++ /** Checked file list: safe unique paths, an index.html, within the file and size limits. */
++ function checkFiles(list) {
++ if (!Array.isArray(list) || !list.length) throw new ApiError(422, 'space.files_required', 'a space needs at least an index.html');
++ if (list.length > limits.maxFiles) throw new ApiError(422, 'space.too_many_files', `a space has at most ${limits.maxFiles} files`);
++ const seen = new Set();
++ let total = 0;
++ const out = [];
++ for (const f of list) {
++ const path = cleanPath(f && f.path);
++ if (!path) throw new ApiError(422, 'space.invalid_path', `"${String(f && f.path).slice(0, 80)}" is not a file path a space can have (letters, digits, ".", "-", "_", up to four folders)`);
++ if (seen.has(path.toLowerCase())) throw new ApiError(422, 'space.duplicate_path', `${path} is there twice`);
++ if (typeof f.content !== 'string') throw new ApiError(422, 'space.invalid_content', `${path} has no text content`);
++ seen.add(path.toLowerCase());
++ total += Buffer.byteLength(f.content);
++ out.push({ path, content: f.content });
++ }
++ if (total > limits.maxBytes) throw new ApiError(413, 'space.too_large', `a space's files add up to at most ${Math.round(limits.maxBytes / 1024)} KB`);
++ if (!seen.has('index.html')) throw new ApiError(422, 'space.index_required', 'a space needs an index.html');
++ return out;
++ }
++
++ async function writeFiles(space, list, now) {
++ await store.tx(async () => {
++ await q.clearFiles.run(space.id);
++ for (const f of list) await q.addFile.run(space.id, f.path, f.content, now);
++ await q.touch.run(now, space.id);
++ });
++ }
++
++ async function uniqueSlug(name) {
++ const base = slugBase(name);
++ for (let i = 0; i < 8; i++) {
++ const tail = crypto.randomBytes(3).toString('hex').slice(0, 4);
++ const slug = `${base.slice(0, 34).replace(/-+$/, '')}-${tail}`;
++ try { sites.checkName(slug); } catch { continue; }
++ if (!(await q.slugUsed.get(slug, slug))) return slug;
++ }
++ throw new ApiError(503, 'space.slug_unavailable', 'could not find a free address for the space; try another name');
++ }
++
++ /** 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);
++ if ((await q.ownedCount.get(person.subject)).n >= limits.maxPerOwner) throw new ApiError(429, 'quota.spaces', `you have ${limits.maxPerOwner} spaces already; delete one to make another`);
++ const name = text(input.name, 40) || 'Untitled space';
++ const fileList = checkFiles(input.files);
++ const now = store.now();
++ const id = `spc_${ids.ulid(now)}`;
++ const 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);
++ for (const f of fileList) await q.addFile.run(id, f.path, f.content, now);
++ });
++ return await get(id);
++ }
++
++ async function saveFiles(viewer, space, list) {
++ const checked = checkFiles(list);
++ await writeFiles(space, checked, store.now());
++ return checked;
++ }
++
++ async function updateSettings(viewer, space, input = {}) {
++ const name = input.name !== undefined ? text(input.name, 40) : space.name;
++ if (!name) throw new ApiError(422, 'space.invalid_name', 'a space needs a name');
++ const visibility = input.visibility !== undefined ? String(input.visibility) : space.visibility;
++ if (!['public', 'unlisted'].includes(visibility)) throw new ApiError(422, 'space.invalid_visibility', 'visibility is public (in the gallery) or unlisted (only by its link)');
++ await q.settings.run(name, input.description !== undefined ? text(input.description, 400) : space.description,
++ input.instructions !== undefined ? String(input.instructions || '').trim().slice(0, 4000) : space.instructions,
++ JSON.stringify(input.capabilities !== undefined ? capsOf(input.capabilities) : capsOf(space.capabilities)), visibility, store.now(), space.id);
++ return await get(space.id);
++ }
++
++ async function fork(viewer, space) {
++ const copy = await create(viewer, {
++ name: `${space.name}`.slice(0, 33) + ' (fork)', description: space.description, instructions: space.instructions,
++ capabilities: capsOf(space.capabilities), files: await files(space), template: space.template, forked_from: space.id,
++ });
++ await q.forked.run(space.id);
++ return copy;
++ }
++
++ // ── AI builds: a run in progress is remembered on the space, so a reload of the editor keeps waiting for it ──
++ async function startBuild(space, runId, prompt) { await q.building.run(runId, text(prompt, 2000), store.now(), space.id); }
++ /** The build's output replaces the files and settings, once (only while that run is still the space's build). */
++ async function finishBuild(space, runId, out) {
++ const list = checkFiles(out.files);
++ const now = store.now();
++ let applied = false;
++ await store.tx(async () => {
++ const r = await q.built.run(text(out.name, 40) || space.name, text(out.description, 400), String(out.instructions || '').slice(0, 4000), JSON.stringify(capsOf(out.capabilities)), now, space.id, runId);
++ if (!r.changes) return;
++ applied = true;
++ await q.clearFiles.run(space.id);
++ for (const f of list) await q.addFile.run(space.id, f.path, f.content, now);
++ });
++ return applied;
++ }
++ async function failBuild(space, runId) { await q.buildFailed.run(store.now(), space.id, runId); }
++
++ /** Count one use of a daily allowance; false when `max` is already used up today. */
++ async function spend(scope, max) {
++ const day = today();
++ const cur = await q.counterGet.get(scope, day);
++ if (cur && cur.n >= max) return false;
++ await q.counter.get(scope, day);
++ return true;
++ }
++ async function used(scope) { const cur = await q.counterGet.get(scope, today()); return cur ? cur.n : 0; }
++
++ // ── Publishing: the hosting engine serves a published space ──
++ const canPublish = (viewer) => Boolean(config.publicHosting || (viewer && viewer.staff));
++
++ async function spacesProject(viewer, person) {
++ const row = await q.project.get(person.subject);
++ if (row) {
++ const p = await projects.get(row.project_id);
++ if (p && p.status !== 'deleted') return p;
++ }
++ const p = await projects.create(viewer, { name: 'Spaces' });
++ await q.addProject.run(person.subject, p.id, store.now());
++ return p;
++ }
++
++ async function publish(viewer, space, { traceparent } = {}) {
++ const person = personOf(viewer);
++ if (!isOwner(viewer, space)) throw new ApiError(403, 'space.not_owner', 'only the space\'s maker publishes it');
++ if (!canPublish(viewer)) throw new ApiError(403, 'space.not_launched', 'Publishing a space at its own address opens with OpenVibe.Space\'s public hosting. Until then a space runs here, in its editor.');
++ if (space.building_run_id) throw new ApiError(409, 'space.building', 'the AI is still changing this space; publish when it is done');
++ const project = await spacesProject(viewer, person);
++ let site = space.site_id ? await sites.get(space.site_id) : null;
++ if (!site || site.status !== 'active') site = await sites.create(viewer, project, { name: space.slug });
++ const entries = (await files(space)).map((f) => ({ path: f.path, data: Buffer.from(f.content, 'utf8') }));
++ const pre = await deploys.precheck(viewer, site.id);
++ const deploy = await deploys.create(viewer, pre, entries, { source: 'files', activate: true, notes: [`space ${space.id}`], traceparent });
++ const now = store.now();
++ await q.published.run(site.id, space.published_at || now, now, space.id);
++ return { space: await get(space.id), site, deploy, url: urlOf(space) };
++ }
++
++ async function unpublish(viewer, space) {
++ if (!space.site_id) return await get(space.id);
++ const site = await sites.get(space.site_id);
++ if (site && site.status === 'active') await sites.remove(viewer, site.id, { deploys });
++ await q.unpublished.run(store.now(), space.id);
++ return await get(space.id);
++ }
++
++ async function remove(viewer, space) {
++ if (space.site_id) await unpublish(viewer, space);
++ const now = store.now();
++ await q.remove.run(now, now, space.id);
++ }
++
++ async function gallery({ q: query = '', sort = 'new', limit = 48 } = {}) {
++ const s = text(query, 80);
++ const like = `%${s.replace(/[%_\\]/g, (c) => `\\${c}`)}%`;
++ const rows = await q.gallery.all(s, like, like, like, sort === 'popular' ? 'popular' : 'new', Math.min(100, Math.max(1, limit)));
++ const out = [];
++ for (const r of rows) {
++ const site = await sites.get(r.site_id);
++ if (site && site.status === 'active' && !(takedowns && await takedowns.ofSite(site))) out.push(r);
++ }
++ return out;
++ }
++ async function mine(viewer) { return await q.mine.all(personOf(viewer).subject); }
++
++ 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,
++ personOf, capsOf, checkFiles, DAY,
++ };
++}
++
++module.exports = { createSpaces, cleanPath, slugBase, CAPABILITIES };
ddiff --git a/server/spaces/templates.js b/server/spaces/templates.js
nnew file mode 100644
iindex 0000000..6ff661c
---- /dev/null
++++ b/server/spaces/templates.js
@@@ -0,0 +1,32 @@
++'use strict';
++/**
++ * The starters a space can begin from (templates/spaces/*.html). Each is one self-contained index.html built on the
++ * space SDK, so it doubles as an example of every SDK feature: shared lists (guestbook, poll, leaderboard), private
++ * storage (to-dos, the AI buddy's history) and OpenVibe AI (the AI buddy).
++ */
++const fs = require('fs');
++const path = require('path');
++
++const DIR = path.join(__dirname, '..', '..', 'templates', 'spaces');
++const TEMPLATES = [
++ { id: 'guestbook', name: 'Guestbook', icon: 'fa-book-open', accent: '#c2410c', description: 'Visitors sign in and leave a note for everyone to read.', capabilities: ['profile', 'data'] },
++ { id: 'ai-buddy', name: 'AI buddy', icon: 'fa-robot', accent: '#4f46e5', description: 'A chat with an AI character you describe; each visitor\'s conversation is kept for them.', capabilities: ['profile', 'kv', 'ai'],
++ instructions: 'You are a friendly, curious and slightly funny companion. Keep answers short and warm, ask a question back now and then, and never pretend to be a person.' },
++ { id: 'poll', name: 'Quick poll', icon: 'fa-square-poll-vertical', accent: '#059669', description: 'One question, a few answers, one vote each, live results.', capabilities: ['profile', 'data'] },
++ { id: 'reaction', name: 'Reaction game', icon: 'fa-bolt', accent: '#16a34a', description: 'Click when it turns green; best times go on a leaderboard.', capabilities: ['profile', 'data'] },
++ { id: 'todo', name: 'Private to-dos', icon: 'fa-list-check', accent: '#ca8a04', description: 'A to-do list saved to each visitor\'s own account.', capabilities: ['profile', 'kv'] },
++ { id: 'blank', name: 'Blank', icon: 'fa-file', accent: '#7c3aed', description: 'An empty page with sign-in, to build from scratch.', capabilities: ['profile'] },
++];
++const cache = new Map();
++
++function list() { return TEMPLATES; }
++function get(id) { return TEMPLATES.find((t) => t.id === id) || null; }
++/** The template's files ([{ path, content }]), or null for an unknown id. */
++function files(id) {
++ const t = get(id);
++ if (!t) return null;
++ if (!cache.has(id)) cache.set(id, fs.readFileSync(path.join(DIR, `${id}.html`), 'utf8'));
++ return [{ path: 'index.html', content: cache.get(id) }];
++}
++
++module.exports = { list, get, files };
ddiff --git a/templates/spaces/ai-buddy.html b/templates/spaces/ai-buddy.html
nnew file mode 100644
iindex 0000000..7b81d61
---- /dev/null
++++ b/templates/spaces/ai-buddy.html
@@@ -0,0 +1,82 @@
++<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>AI buddy</title>
++<style>
++ :root { --bg: #f4f6fb; --panel: #ffffff; --ink: #141a2b; --muted: #61697f; --me: #4f46e5; --line: #e1e5ef; }
++ @media (prefers-color-scheme: dark) { :root { --bg: #0d1020; --panel: #161a2e; --ink: #e8ebf7; --muted: #9aa3bd; --me: #818cf8; --line: #262c47; } }
++ * { box-sizing: border-box; }
++ html, body { height: 100%; }
++ body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; display: flex; justify-content: center; }
++ .app { width: 100%; max-width: 720px; height: 100%; display: grid; grid-template-rows: auto 1fr auto; }
++ header { padding: 18px 16px 10px; display: flex; align-items: center; gap: 12px; }
++ .face { width: 44px; height: 44px; border-radius: 14px; display: grid; place-items: center; font-size: 24px; background: linear-gradient(135deg, #6366f1, #ec4899); }
++ h1 { font-size: 1.15rem; margin: 0; } .tag { color: var(--muted); font-size: 0.85rem; margin: 0; }
++ header button { margin-left: auto; }
++ #log { overflow-y: auto; padding: 8px 16px 16px; display: flex; flex-direction: column; gap: 10px; }
++ .msg { max-width: 82%; padding: 10px 14px; border-radius: 18px; white-space: pre-wrap; overflow-wrap: anywhere; }
++ .ai { background: var(--panel); border: 1px solid var(--line); border-bottom-left-radius: 6px; align-self: flex-start; }
++ .me { background: var(--me); color: #fff; border-bottom-right-radius: 6px; align-self: flex-end; }
++ .hint { color: var(--muted); text-align: center; margin: auto; padding: 24px; max-width: 420px; }
++ form { display: flex; gap: 8px; padding: 12px 16px 18px; background: linear-gradient(transparent, var(--bg) 30%); }
++ input { flex: 1; min-width: 0; border: 1px solid var(--line); background: var(--panel); color: inherit; border-radius: 999px; padding: 12px 16px; font: inherit; }
++ button { border: 0; border-radius: 999px; padding: 10px 18px; background: var(--me); color: #fff; font: 600 0.95rem system-ui, sans-serif; cursor: pointer; }
++ button.ghost { background: transparent; color: var(--muted); border: 1px solid var(--line); padding: 6px 12px; font-weight: 500; }
++ button:disabled { opacity: 0.6; cursor: default; }
++ :focus-visible { outline: 3px solid color-mix(in srgb, var(--me) 45%, transparent); outline-offset: 2px; }
++ .typing { color: var(--muted); font-style: italic; }
++</style>
++</head>
++<body>
++<div class="app">
++ <header>
++ <div class="face" aria-hidden="true">🤖</div>
++ <div><h1>AI buddy</h1><p class="tag">Friendly, curious and a little bit funny.</p></div>
++ <button class="ghost" id="clear" type="button" hidden>New chat</button>
++ </header>
++ <div id="log" aria-live="polite"></div>
++ <form id="ask"><input id="q" autocomplete="off" maxlength="2000" placeholder="Say something…" aria-label="Your message"><button type="submit">Send</button></form>
++</div>
++<script src="/_openvibe/sdk.js"></script>
++<script>
++ const log = document.getElementById('log'), form = document.getElementById('ask'), input = document.getElementById('q'), clear = document.getElementById('clear');
++ let history = [];
++ function bubble(role, text, cls) { const d = document.createElement('div'); d.className = 'msg ' + (role === 'user' ? 'me' : 'ai') + (cls ? ' ' + cls : ''); d.textContent = text; log.append(d); log.scrollTop = log.scrollHeight; return d; }
++ function render() {
++ log.replaceChildren();
++ if (!OV.user) {
++ const h = document.createElement('div'); h.className = 'hint';
++ h.append('Sign in to chat. Your conversation is kept just for you. ');
++ const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Sign in with OpenVibe'; b.onclick = () => OV.signIn();
++ h.append(document.createElement('br'), document.createElement('br'), b); log.append(h); clear.hidden = true; return;
++ }
++ if (!history.length) { const h = document.createElement('div'); h.className = 'hint'; h.textContent = 'Hi ' + OV.user.display_name + '! Ask me anything.'; log.append(h); }
++ history.forEach((m) => bubble(m.role, m.content));
++ clear.hidden = !history.length;
++ }
++ async function loadHistory() { history = OV.user ? (await OV.kv.get('history').catch(() => null)) || [] : []; render(); }
++ form.onsubmit = async (e) => {
++ e.preventDefault();
++ const text = input.value.trim(); if (!text) return;
++ if (!OV.user) return OV.signIn();
++ input.value = ''; form.querySelector('button').disabled = true;
++ if (!history.length) log.replaceChildren();
++ history.push({ role: 'user', content: text }); bubble('user', text);
++ const wait = bubble('ai', 'thinking…', 'typing');
++ try {
++ const answer = await OV.ai.ask({ messages: history.slice(-20) });
++ history.push({ role: 'assistant', content: answer }); wait.classList.remove('typing'); wait.textContent = answer;
++ await OV.kv.set('history', history.slice(-40));
++ } catch (err) {
++ history.pop(); wait.classList.remove('typing');
++ wait.textContent = err.code === 'quota' ? 'I am out of answers for today. Come back tomorrow!' : 'Hmm, I could not answer: ' + err.message;
++ } finally { form.querySelector('button').disabled = false; clear.hidden = !history.length; input.focus(); }
++ };
++ clear.onclick = async () => { history = []; await OV.kv.remove('history').catch(() => {}); render(); };
++ OV.on('user', loadHistory);
++ OV.ready.then(loadHistory);
++</script>
++</body>
++</html>
ddiff --git a/templates/spaces/blank.html b/templates/spaces/blank.html
nnew file mode 100644
iindex 0000000..b6e0f90
---- /dev/null
++++ b/templates/spaces/blank.html
@@@ -0,0 +1,34 @@
++<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>My space</title>
++<style>
++ :root { --bg: #f7f7fb; --ink: #16161d; --muted: #6b6b7b; --accent: #7c3aed; }
++ @media (prefers-color-scheme: dark) { :root { --bg: #101016; --ink: #ececf3; --muted: #a0a0b4; --accent: #a78bfa; } }
++ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; padding: 24px; text-align: center; }
++ h1 { font-size: 2.2rem; margin: 0 0 8px; }
++ p { color: var(--muted); margin: 0 0 20px; }
++ button { border: 0; border-radius: 999px; padding: 10px 18px; background: var(--accent); color: #fff; font: 600 1rem system-ui, sans-serif; cursor: pointer; }
++</style>
++</head>
++<body>
++<main>
++ <h1>Hello!</h1>
++ <p id="hi">This is my new space on OpenVibe.</p>
++ <button id="who" type="button">Sign in with OpenVibe</button>
++</main>
++<script src="/_openvibe/sdk.js"></script>
++<script>
++ const hi = document.getElementById('hi'), who = document.getElementById('who');
++ function show() {
++ hi.textContent = OV.user ? 'Welcome, ' + OV.user.display_name + '!' : 'This is my new space on OpenVibe.';
++ who.textContent = OV.user ? 'Sign out' : 'Sign in with OpenVibe';
++ }
++ who.onclick = () => (OV.user ? OV.signOut() : OV.signIn());
++ OV.on('user', show);
++ OV.ready.then(show);
++</script>
++</body>
++</html>
ddiff --git a/templates/spaces/guestbook.html b/templates/spaces/guestbook.html
nnew file mode 100644
iindex 0000000..3bf8def
---- /dev/null
++++ b/templates/spaces/guestbook.html
@@@ -0,0 +1,91 @@
++<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>Guestbook</title>
++<style>
++ :root { --bg: #fdf8f1; --card: #ffffff; --ink: #2b2118; --muted: #7a6a5a; --accent: #c2410c; --line: #eadfd2; }
++ @media (prefers-color-scheme: dark) { :root { --bg: #17120e; --card: #221a14; --ink: #f4ebe1; --muted: #b3a291; --accent: #fb923c; --line: #3a2d22; } }
++ * { box-sizing: border-box; }
++ body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 Georgia, "Iowan Old Style", serif; }
++ main { max-width: 640px; margin: 0 auto; padding: 40px 16px 64px; }
++ h1 { font-size: 2.4rem; margin: 0 0 4px; letter-spacing: -0.02em; }
++ .sub { color: var(--muted); margin: 0 0 28px; }
++ form, .signin { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 16px; display: grid; gap: 10px; }
++ label { font: 600 0.85rem system-ui, sans-serif; color: var(--muted); }
++ textarea { width: 100%; min-height: 84px; resize: vertical; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font: inherit; background: transparent; color: inherit; }
++ button { justify-self: start; border: 0; border-radius: 999px; padding: 10px 18px; background: var(--accent); color: #fff; font: 600 0.95rem system-ui, sans-serif; cursor: pointer; }
++ button:focus-visible, textarea:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
++ .row { display: flex; justify-content: space-between; align-items: center; gap: 12px; font: 0.85rem system-ui, sans-serif; color: var(--muted); }
++ ol { list-style: none; margin: 28px 0 0; padding: 0; display: grid; gap: 14px; }
++ li { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; display: grid; grid-template-columns: 40px 1fr; gap: 12px; }
++ li img { width: 40px; height: 40px; border-radius: 50%; background: var(--line); object-fit: cover; }
++ .who { font: 600 0.9rem system-ui, sans-serif; } .when { color: var(--muted); font-weight: 400; margin-left: 6px; }
++ .note { margin: 2px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
++ .empty, .error { color: var(--muted); text-align: center; padding: 24px; }
++ .error { color: var(--accent); }
++ .link { background: none; color: var(--muted); padding: 0; text-decoration: underline; font-weight: 400; }
++</style>
++</head>
++<body>
++<main>
++ <h1>Guestbook</h1>
++ <p class="sub">Say hello and leave a note for everyone who visits.</p>
++ <div id="composer"></div>
++ <ol id="notes" aria-live="polite"><li class="empty" style="display:block">Loading notes…</li></ol>
++</main>
++<script src="/_openvibe/sdk.js"></script>
++<script>
++ const $ = (s) => document.querySelector(s);
++ const ago = (iso) => { const s = Math.max(1, (Date.now() - new Date(iso)) / 1000); const u = [[86400, 'd'], [3600, 'h'], [60, 'm']].find(([n]) => s >= n); return u ? Math.floor(s / u[0]) + u[1] + ' ago' : 'just now'; };
++ function el(tag, attrs, ...kids) { const e = document.createElement(tag); Object.assign(e, attrs || {}); kids.forEach((k) => e.append(k)); return e; }
++
++ function renderComposer() {
++ const box = $('#composer'); box.replaceChildren();
++ if (!OV.user) {
++ const b = el('button', { type: 'button', textContent: 'Sign in with OpenVibe to sign the guestbook' });
++ b.onclick = () => OV.signIn();
++ box.append(el('div', { className: 'signin' }, b));
++ return;
++ }
++ const text = el('textarea', { id: 'note', maxLength: 280, required: true, placeholder: 'Write something nice…' });
++ const count = el('span', { textContent: '280 left' });
++ text.oninput = () => { count.textContent = (280 - text.value.length) + ' left'; };
++ const out = el('button', { type: 'button', className: 'link', textContent: 'Sign out' });
++ out.onclick = () => OV.signOut();
++ const form = el('form', {},
++ el('label', { htmlFor: 'note', textContent: 'Signing as ' + OV.user.display_name }), text,
++ el('div', { className: 'row' }, el('button', { type: 'submit', textContent: 'Sign the guestbook' }), count), out);
++ form.onsubmit = async (e) => {
++ e.preventDefault();
++ const value = text.value.trim(); if (!value) return;
++ form.querySelector('[type=submit]').disabled = true;
++ try { await OV.items.add('notes', { text: value }); text.value = ''; count.textContent = '280 left'; await load(); }
++ catch (err) { alert(err.code === 'quota' ? 'Slow down a little and try again in a minute.' : err.message); }
++ finally { form.querySelector('[type=submit]').disabled = false; }
++ };
++ box.append(form);
++ }
++
++ async function load() {
++ const list = $('#notes');
++ try {
++ const notes = await OV.items.list('notes', { limit: 100 });
++ list.replaceChildren();
++ if (!notes.length) { list.append(el('li', { className: 'empty', style: 'display:block', textContent: 'No notes yet. Be the first to sign!' })); return; }
++ for (const n of notes) {
++ const img = el('img', { alt: '', loading: 'lazy', src: 'https://openvibe.network/avatar/' + encodeURIComponent(n.author.username) });
++ img.onerror = () => { img.removeAttribute('src'); };
++ list.append(el('li', {}, img, el('div', {},
++ el('div', { className: 'who' }, n.author.display_name, el('span', { className: 'when', textContent: ago(n.created_at) })),
++ el('p', { className: 'note', textContent: String((n.value && n.value.text) || '') }))));
++ }
++ } catch (err) { list.replaceChildren(el('li', { className: 'error', style: 'display:block', textContent: 'Could not load the notes: ' + err.message })); }
++ }
++
++ OV.on('user', renderComposer);
++ OV.ready.then(() => { renderComposer(); load(); });
++</script>
++</body>
++</html>
ddiff --git a/templates/spaces/poll.html b/templates/spaces/poll.html
nnew file mode 100644
iindex 0000000..79b952e
---- /dev/null
++++ b/templates/spaces/poll.html
@@@ -0,0 +1,83 @@
++<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>Quick poll</title>
++<style>
++ :root { --bg: #effaf5; --card: #ffffff; --ink: #0f2a1f; --muted: #557566; --accent: #059669; --bar: #a7f3d0; --line: #d3ebe0; }
++ @media (prefers-color-scheme: dark) { :root { --bg: #08150f; --card: #10231a; --ink: #e2f5ec; --muted: #8fb5a4; --accent: #34d399; --bar: #065f46; --line: #1d3a2d; } }
++ * { box-sizing: border-box; }
++ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--ink); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; padding: 24px 16px; }
++ .card { width: 100%; max-width: 520px; background: var(--card); border: 1px solid var(--line); border-radius: 20px; padding: 24px; box-shadow: 0 20px 50px -30px rgba(0,0,0,.35); }
++ .eyebrow { text-transform: uppercase; letter-spacing: .12em; font-size: .75rem; color: var(--accent); font-weight: 700; margin: 0; }
++ h1 { font-size: 1.6rem; line-height: 1.25; margin: 6px 0 18px; }
++ .options { display: grid; gap: 10px; }
++ .opt { position: relative; overflow: hidden; display: flex; justify-content: space-between; align-items: center; gap: 12px; width: 100%; text-align: left; border: 1px solid var(--line); background: transparent; color: inherit; border-radius: 12px; padding: 12px 14px; font: inherit; cursor: pointer; }
++ .opt .fill { position: absolute; inset: 0 auto 0 0; background: var(--bar); z-index: 0; transition: width .5s ease; }
++ .opt span { position: relative; z-index: 1; }
++ .opt.chosen { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
++ .opt:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
++ .pct { font-variant-numeric: tabular-nums; font-weight: 600; }
++ .foot { margin-top: 16px; color: var(--muted); font-size: .9rem; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
++ .foot button { border: 0; background: none; color: var(--accent); font: inherit; cursor: pointer; padding: 0; text-decoration: underline; }
++</style>
++</head>
++<body>
++<main class="card">
++ <p class="eyebrow">Quick poll</p>
++ <h1 id="question"></h1>
++ <div class="options" id="options" role="group" aria-labelledby="question"></div>
++ <div class="foot"><span id="total">Loading votes…</span><span id="who"></span></div>
++</main>
++<script src="/_openvibe/sdk.js"></script>
++<script>
++ // Change the question and the answers here.
++ const POLL = { question: 'Which pizza topping wins?', options: ['Pepperoni', 'Mushrooms', 'Pineapple', 'Extra cheese'] };
++
++ document.getElementById('question').textContent = POLL.question;
++ let votes = [];
++ async function loadVotes() {
++ const all = [];
++ let before = 0;
++ for (let page = 0; page < 10; page++) {
++ const batch = await OV.items.list('votes', { limit: 100, before });
++ all.push(...batch);
++ if (batch.length < 100) break;
++ before = Number(batch[batch.length - 1].id);
++ }
++ votes = all;
++ }
++ function render() {
++ const counts = POLL.options.map(() => 0);
++ let mine = null;
++ for (const v of votes) { const i = v.value && v.value.option; if (Number.isInteger(i) && i >= 0 && i < counts.length) counts[i]++; if (v.mine) mine = i; }
++ const total = counts.reduce((a, b) => a + b, 0);
++ const box = document.getElementById('options'); box.replaceChildren();
++ POLL.options.forEach((label, i) => {
++ const pct = total ? Math.round(counts[i] * 100 / total) : 0;
++ const b = document.createElement('button'); b.type = 'button'; b.className = 'opt' + (mine === i ? ' chosen' : '');
++ b.setAttribute('aria-pressed', mine === i ? 'true' : 'false');
++ const fill = document.createElement('span'); fill.className = 'fill'; fill.style.width = (mine !== null ? pct : 0) + '%';
++ const name = document.createElement('span'); name.textContent = label + (mine === i ? ' ✓' : '');
++ const p = document.createElement('span'); p.className = 'pct'; p.textContent = mine !== null ? pct + '%' : '';
++ b.append(fill, name, p);
++ b.onclick = () => vote(i);
++ box.append(b);
++ });
++ document.getElementById('total').textContent = total === 1 ? '1 vote' : total + ' votes';
++ const who = document.getElementById('who'); who.replaceChildren();
++ if (OV.user) who.textContent = mine !== null ? 'You can change your vote.' : 'Pick one to see the results.';
++ else { const s = document.createElement('button'); s.textContent = 'Sign in with OpenVibe to vote'; s.onclick = () => OV.signIn(); who.append(s); }
++ }
++ async function vote(i) {
++ if (!OV.user) return OV.signIn();
++ try { await OV.items.set('votes', { option: i }); await loadVotes(); render(); }
++ catch (err) { alert(err.code === 'quota' ? 'Too many votes at once; wait a minute.' : err.message); }
++ }
++ async function refresh() { try { await loadVotes(); } catch (err) { document.getElementById('total').textContent = 'Could not load the votes: ' + err.message; } render(); }
++ OV.on('user', refresh);
++ OV.ready.then(refresh);
++</script>
++</body>
++</html>
ddiff --git a/templates/spaces/reaction.html b/templates/spaces/reaction.html
nnew file mode 100644
iindex 0000000..eb4b04a
---- /dev/null
++++ b/templates/spaces/reaction.html
@@@ -0,0 +1,83 @@
++<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>Reaction time</title>
++<style>
++ :root { --bg: #0b0b12; --panel: #151522; --ink: #f3f3fa; --muted: #9a9ab4; --wait: #dc2626; --go: #16a34a; --line: #26263a; --gold: #fbbf24; }
++ * { box-sizing: border-box; }
++ body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
++ main { max-width: 900px; margin: 0 auto; padding: 24px 16px 48px; display: grid; gap: 20px; grid-template-columns: 1fr; }
++ @media (min-width: 760px) { main { grid-template-columns: 1.4fr 1fr; align-items: start; } }
++ h1 { margin: 0; font-size: 1.6rem; } .sub { margin: 2px 0 0; color: var(--muted); }
++ #pad { border: 0; width: 100%; aspect-ratio: 4 / 3; border-radius: 22px; color: #fff; font: 700 clamp(1.4rem, 4vw, 2.2rem)/1.2 system-ui, sans-serif; cursor: pointer; background: #2a2a40; display: grid; place-items: center; padding: 16px; text-align: center; transition: background .08s; user-select: none; touch-action: manipulation; }
++ #pad.wait { background: var(--wait); } #pad.go { background: var(--go); }
++ #pad:focus-visible { outline: 4px solid #818cf8; outline-offset: 3px; }
++ .small { display: block; font-size: .95rem; font-weight: 500; opacity: .85; margin-top: 8px; }
++ aside { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 18px; }
++ aside h2 { margin: 0 0 10px; font-size: 1.05rem; }
++ ol { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; counter-reset: r; }
++ li { display: grid; grid-template-columns: 28px 1fr auto; gap: 8px; align-items: center; padding: 6px 8px; border-radius: 10px; counter-increment: r; }
++ li::before { content: counter(r); color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
++ li:nth-child(1)::before { content: '🥇'; } li:nth-child(2)::before { content: '🥈'; } li:nth-child(3)::before { content: '🥉'; }
++ li.muted::before { content: none; }
++ li.me { background: #22223a; } .ms { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--gold); }
++ .muted { color: var(--muted); font-size: .9rem; }
++ button.link { border: 0; background: none; color: #a5b4fc; font: inherit; cursor: pointer; padding: 0; text-decoration: underline; }
++</style>
++</head>
++<body>
++<main>
++ <section>
++ <h1>Reaction time</h1>
++ <p class="sub">Click when it turns green. Your best time goes on the board.</p>
++ <p></p>
++ <button id="pad" type="button">Click to start</button>
++ <p class="muted" id="status"></p>
++ </section>
++ <aside aria-live="polite"><h2>Leaderboard</h2><ol id="board"><li class="muted" style="display:block">Loading…</li></ol></aside>
++</main>
++<script src="/_openvibe/sdk.js"></script>
++<script>
++ const pad = document.getElementById('pad'), status = document.getElementById('status');
++ let state = 'idle', timer = null, shownAt = 0, best = null;
++ function show(cls, text, small) { pad.className = cls; pad.replaceChildren(text); if (small) { const s = document.createElement('span'); s.className = 'small'; s.textContent = small; pad.append(s); } }
++ function start() { state = 'wait'; show('wait', 'Wait for green…'); timer = setTimeout(() => { state = 'go'; shownAt = performance.now(); show('go', 'CLICK!'); }, 1200 + Math.random() * 2600); }
++ async function finish(ms) {
++ state = 'idle';
++ const isBest = best === null || ms < best;
++ show('', ms + ' ms', isBest ? 'New personal best! Click to go again.' : 'Best: ' + best + ' ms. Click to go again.');
++ if (!isBest) return;
++ best = ms;
++ if (!OV.user) { status.replaceChildren('Sign in to put ' + ms + ' ms on the board. '); const b = document.createElement('button'); b.className = 'link'; b.textContent = 'Sign in with OpenVibe'; b.onclick = () => OV.signIn(); status.append(b); return; }
++ try { await OV.items.set('scores', { ms }); status.textContent = 'Saved to the leaderboard.'; await board(); }
++ catch (err) { status.textContent = 'Could not save: ' + err.message; }
++ }
++ pad.addEventListener('pointerdown', (e) => {
++ e.preventDefault();
++ if (state === 'idle') return start();
++ if (state === 'wait') { clearTimeout(timer); state = 'idle'; show('', 'Too soon!', 'Wait for green. Click to try again.'); return; }
++ if (state === 'go') return finish(Math.round(performance.now() - shownAt));
++ });
++ pad.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); pad.dispatchEvent(new PointerEvent('pointerdown')); } });
++ async function board() {
++ const list = document.getElementById('board');
++ try {
++ const scores = (await OV.items.list('scores', { limit: 100 })).filter((s) => s.value && Number.isFinite(s.value.ms) && s.value.ms > 50).sort((a, b) => a.value.ms - b.value.ms).slice(0, 15);
++ const mine = scores.find((s) => s.mine); if (mine && (best === null || mine.value.ms < best)) best = mine.value.ms;
++ list.replaceChildren();
++ if (!scores.length) { const li = document.createElement('li'); li.className = 'muted'; li.style.display = 'block'; li.textContent = 'No scores yet. Set the first one!'; list.append(li); return; }
++ for (const s of scores) {
++ const li = document.createElement('li'); if (s.mine) li.className = 'me';
++ const name = document.createElement('span'); name.textContent = s.author.display_name;
++ const ms = document.createElement('span'); ms.className = 'ms'; ms.textContent = s.value.ms + ' ms';
++ li.append(name, ms); list.append(li);
++ }
++ } catch (err) { list.textContent = 'Could not load the board: ' + err.message; }
++ }
++ OV.on('user', board);
++ OV.ready.then(board);
++</script>
++</body>
++</html>
ddiff --git a/templates/spaces/todo.html b/templates/spaces/todo.html
nnew file mode 100644
iindex 0000000..baf8d45
---- /dev/null
++++ b/templates/spaces/todo.html
@@@ -0,0 +1,74 @@
++<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>My to-dos</title>
++<style>
++ :root { --bg: #fffdf5; --card: #ffffff; --ink: #1f1d17; --muted: #77705f; --accent: #ca8a04; --line: #eee6cf; }
++ @media (prefers-color-scheme: dark) { :root { --bg: #14130e; --card: #1e1c15; --ink: #f3efe2; --muted: #ada58f; --accent: #facc15; --line: #34301f; } }
++ * { box-sizing: border-box; }
++ body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
++ main { max-width: 560px; margin: 0 auto; padding: 40px 16px; }
++ h1 { margin: 0 0 4px; font-size: 2rem; } .sub { color: var(--muted); margin: 0 0 20px; }
++ form { display: flex; gap: 8px; }
++ input[type=text] { flex: 1; min-width: 0; border: 1px solid var(--line); background: var(--card); color: inherit; border-radius: 12px; padding: 12px 14px; font: inherit; }
++ button { border: 0; border-radius: 12px; padding: 10px 16px; background: var(--accent); color: #1f1d17; font: 600 .95rem system-ui, sans-serif; cursor: pointer; }
++ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: 2px; }
++ ul { list-style: none; padding: 0; margin: 20px 0 0; display: grid; gap: 8px; }
++ li { display: flex; align-items: center; gap: 10px; background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; }
++ li label { flex: 1; display: flex; gap: 10px; align-items: center; cursor: pointer; overflow-wrap: anywhere; }
++ li input { width: 20px; height: 20px; accent-color: var(--accent); }
++ li.done span { text-decoration: line-through; color: var(--muted); }
++ .x { background: transparent; color: var(--muted); padding: 4px 8px; font-size: 1.1rem; }
++ .center { text-align: center; color: var(--muted); padding: 32px 0; }
++ .foot { display: flex; justify-content: space-between; color: var(--muted); font-size: .9rem; margin-top: 14px; }
++ .foot button { background: none; color: var(--muted); padding: 0; text-decoration: underline; font-weight: 400; }
++</style>
++</head>
++<body>
++<main>
++ <h1>My to-dos</h1>
++ <p class="sub">A private list, saved to your OpenVibe account. Only you can see it.</p>
++ <div id="app"><p class="center">Loading…</p></div>
++</main>
++<script src="/_openvibe/sdk.js"></script>
++<script>
++ const app = document.getElementById('app');
++ let todos = [];
++ const save = () => OV.kv.set('todos', todos).catch((err) => alert('Could not save: ' + err.message));
++ function render() {
++ app.replaceChildren();
++ if (!OV.user) {
++ const p = document.createElement('p'); p.className = 'center'; p.textContent = 'Sign in to keep your list. ';
++ const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Sign in with OpenVibe'; b.onclick = () => OV.signIn();
++ p.append(document.createElement('br'), document.createElement('br'), b); app.append(p); return;
++ }
++ const form = document.createElement('form');
++ const input = document.createElement('input'); input.type = 'text'; input.maxLength = 200; input.placeholder = 'Add something to do'; input.setAttribute('aria-label', 'New to-do');
++ const add = document.createElement('button'); add.type = 'submit'; add.textContent = 'Add';
++ form.append(input, add);
++ form.onsubmit = (e) => { e.preventDefault(); const t = input.value.trim(); if (!t) return; todos.unshift({ id: Date.now(), text: t, done: false }); input.value = ''; save(); render(); app.querySelector('input[type=text]').focus(); };
++ const ul = document.createElement('ul');
++ todos.forEach((t) => {
++ const li = document.createElement('li'); if (t.done) li.className = 'done';
++ const label = document.createElement('label'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = t.done;
++ cb.onchange = () => { t.done = cb.checked; save(); render(); };
++ const span = document.createElement('span'); span.textContent = t.text; label.append(cb, span);
++ const x = document.createElement('button'); x.type = 'button'; x.className = 'x'; x.textContent = '×'; x.setAttribute('aria-label', 'Delete "' + t.text + '"');
++ x.onclick = () => { todos = todos.filter((o) => o !== t); save(); render(); };
++ li.append(label, x); ul.append(li);
++ });
++ if (!todos.length) { const li = document.createElement('li'); li.style.justifyContent = 'center'; li.style.color = 'var(--muted)'; li.textContent = 'Nothing to do. Nice!'; ul.append(li); }
++ const left = todos.filter((t) => !t.done).length;
++ const foot = document.createElement('div'); foot.className = 'foot';
++ const out = document.createElement('button'); out.type = 'button'; out.textContent = 'Sign out'; out.onclick = () => OV.signOut();
++ foot.append(left === 1 ? '1 left' : left + ' left', out);
++ app.append(form, ul, foot);
++ }
++ async function load() { todos = OV.user ? (await OV.kv.get('todos').catch(() => null)) || [] : []; render(); }
++ OV.on('user', load);
++ OV.ready.then(load);
++</script>
++</body>
++</html>
ddiff --git a/test/boot.test.js b/test/boot.test.js
iindex 0e2451c..1d59e5c 100644
---- a/test/boot.test.js
++++ b/test/boot.test.js
@@@ -67,8 +67,8 @@ async function check(name, fn) {
const r = await fetch(`${base}/`);
assert.strictEqual(r.status, 200);
const html = await r.text();
-- assert.match(html, /<title>OpenVibe\.Space — static hosting<\/title>/);
-- assert.match(html, /<h1>Publish a static site\.<span class="sc-accent"> Keep every deploy\.<\/span><\/h1>/);
++ assert.match(html, /<title>OpenVibe\.Space — spaces and static hosting<\/title>/);
++ assert.match(html, /<h1>Make a space\.<span class="sc-accent"> An app on your OpenVibe account\.<\/span><\/h1>/);
assert.match(html, /The forum moved to OpenVibe\.Community/);
assert.match(html, /href="https:\/\/openvibe\.community\/s"/);
});
ddiff --git a/test/home.test.js b/test/home.test.js
iindex 9a6f497..5283199 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -2,7 +2,7 @@
/**
* What Space says about itself while it is rebuilt: the home page is honest (code and dynamic pages,
* and "spaces" apps; nothing published yet) and links the forum on OpenVibe.Community; the discovery
-- * files describe the same site and the sitemap holds the home page alone; health and readiness answer.
++ * files describe the same site and the sitemap holds the home page and the spaces gallery; health and readiness answer.
*/
const assert = require('assert');
@@@ -21,10 +21,12 @@ const { boot, check, done } = require('./helpers/app');
(async () => {
const t = await boot();
-- await check('the home page describes staff static hosting and links the forum on OpenVibe.Community', async () => {
++ await check('the home page leads with spaces, describes staff static hosting and links the forum on OpenVibe.Community', async () => {
const r = await t.get('/');
assert.strictEqual(r.status, 200);
-- assert.match(r.text, /<h1>Publish a static site\.<span class="sc-accent"> Keep every deploy\.<\/span><\/h1>/);
++ assert.match(r.text, /<h1>Make a space\.<span class="sc-accent"> An app on your OpenVibe account\.<\/span><\/h1>/);
++ assert.match(r.text, /href="\/spaces\/new"/, 'the home page starts a space');
++ assert.match(r.text, /<h2[^>]*>Static hosting: publish a site, keep every deploy\.<\/h2>/);
assert.match(r.text, /Static site hosting is built and tested/);
assert.match(r.text, /OpenVibe staff only until public launch/);
assert.match(r.text, /What works now/);
@@@ -51,12 +53,12 @@ const { boot, check, done } = require('./helpers/app');
assert.doesNotMatch(r.text, /\/s\/feed\.xml/, 'no forum feed is advertised');
});
-- await check('the sitemap holds the home page alone', async () => {
++ await check('the sitemap holds the home page and the spaces gallery (no published space yet)', async () => {
const r = await t.get('/sitemap.xml');
assert.strictEqual(r.status, 200);
const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
-- assert.deepStrictEqual(locs, ['https://openvibe.space/']);
-- assert.strictEqual(assertSitemapLastmods(r.text).length, 1);
++ assert.deepStrictEqual(locs, ['https://openvibe.space/', 'https://openvibe.space/spaces']);
++ assert.strictEqual(assertSitemapLastmods(r.text).length, 2);
});
await check('health and readiness answer, and readiness names the store', async () => {
ddiff --git a/test/hosting-llms.test.js b/test/hosting-llms.test.js
iindex 5883416..3b8adba 100644
---- a/test/hosting-llms.test.js
++++ b/test/hosting-llms.test.js
@@@ -22,7 +22,7 @@ const { boot, check, done, DASHBOARD } = require('./stageb/boot');
assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.space\/">/);
const map = await t.get(DASHBOARD, '/sitemap.xml');
assert.strictEqual(map.status, 200);
-- assert.deepStrictEqual([...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]), ['https://openvibe.space/']);
++ assert.deepStrictEqual([...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]), ['https://openvibe.space/', 'https://openvibe.space/spaces']);
});
await t.close();
done();
ddiff --git a/test/spaces.test.js b/test/spaces.test.js
nnew file mode 100644
iindex 0000000..0bb6983
---- /dev/null
++++ b/test/spaces.test.js
@@@ -0,0 +1,310 @@
++'use strict';
++/**
++ * Spaces (server/spaces/): a person makes a space from a template or from a sentence (the AI builder), edits and
++ * previews it (the preview's SDK calls run as the maker), and publishes it as a site of their Spaces project, where it
++ * is served with the app policy and the space SDK. A visitor signs in through the connect page (PKCE, a one-time code
++ * traded from the space's own origin for a token bound to that space), adds to its shared lists, keeps private data and
++ * asks its AI; capabilities, authorship, origins and the launch switch are enforced. Forks, deletion, the gallery and
++ * the sitemap follow.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./stageb/boot');
++
++const ORIGIN = 'https://openvibe.space';
++const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
++
++function fakeAi() {
++ const calls = [];
++ const runs = new Map();
++ return {
++ calls, runs,
++ runs_: runs,
++ client: {
++ runs: {
++ async create(workflow, input, o) {
++ calls.push({ workflow, input, o });
++ if (workflow === 'space.app.ask') return { run: { id: 'run_ask', status: 'succeeded', output: { text: `Arr, ${input.instructions ? 'matey' : 'friend'}: call it Polly.` } } };
++ const id = `run_build_${runs.size + 1}`;
++ runs.set(id, { id, status: 'running' });
++ return { run: { id, status: 'queued' } };
++ },
++ async get(id) { return { run: runs.get(id) }; },
++ },
++ },
++ };
++}
++
++(async () => {
++ const ai = fakeAi();
++ const t = await boot({ spacesAiClient: ai.client });
++ const { csrfToken } = require('../server/auth/forms');
++ const alice = t.user('alice'), bob = t.user('bob'), carol = t.user('carol');
++ const csrf = (u) => csrfToken(t.config, { subject: u.subject });
++ const form = (u, path, fields) => t.api('POST', path, { session: u, form: { csrf: csrf(u), ...fields }, headers: { origin: ORIGIN } });
++ const edit = (u, method, path, json) => t.api(method, path, { session: u, json, headers: { origin: ORIGIN, 'x-csrf-token': csrf(u) } });
++ let space = null;
++ let slug = null;
++
++ await check('the gallery shows the templates to everyone, with a sign-in to start', async () => {
++ const r = await t.api('GET', '/spaces');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<h1>Make a space\./);
++ for (const name of ['Guestbook', 'AI buddy', 'Quick poll', 'Reaction game', 'Private to-dos']) assert.ok(r.text.includes(name), name);
++ assert.match(r.text, /Sign in to start/);
++ assert.doesNotMatch(r.text, /name="template"/, 'no start form for someone signed out');
++ });
++
++ await check('a person starts a space from a template and lands in its editor', async () => {
++ const r = await form(alice, '/spaces/new', { template: 'guestbook' });
++ assert.strictEqual(r.status, 303, r.text);
++ const m = /^\/spaces\/(guestbook-[0-9a-f]{4})\/edit$/.exec(r.headers.location);
++ assert.ok(m, r.headers.location);
++ slug = m[1];
++ space = await t.ctx.spaces.getBySlug(slug);
++ assert.deepStrictEqual([space.name, space.owner_username, JSON.parse(space.capabilities)], ['Guestbook', 'alice', ['profile', 'data']]);
++ const ed = await t.api('GET', `/spaces/${slug}/edit`, { session: alice });
++ assert.strictEqual(ed.status, 200);
++ const bootJson = JSON.parse(/<script type="application\/json" id="sp-boot">([\s\S]*?)<\/script>/.exec(ed.text)[1]);
++ assert.deepStrictEqual(bootJson.files.map((f) => f.path), ['index.html']);
++ assert.match(bootJson.files[0].content, /\/_openvibe\/sdk\.js/);
++ assert.match(ed.headers['content-security-policy'], /script-src 'self' 'unsafe-inline'.*https:\/\/cdn\.jsdelivr\.net/, 'the preview frame inherits a policy that lets a space\'s pinned CDN scripts run');
++ });
++
++ await check('a draft is the maker\'s alone; a form without its token is refused', async () => {
++ assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: bob })).status, 404, 'a stranger does not see a draft');
++ assert.strictEqual((await t.api('GET', `/spaces/${slug}/edit`, { session: bob })).status, 404);
++ assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: alice })).status, 200);
++ assert.strictEqual((await t.api('GET', `/spaces/${slug}/edit`)).status, 303, 'signed out: to sign-in');
++ const forged = await t.api('POST', '/spaces/new', { session: alice, form: { csrf: 'nope', template: 'poll' }, headers: { origin: ORIGIN } });
++ assert.strictEqual(forged.status, 403);
++ const crossSite = await t.api('PUT', `/spaces/${slug}/files`, { session: alice, json: { files: [] }, headers: { origin: 'https://evil.example', 'x-csrf-token': csrf(alice) } });
++ assert.strictEqual(crossSite.status, 403);
++ });
++
++ await check('saving files: safe paths, an index.html, within the limits', async () => {
++ const html = '<!doctype html><html><body><h1>Guests</h1><script src="/_openvibe/sdk.js"></script><script src="app.js"></script></body></html>';
++ const ok = await edit(alice, 'PUT', `/spaces/${slug}/files`, { files: [{ path: 'index.html', content: html }, { path: 'app.js', content: 'OV.ready.then(() => {});' }] });
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.deepStrictEqual((await t.ctx.spaces.files(space)).map((f) => f.path), ['app.js', 'index.html']);
++ for (const [files, code] of [
++ [[{ path: '../x.html', content: 'x' }, { path: 'index.html', content: html }], 'space.invalid_path'],
++ [[{ path: 'app.js', content: 'x' }], 'space.index_required'],
++ [[{ path: 'index.html', content: html }, { path: 'INDEX.html', content: html }], 'space.duplicate_path'],
++ [[{ path: 'index.html', content: 'x'.repeat(1024 * 1024 + 1) }], 'space.too_large'],
++ ]) {
++ const r = await edit(alice, 'PUT', `/spaces/${slug}/files`, { files });
++ assert.strictEqual(r.json().error.code, code, JSON.stringify(files.map((f) => f.path)));
++ }
++ });
++
++ await check('the preview runs the SDK as the maker: lists, private storage and the space\'s AI', async () => {
++ const op = async (o, args) => edit(alice, 'POST', `/spaces/${slug}/preview`, { op: o, args });
++ const added = await op('items.add', { collection: 'notes', value: { text: 'first!' } });
++ assert.strictEqual(added.status, 200, added.text);
++ assert.deepStrictEqual([added.json().result.mine, added.json().result.author.username], [true, 'alice']);
++ const list = (await op('items.list', { collection: 'notes' })).json().result;
++ assert.deepStrictEqual(list.map((i) => i.value.text), ['first!']);
++ await op('kv.set', { key: 'theme', value: { dark: true } });
++ assert.deepStrictEqual((await op('kv.get', { key: 'theme' })).json().result, { dark: true });
++ await edit(alice, 'PUT', `/spaces/${slug}/settings`, { instructions: 'Talk like a pirate.', capabilities: ['profile', 'data', 'kv', 'ai'] });
++ const asked = await op('ai.ask', { input: { messages: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'Ahoy' }, { role: 'user', content: 'name my parrot' }] } });
++ assert.strictEqual(asked.json().result.text, 'Arr, matey: call it Polly.');
++ const call = ai.calls.find((c) => c.workflow === 'space.app.ask');
++ assert.deepStrictEqual([call.input.message, call.input.transcript, call.o.onBehalfOf], ['name my parrot', 'visitor: hi\nai: Ahoy', { type: 'user', id: alice.subject }]);
++ assert.strictEqual((await op('items.add', { collection: 'bad name!', value: 1 })).json().error.code, 'space.invalid_name');
++ assert.strictEqual((await op('nope', {})).json().error.code, 'space.unknown_op');
++ });
++
++ let url = null;
++ await check('publishing deploys the files to a site in the maker\'s Spaces project, served with the app policy and the SDK', async () => {
++ const r = await edit(alice, 'POST', `/spaces/${slug}/publish`);
++ assert.strictEqual(r.status, 200, r.text);
++ url = r.json().url;
++ assert.strictEqual(url, `https://${slug}.openvibe.website`);
++ space = await t.ctx.spaces.getBySlug(slug);
++ assert.match(space.site_id, /^sit_/);
++ const page = await t.get(`${slug}.openvibe.website`, '/');
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<h1>Guests<\/h1>/);
++ const csp = page.headers['content-security-policy'];
++ assert.match(csp, /script-src 'self' 'unsafe-inline' https:\/\/cdnjs\.cloudflare\.com/);
++ assert.match(csp, /connect-src 'self' https:\/\/openvibe\.space/);
++ assert.match(csp, /frame-ancestors 'self' https:\/\/openvibe\.space/);
++ const sdk = await t.get(`${slug}.openvibe.website`, '/_openvibe/sdk.js');
++ assert.strictEqual(sdk.status, 200);
++ assert.match(sdk.headers['content-type'], /javascript/);
++ assert.ok(sdk.text.includes(`"id":"${space.id}"`) && sdk.text.includes('"mode":"live"') && sdk.text.includes('"api":"https://openvibe.space"'));
++ const project = await t.ctx.store.db.prepare('SELECT p.name FROM space_app_projects a JOIN space_projects p ON p.id = a.project_id WHERE a.owner_subject = ?').get(alice.subject);
++ assert.strictEqual(project.name, 'Spaces');
++ // A second publish updates the same site.
++ await edit(alice, 'PUT', `/spaces/${slug}/files`, { files: [{ path: 'index.html', content: '<!doctype html><html><body><h1>Guests v2</h1><script src="/_openvibe/sdk.js"></script></body></html>' }] });
++ await edit(alice, 'POST', `/spaces/${slug}/publish`);
++ assert.match((await t.get(`${slug}.openvibe.website`, '/')).text, /Guests v2/);
++ assert.strictEqual((await t.ctx.spaces.getBySlug(slug)).site_id, space.site_id);
++ });
++
++ await check('the gallery and the sitemap list a published public space; its page embeds it', async () => {
++ assert.match((await t.api('GET', '/spaces')).text, new RegExp(`href="/spaces/${slug}"`));
++ assert.match((await t.api('GET', '/sitemap.xml')).text, new RegExp(`/spaces/${slug}</loc>`));
++ const p = await t.api('GET', `/spaces/${slug}`, { session: bob });
++ assert.strictEqual(p.status, 200);
++ assert.match(p.text, new RegExp(`<iframe src="${url}"`));
++ assert.match(p.headers['content-security-policy'], /frame-src [^;]*https:\/\/\*\.openvibe\.website/);
++ });
++
++ // A visitor signs in: the connect page, a code back to the space, traded from the space's origin.
++ const verifier = b64url(crypto.randomBytes(32));
++ const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
++ let bobToken = null;
++ await check('a visitor approves the space on the connect page and comes back with a one-time code', async () => {
++ const q = `challenge=${challenge}&state=st4te-abc&return=${encodeURIComponent('/game?x=1')}`;
++ assert.strictEqual((await t.api('GET', `/spaces/${slug}/connect?${q}`)).status, 303, 'signed out: to sign-in first');
++ const page = await t.api('GET', `/spaces/${slug}/connect?${q}`, { session: bob });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /Ask OpenVibe AI for you/);
++ assert.strictEqual(page.headers['x-frame-options'], 'DENY');
++ const r = await form(bob, `/spaces/${slug}/connect`, { challenge, state: 'st4te-abc', return: '/game?x=1', decision: 'allow' });
++ assert.strictEqual(r.status, 303);
++ const m = new RegExp(`^${url}/game\\?x=1#ov_code=([A-Za-z0-9_-]+)&ov_state=st4te-abc$`).exec(r.headers.location);
++ assert.ok(m, r.headers.location);
++ const code = m[1];
++ const tok = (o) => t.api('POST', `/api/v1/apps/${space.id}/token`, { json: { code, code_verifier: verifier }, headers: o ? { origin: o } : {} });
++ assert.strictEqual((await tok('https://evil.example')).status, 403, 'another origin cannot trade it');
++ assert.strictEqual((await tok(null)).status, 403, 'nor a request with no origin');
++ const ok = await tok(url);
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(ok.headers['access-control-allow-origin'], url);
++ bobToken = ok.json().token;
++ assert.deepStrictEqual([ok.json().user.username, ok.json().capabilities], ['bob', ['profile', 'data', 'kv', 'ai']]);
++ assert.strictEqual((await tok(url)).json().error.code, 'space.invalid_grant', 'a code works once');
++ // Already approved: the next sign-in skips the page.
++ const again = await t.api('GET', `/spaces/${slug}/connect?${q}`, { session: bob });
++ assert.strictEqual(again.status, 303);
++ assert.match(again.headers.location, /#ov_code=/);
++ // The return path stays on the space's own host.
++ const sneaky = await form(bob, `/spaces/${slug}/connect`, { challenge, state: 'st4te-abc', return: '//evil.example/x', decision: 'allow' });
++ assert.ok(sneaky.headers.location.startsWith(`${url}/#ov_code=`), sneaky.headers.location);
++ const denied = await form(carol, `/spaces/${slug}/connect`, { challenge, state: 'st4te-abc', return: '/', decision: 'deny' });
++ assert.strictEqual(denied.headers.location, `${url}/`);
++ });
++
++ await check('the space\'s API: lists for everyone, writes and storage for the signed-in visitor, authorship enforced', async () => {
++ const run = (o, args, token = bobToken, origin = url) => t.api('POST', `/api/v1/apps/${space.id}/run`, { json: { op: o, args }, headers: { origin, ...(token ? { authorization: `Bearer ${token}` } : {}) } });
++ const added = await run('items.add', { collection: 'notes', value: { text: 'hi from bob' } });
++ assert.strictEqual(added.status, 200, added.text);
++ assert.strictEqual(added.headers['access-control-allow-origin'], url);
++ const pub = await t.api('GET', `/api/v1/apps/${space.id}/items/notes`, { headers: { origin: url } });
++ assert.deepStrictEqual(pub.json().items.map((i) => [i.author.username, i.value.text]), [['bob', 'hi from bob'], ['alice', 'first!']]);
++ assert.strictEqual((await run('items.add', { collection: 'notes', value: 1 }, null)).json().error.code, 'auth.required');
++ assert.strictEqual((await run('items.list', { collection: 'notes' }, null, 'https://evil.example')).status, 403, 'only the space\'s own page');
++ const alicesNote = pub.json().items.find((i) => i.author.username === 'alice');
++ assert.strictEqual((await run('items.remove', { collection: 'notes', id: alicesNote.id })).json().error.code, 'space.not_author');
++ await run('items.set', { collection: 'votes', value: { option: 1 } });
++ await run('items.set', { collection: 'votes', value: { option: 2 } });
++ const votes = (await run('items.list', { collection: 'votes' })).json().result;
++ assert.deepStrictEqual(votes.map((v) => [v.value.option, v.mine]), [[2, true]], 'set keeps one item per person');
++ await run('kv.set', { key: 'theme', value: 'mine' });
++ assert.strictEqual((await run('kv.get', { key: 'theme' })).json().result, 'mine');
++ const alicesTheme = await edit(alice, 'POST', `/spaces/${slug}/preview`, { op: 'kv.get', args: { key: 'theme' } });
++ assert.deepStrictEqual(alicesTheme.json().result, { dark: true }, 'each person\'s storage is their own');
++ assert.strictEqual((await run('ai.ask', { input: 'name my parrot' })).json().result.text, 'Arr, matey: call it Polly.');
++ assert.deepStrictEqual(ai.calls.filter((c) => c.workflow === 'space.app.ask').pop().o.onBehalfOf, { type: 'user', id: bob.subject }, 'on behalf of the visitor');
++ assert.strictEqual((await run('me', {}, 'x'.repeat(43))).json().error.code, 'auth.required', 'a made-up token is no sign-in');
++ const out = await t.api('DELETE', `/api/v1/apps/${space.id}/token`, { headers: { origin: url, authorization: `Bearer ${bobToken}` } });
++ assert.strictEqual(out.status, 200);
++ assert.strictEqual((await run('kv.get', { key: 'theme' })).status, 401, 'signed out');
++ });
++
++ await check('a capability the space did not ask for is refused', async () => {
++ await edit(alice, 'PUT', `/spaces/${slug}/settings`, { capabilities: ['profile', 'data'] });
++ const v2 = b64url(crypto.randomBytes(32));
++ const c2 = b64url(crypto.createHash('sha256').update(v2).digest());
++ const r = await form(carol, `/spaces/${slug}/connect`, { challenge: c2, state: 'state-two', return: '/', decision: 'allow' });
++ const code = /#ov_code=([^&]+)/.exec(r.headers.location)[1];
++ const tok = (await t.api('POST', `/api/v1/apps/${space.id}/token`, { json: { code, code_verifier: v2 }, headers: { origin: url } })).json().token;
++ const asked = await t.api('POST', `/api/v1/apps/${space.id}/run`, { json: { op: 'ai.ask', args: { input: 'hi' } }, headers: { origin: url, authorization: `Bearer ${tok}` } });
++ assert.strictEqual(asked.json().error.code, 'space.capability');
++ });
++
++ await check('the AI builder: a sentence becomes a space, polled until the run finishes', async () => {
++ const r = await form(bob, '/spaces/new', { prompt: 'A pizza topping poll with live results' });
++ assert.strictEqual(r.status, 303, r.text);
++ const s = await t.ctx.spaces.getBySlug(/^\/spaces\/([^/]+)\/edit$/.exec(r.headers.location)[1]);
++ assert.ok(s.building_run_id);
++ const call = ai.calls.find((c) => c.workflow === 'space.app.build' && c.input.prompt.startsWith('A pizza'));
++ assert.deepStrictEqual([call.o.onBehalfOf, call.o.wait], [{ type: 'user', id: bob.subject }, 0]);
++ const poll = () => edit(bob, 'GET', `/spaces/${s.slug}/ai`);
++ assert.strictEqual((await poll()).json().status, 'running');
++ ai.runs.set(s.building_run_id, { id: s.building_run_id, status: 'succeeded', output: { name: 'Pizza poll', description: 'Vote for a topping.', instructions: '', capabilities: ['profile', 'data'], files: [{ path: 'index.html', content: '<!doctype html><html><body><h1>Pizza</h1></body></html>' }] } });
++ const done1 = (await poll()).json();
++ assert.deepStrictEqual([done1.status, done1.space.name, done1.files.map((f) => f.path)], ['done', 'Pizza poll', ['index.html']]);
++ assert.strictEqual((await poll()).json().status, 'idle', 'applied once');
++ // A change to an existing space sends its current files.
++ await edit(bob, 'POST', `/spaces/${s.slug}/ai`, { prompt: 'make it blue' });
++ const change = ai.calls.filter((c) => c.workflow === 'space.app.build').pop();
++ assert.deepStrictEqual([change.input.name, change.input.current.map((f) => f.path)], ['Pizza poll', ['index.html']]);
++ const s2 = await t.ctx.spaces.get(s.id);
++ ai.runs.set(s2.building_run_id, { id: s2.building_run_id, status: 'failed' });
++ assert.strictEqual((await poll()).json().status, 'failed');
++ assert.strictEqual((await t.ctx.spaces.get(s.id)).name, 'Pizza poll', 'a failed change leaves the space as it was');
++ });
++
++ await check('a fork is the forker\'s own draft; the original counts it', async () => {
++ const r = await form(carol, `/spaces/${slug}/fork`, {});
++ assert.strictEqual(r.status, 303);
++ const copy = await t.ctx.spaces.getBySlug(/^\/spaces\/([^/]+)\/edit$/.exec(r.headers.location)[1]);
++ assert.deepStrictEqual([copy.owner_username, copy.forked_from, copy.site_id], ['carol', space.id, null]);
++ assert.strictEqual((await t.ctx.spaces.get(space.id)).forks, 1);
++ assert.strictEqual((await edit(carol, 'PUT', `/spaces/${slug}/files`, { files: [{ path: 'index.html', content: '<p>mine now</p>' }] })).status, 403, 'not the original');
++ });
++
++ await check('unpublishing stops the address and the API; deleting removes the space', async () => {
++ await edit(alice, 'POST', `/spaces/${slug}/unpublish`);
++ assert.notStrictEqual((await t.get(`${slug}.openvibe.website`, '/')).status, 200);
++ assert.strictEqual((await t.api('GET', `/api/v1/apps/${space.id}/items/notes`, { headers: { origin: url } })).status, 404);
++ assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: bob })).status, 404, 'a draft again');
++ await edit(alice, 'POST', `/spaces/${slug}/delete`);
++ assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: alice })).status, 404);
++ });
++
++ await t.close();
++
++ // Before the public launch: everyone makes and previews; only staff publish.
++ const t2 = await boot({ env: { SPACE_PUBLIC_HOSTING: 'off' }, spacesAiClient: fakeAi().client });
++ const dana = t2.user('dana'), root = t2.user('root', { role: 'admin' });
++ const csrf2 = (u) => csrfToken(t2.config, { subject: u.subject });
++ await check('before the public launch a person makes and previews a space; publishing waits (staff may)', async () => {
++ const make = async (u) => {
++ const r = await t2.api('POST', '/spaces/new', { session: u, form: { csrf: csrf2(u), template: 'poll' }, headers: { origin: ORIGIN } });
++ return /^\/spaces\/([^/]+)\/edit$/.exec(r.headers.location)[1];
++ };
++ const s = await make(dana);
++ const hdr = (u) => ({ origin: ORIGIN, 'x-csrf-token': csrf2(u) });
++ const pv = await t2.api('POST', `/spaces/${s}/preview`, { session: dana, json: { op: 'items.set', args: { collection: 'votes', value: { option: 0 } } }, headers: hdr(dana) });
++ assert.strictEqual(pv.status, 200, 'the preview works before launch');
++ const pub = await t2.api('POST', `/spaces/${s}/publish`, { session: dana, headers: hdr(dana) });
++ assert.deepStrictEqual([pub.status, pub.json().error.code], [403, 'space.not_launched']);
++ const staffSpace = await make(root);
++ assert.strictEqual((await t2.api('POST', `/spaces/${staffSpace}/publish`, { session: root, headers: hdr(root) })).status, 200, 'staff publish');
++ });
++ await t2.close();
++
++ await check('the SDK (live and preview) and every template\'s scripts compile', async () => {
++ const { sdkSource } = require('../server/spaces/sdk');
++ for (const mode of ['live', 'preview']) new Function(sdkSource({ mode, space: { id: 'spc_X', slug: 'x-1234', name: 'X </script>' }, api: 'https://openvibe.space', avatar: 'https://openvibe.network/avatar/{username}' }));
++ assert.ok(!sdkSource({ space: { id: 'spc_X', slug: 'x', name: '</script><b>' } }).includes('</script>'), 'a name cannot close the script');
++ const templates = require('../server/spaces/templates');
++ for (const tpl of templates.list()) {
++ const html = templates.files(tpl.id)[0].content;
++ assert.match(html, /<script src="\/_openvibe\/sdk\.js"><\/script>/, tpl.id);
++ for (const m of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) new Function(m[1]);
++ }
++ });
++
++ const { cleanReturn } = require('../server/spaces/pages');
++ await check('a return path stays a path on the space\'s own host', async () => {
++ assert.deepStrictEqual(['/play?x=1', '//evil.example', 'https://evil.example', '/a\\b', '/ok#frag', '/x y', ''].map(cleanReturn), ['/play?x=1', '/', '/', '/', '/ok', '/', '/']);
++ });
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/stageb/boot.js b/test/stageb/boot.js
iindex e29f76e..4b4b691 100644
---- a/test/stageb/boot.js
++++ b/test/stageb/boot.js
@@@ -67,7 +67,7 @@ async function boot(opts = {}) {
Object.assign(process.env, env);
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
const { createApp } = require('../../server/app');
-- const app = await createApp({ db, hostingConfig: config, now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow });
++ const app = await createApp({ db, hostingConfig: config, now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow, spacesAiClient: opts.spacesAiClient });
await app.locals.auth.ensureKey();
built = { app, ctx: app.locals.hosting };
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });