openvibers/openvibe.space Commits
Spaces: server functions (functions/<name>.js) run in a QuickJS sandbox at /api/<name>, from OV.fn and in the editor's preview, with a Trivia starter whose answers stay on the server (#62)
Changes
@@@ -9,11 +9,12 @@ Space owns static hosting. The Stage B engine moved here from OpenVibe.Host as cPublic hosting remains off by default (`SPACE_PUBLIC_HOSTING=off`). Only OpenVibe staff can create projects. Abuse handling and a designated DMCA agent precede public launch.**Spaces** (plan T20 step 2; `server/spaces/`, `docs/spaces.md`) are small web apps people make here: described to the AI--builder (OpenVibe.AI `space.app.build`), started from a template (guestbook, AI buddy, poll, reaction game, private--to-dos, blank) or forked, then edited in a browser editor with a live preview. A space uses the visitor's OpenVibe--account through the space SDK (`/_openvibe/sdk.js`): sign-in that the visitor approves once, shared lists--(`OV.items`), private storage (`OV.kv`) and the space's own AI (`OV.ai.ask`, OpenVibe.AI `space.app.ask` on behalf of--the visitor). Making, editing, previewing and the AI builder work for everyone signed in. Publishing deploys a space to++builder (OpenVibe.AI `space.app.build`), started from a template (guestbook, AI buddy, poll, reaction game, trivia,++private to-dos, blank) or forked, then edited in a browser editor with a live preview. A space uses the visitor's++OpenVibe account through the space SDK (`/_openvibe/sdk.js`): sign-in that the visitor approves once, shared lists++(`OV.items`), private storage (`OV.kv`), the space's own AI (`OV.ai.ask`, OpenVibe.AI `space.app.ask` on behalf of++the visitor) and its own server code (`functions/<name>.js`, run in a QuickJS WebAssembly sandbox at `/api/<name>`++and from `OV.fn`). Making, editing, previewing and the AI builder work for everyone signed in. Publishing deploys a space toa site of its maker's Spaces project at `<slug>.openvibe.website`, so it follows the same launch switch: staff until`SPACE_PUBLIC_HOSTING=on`.@@@ -18,6 +18,23 @@ server {ssl_protocols TLSv1.2 TLSv1.3;client_max_body_size 1k;add_header Strict-Transport-Security "max-age=31536000" always;++ # A space's functions (/api/<name>, server/spaces/functions.js) also take POST, a small body and the page's++ # Bearer token (the space SDK's sign-in); cookies never pass, as everywhere on a tenant host.++ location ~ ^/api/[a-z0-9][a-z0-9-]{0,39}/?$ {++ limit_except GET HEAD POST { deny all; }++ client_max_body_size 64k;++ limit_req zone=ovspace_sites burst=200 nodelay;++ proxy_pass http://127.0.0.1:4940;++ proxy_http_version 1.1;++ proxy_set_header Connection "";++ proxy_set_header Host $host;++ proxy_set_header Cookie "";++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_hide_header Set-Cookie;++ proxy_read_timeout 60s;++ }location / {limit_except GET HEAD { deny all; }limit_req zone=ovspace_sites burst=200 nodelay;@@@ -51,11 +51,50 @@ Load it before your own script:| `OV.items.remove(collection, id)` | Removes the visitor's own item (the space's maker may remove any). || `OV.kv.get(key)`, `OV.kv.set(key, value)`, `OV.kv.remove(key)` | The visitor's private storage in this space (up to 16 KB a value, 200 keys). || `OV.ai.ask(text \| { messages })` | An answer from OpenVibe AI that follows the space's instructions, on behalf of the visitor. |++| `OV.fn(name[, body][, { method, query }])` | Runs the space's `functions/<name>.js` on the server (POST when there is a body) and resolves with what it answered: JSON parsed, anything else as text. |Every call returns a Promise. A failure is an `Error` with `.code`: `auth.required` (call `OV.signIn()`), `quota`(try later), `space.capability` (the space did not ask for it), and so on. Collection names and keys are 1-64characters of letters, digits, `.`, `-` and `_`.++## Functions (server code)++++A space may keep code that runs on the server instead of in the visitor's browser: answers nobody should see, a++counter every visitor shares, a check before something is saved, a page put together per request. Each file++`functions/<name>.js` (a name of 1-40 characters: `a-z`, `0-9`, `-`) defines one handler:++++```js++// functions/hello.js++async function handle(request, ov) {++ const visits = ((await ov.store.get('visits')) || 0) + 1;++ await ov.store.set('visits', visits);++ return { hello: ov.user ? ov.user.display_name : 'stranger', visits };++}++```++++- **Where it runs:** on a published space at `https://<slug>.openvibe.website/api/<name>` (GET, HEAD and POST; it is++ the published copy that runs, so a saved change waits for the next publish). The page calls it with++ `OV.fn('hello')`, and a link or form may point at `/api/hello` directly. In the editor, the preview runs the copy in++ the editor (unsaved changes too) as the maker, and each call's status and console lines show in **Function log**++ under the preview. The files under `functions/` are never served as files, so their source stays private.++- **`request`:** `{ method, path, query, headers, body }`. `headers` carries only `content-type`, `accept`,++ `accept-language` and `user-agent`; `body` is parsed JSON, a form as an object, or text (64 KB at most). The++ visitor's address and cookies never reach a function.++- **`ov`:** `ov.user` (the visitor signed in through the SDK, or `null`), `ov.space` (`{ id, slug, name }`),++ `ov.items.list/add/set/remove` (the shared lists: an add or set is the visitor's when they approved "data", otherwise++ the space's own; a function may remove any item), `ov.store.get/set/remove` (the space's own storage on the server,++ which no visitor reads directly), `ov.ai.ask` (for a signed-in visitor who approved "ai") and++ `console.log/warn/error`.++- **What it returns** becomes the answer: a string (HTML when it starts with `<`, text otherwise), any other value as++ JSON, nothing as 204, or `{ status, headers, body }` (only `content-type` and `cache-control` headers are kept). An++ answer to a signed-in visitor or to a POST is never cached.++- **The sandbox:** each call runs in a fresh QuickJS runtime compiled to WebAssembly, on a worker thread: no network,++ no files, no timers, no Node. It has 32 MB of memory, 1 s of its own computing and 5 s in all (time spent waiting on++ `ov` is not computing), and at most 100 `ov` calls. A call that overstays answers 504; a thrown error answers 500++ with its message.++++Starting from the **Trivia** template gives a working example: its answers live in `functions/check.js`.++## Sign-in, step by step1. `OV.signIn()` makes a PKCE verifier and state, keeps them in `sessionStorage`, and goes to@@@ -76,6 +115,8 @@ These are per person or per space, set in `server/hosting-config.js`:- 25 spaces per person, 20 files and 1 MB per space.- 20,000 list items per space, 30 writes a minute per person and space.- 20 AI builds per person a day, 6 AI questions a minute per person and space, 500 AI answers per space a day.++- Functions: 240 calls a minute per space and 60 per caller (a signed-in visitor, or an address), two worker threads++ (`SPACE_FN_WORKERS`), and 300 writes a minute for the space's own writes (ten times a person's).- Each OpenVibe AI run also counts against the person's own daily allowance in OpenVibe.AI.## Code@@@ -87,7 +128,8 @@ These are per person or per space, set in `server/hosting-config.js`:| `server/spaces/api.js` | The API a space's page calls. || `server/spaces/pages.js` and `render.js` | The pages, the editor's calls and the connect flow. || `server/spaces/sdk.js` | The SDK. |++| `server/spaces/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. || `public/js/space-editor.js`, `public/css/spaces.css` | The editor and the styles. |--| `test/spaces.test.js` | The tests. |++| `test/spaces.test.js`, `test/space-functions.test.js` | The tests. |@@@ -18,7 +18,8 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "quickjs-emscripten": "0.32.0"},"devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -35,6 +36,48 @@"devOptional": true,"license": "Apache-2.0"},++ "node_modules/@jitl/quickjs-ffi-types": {++ "version": "0.32.0",++ "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",++ "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",++ "license": "MIT"++ },++ "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {++ "version": "0.32.0",++ "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz",++ "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==",++ "license": "MIT",++ "dependencies": {++ "@jitl/quickjs-ffi-types": "0.32.0"++ }++ },++ "node_modules/@jitl/quickjs-wasmfile-debug-sync": {++ "version": "0.32.0",++ "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz",++ "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==",++ "license": "MIT",++ "dependencies": {++ "@jitl/quickjs-ffi-types": "0.32.0"++ }++ },++ "node_modules/@jitl/quickjs-wasmfile-release-asyncify": {++ "version": "0.32.0",++ "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz",++ "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==",++ "license": "MIT",++ "dependencies": {++ "@jitl/quickjs-ffi-types": "0.32.0"++ }++ },++ "node_modules/@jitl/quickjs-wasmfile-release-sync": {++ "version": "0.32.0",++ "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz",++ "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==",++ "license": "MIT",++ "dependencies": {++ "@jitl/quickjs-ffi-types": "0.32.0"++ }++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -1032,6 +1075,31 @@"url": "https://github.com/sponsors/ljharb"}},++ "node_modules/quickjs-emscripten": {++ "version": "0.32.0",++ "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz",++ "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==",++ "license": "MIT",++ "dependencies": {++ "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0",++ "@jitl/quickjs-wasmfile-debug-sync": "0.32.0",++ "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0",++ "@jitl/quickjs-wasmfile-release-sync": "0.32.0",++ "quickjs-emscripten-core": "0.32.0"++ },++ "engines": {++ "node": ">=16.0.0"++ }++ },++ "node_modules/quickjs-emscripten-core": {++ "version": "0.32.0",++ "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",++ "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",++ "license": "MIT",++ "dependencies": {++ "@jitl/quickjs-ffi-types": "0.32.0"++ }++ },"node_modules/range-parser": {"version": "1.2.1","resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",@@@ -30,7 +30,8 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "quickjs-emscripten": "0.32.0"},"engines": {"node": ">=22.0.0"@@@ -115,7 +115,14 @@ body[data-page="spaces"] .page:has(.sp-editor) { padding-bottom: 0; }.sp-ed-code .cm-s-default .cm-meta, .sp-ed-code .cm-s-default .cm-bracket, .sp-ed-code .cm-s-default .cm-operator { color: #a5b4c8; }.sp-ed-code .cm-s-default .cm-error { color: #f87171; }.sp-ed-code .CodeMirror-matchingbracket { color: #4ade80 !important; outline: 1px solid color-mix(in srgb, #4ade80 50%, transparent); }--.sp-ed-preview { display: grid; grid-template-rows: auto 1fr; min-height: 0; }++.sp-ed-preview { display: grid; grid-template-rows: auto 1fr auto; min-height: 0; }++/* The preview's server function calls (OV.fn): each call's status and its console lines. */++.sp-ed-fnlog { border-top: 1px solid var(--border, #2c3d5c); font-size: 0.84rem; max-height: 34vh; overflow: auto; }++.sp-ed-fnlog summary { cursor: pointer; padding: 6px 10px; }++.sp-ed-fnlog ol { list-style: none; margin: 0; padding: 0 10px 8px; display: grid; gap: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }++.sp-ed-fnlog li { white-space: pre-wrap; overflow-wrap: anywhere; }++.sp-ed-fnlog .is-call { color: var(--text-secondary, #9fb0c8); margin-top: 4px; }++.sp-ed-fnlog .is-warn { color: #fbbf24; } .sp-ed-fnlog .is-error { color: #f87171; }.sp-ed-preview-bar { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid var(--border, #2c3d5c); font-size: 0.88rem; }.sp-ed-preview iframe { width: 100%; height: 100%; border: 0; background: #fff; }.sp-ed-building { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; background: color-mix(in srgb, var(--bg-primary, #0b1220) 88%, transparent); backdrop-filter: blur(4px); z-index: 5; padding: 24px; }@@@ -36,9 +36,11 @@function fileOf(path) { return files.find(function (f) { return f.path === path; }); }function stash() { var f = fileOf(current); if (f) f.content = getText(); }++ // Tabs: index.html, then the other top-level files, then folders (functions/…), each by name.++ function tabRank(p) { return p === 'index.html' ? 0 : p.indexOf('/') < 0 ? 1 : 2; }function renderTabs() {tabs.replaceChildren();-- files.forEach(function (f) {++ files.slice().sort(function (a, b) { return tabRank(a.path) - tabRank(b.path) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0); }).forEach(function (f) {var b = document.createElement('button');b.type = 'button'; b.className = 'sp-ed-tab'; b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', f.path === current ? 'true' : 'false');b.append(f.path);@@@ -87,10 +89,45 @@var d = ev.data;if (ev.source !== frame.contentWindow || !d || d.ovSpacePreview !== 1 || typeof d.op !== 'string') return;var reply = function (msg) { try { frame.contentWindow.postMessage(Object.assign({ ovSpacePreview: 1, id: d.id }, msg), '*'); } catch (e) { /* the frame reloaded */ } };-- call('POST', '/preview', { op: d.op, args: d.args || {} })-- .then(function (j) { reply({ ok: true, result: j.result }); }, function (e) { reply({ ok: false, code: e.code || 'error', message: e.message }); });++ var args = d.args || {};++ if (d.op === 'fn.call' && typeof args.name === 'string') {++ // The function as it is in the editor now, saved or not.++ stash();++ var src = fileOf('functions/' + args.name + '.js');++ if (src) args = Object.assign({}, args, { source: src.content });++ }++ var started = Date.now();++ call('POST', '/preview', { op: d.op, args: args })++ .then(function (j) {++ if (d.op === 'fn.call') fnLog(args.name, j.result, Date.now() - started);++ reply({ ok: true, result: j.result });++ }, function (e) {++ if (d.op === 'fn.call') fnLog(args.name, { status: e.status || 0, body: e.message, logs: [] }, Date.now() - started);++ reply({ ok: false, code: e.code || 'error', message: e.message });++ });});++ // ── The function log: each OV.fn call in the preview, newest first, with what it logged ──++ var fnLogBox = $('ed-fnlog'), fnLogList = $('ed-fnlog-list');++ function fnLog(name, r, ms) {++ if (!fnLogBox || !r) return;++ var lines = [];++ var head = document.createElement('li');++ head.className = 'is-call' + (r.status >= 400 || !r.status ? ' is-error' : '');++ head.textContent = 'functions/' + name + '.js → ' + (r.status || 'error') + ' · ' + ms + ' ms' + (r.status >= 400 || !r.status ? ' · ' + String(r.body || '').slice(0, 300) : '');++ lines.push(head);++ (r.logs || []).forEach(function (l) {++ var li = document.createElement('li');++ li.className = l[0] === 'warn' ? 'is-warn' : l[0] === 'error' ? 'is-error' : '';++ li.textContent = l[1];++ lines.push(li);++ });++ for (var i = lines.length - 1; i >= 0; i--) fnLogList.insertBefore(lines[i], fnLogList.firstChild);++ while (fnLogList.children.length > 200) fnLogList.removeChild(fnLogList.lastChild);++ $('ed-fnlog-last').textContent = '· ' + name + ' → ' + (r.status || 'error');++ fnLogBox.hidden = false;++ }++// ── Saving ──function save() {stash();@@@ -166,6 +166,10 @@ function load(env = process.env) {aiBuildsPerDay: int(env.SPACE_AI_BUILDS_PER_DAY, 20),aiAsksPerMinute: int(env.SPACE_AI_ASKS_PER_MINUTE, 6),aiAsksPerSpacePerDay: int(env.SPACE_AI_ASKS_PER_SPACE_PER_DAY, 500),++ // Space functions (server/spaces/functions.js): worker threads, and calls per minute per space and per caller.++ fnWorkers: int(env.SPACE_FN_WORKERS, 2),++ fnCallsPerMinute: int(env.SPACE_FN_CALLS_PER_MINUTE, 240),++ fnCallsPerCallerPerMinute: int(env.SPACE_FN_CALLS_PER_CALLER_PER_MINUTE, 60),ai: {enabled: String(env.SPACE_AI || 'on').trim().toLowerCase() !== 'off',url: trim(env.OV_AI_INTERNAL_URL || env.OV_AI_URL || 'http://127.0.0.1:4700'),@@@ -68,6 +68,7 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fctx.spaces = require('./spaces/spaces').createSpaces({ store, config, projects, sites, deploys, takedowns, log });ctx.spacesAi = require('./spaces/ai').createSpacesAi({ config, client: spacesAiClient, log });ctx.spaceData = require('./spaces/data').createSpaceData({ store, config, spaces: ctx.spaces, ai: ctx.spacesAi });++ ctx.spaceFunctions = require('./spaces/functions').createSpaceFunctions({ data: ctx.spaceData, workers: config.spaces.fnWorkers, log });{const { sdkSource } = require('./spaces/sdk');const bySite = new Map();@@@ -83,6 +84,14 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f},forget(siteId) { bySite.delete(siteId); },sdk: (space) => sdkSource({ mode: 'live', space, api: new URL(config.baseUrl).origin, avatar: `${config.networkUrl}/avatar/{username}` }),++ // functions/<name>.js of a published space, for one request to https://<slug>.<sitesDomain>/api/<name>.++ async runFunction(space, name, code, request, { bearer = null, caller = '' } = {}) {++ const visitor = bearer ? await ctx.spaceData.verify(space, bearer) : null;++ return await ctx.spaceFunctions.call(space, name, code, request, {++ visitor, caller: visitor ? visitor.person.subject : `ip:${caller}`,++ perMinute: config.spaces.fnCallsPerMinute, perCallerPerMinute: config.spaces.fnCallsPerCallerPerMinute,++ });++ },};}if (app.locals.metrics && typeof app.locals.metrics.gauge === 'function') {@@@ -129,6 +129,22 @@ function baseHeaders(res, { sandbox = false } = {}) {if (sandbox) res.setHeader('X-Robots-Tag', 'noindex, nofollow');}++const FN_PATH = /^\/api\/([a-z0-9][a-z0-9-]{0,39})\/?$/;++const FN_MAX_SOURCE = 64 * 1024;++const FN_MAX_BODY = 64 * 1024;++++/** A request body up to `max` bytes, or null when it is longer (the rest is drained, not kept). */++function readBody(req, max) {++ return new Promise((resolve, reject) => {++ const chunks = [];++ let size = 0;++ let over = false;++ req.on('data', (chunk) => { size += chunk.length; if (size > max) over = true; else chunks.push(chunk); });++ req.on('end', () => resolve(over ? null : Buffer.concat(chunks)));++ req.on('error', reject);++ });++}++function plain(res, status, text, extra = {}) {res.statusCode = status;res.setHeader('Content-Type', 'text/plain; charset=utf-8');@@@ -249,6 +265,53 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfigstream.pipe(res);}++ /**++ * GET/HEAD/POST /api/<name> on a published space: functions/<name>.js of the deploy the site serves, run in the++ * functions sandbox (server/spaces/functions.js) for this request. The page's Bearer token (the space SDK sends it)++ * signs the visitor in; cookies, the address and other headers never reach the function. An answer to a signed-in++ * visitor or to a POST is never cached; otherwise the function may choose its Cache-Control.++ */++ async function spaceFunction(req, res, site, space, name, rawPath, search) {++ const row = site.active_deploy_id ? await q.file.get(site.active_deploy_id, `functions/${name}.js`) : null;++ if (!row || row.project_id !== site.project_id) {++ if (req.method === 'POST') return plain(res, 404, `This space has no function named ${name}.`);++ return await notFound(req, res, site, 'missing');++ }++ if (row.size > FN_MAX_SOURCE) return plain(res, 500, 'This function is too large to run.');++ let code;++ try { code = await fs.promises.readFile(await blobs.ensure(row.project_id, row.sha256), 'utf8'); } catch (err) {++ log.error('[Space] function unreadable:', row.project_id, row.sha256, err.code || err.message);++ return plain(res, 500, 'This function could not be read. Try again later.');++ }++ let body = null;++ if (req.method === 'POST') {++ const raw = await readBody(req, FN_MAX_BODY);++ if (raw === null) return plain(res, 413, `A request to a space function is at most ${FN_MAX_BODY / 1024} KB.`);++ const type = String(req.headers['content-type'] || '').toLowerCase();++ const text = raw.toString('utf8');++ if (/^application\/json/.test(type)) { try { body = JSON.parse(text || 'null'); } catch { return plain(res, 400, 'The request body is not valid JSON.'); } }++ else if (/^application\/x-www-form-urlencoded/.test(type)) body = Object.fromEntries(new URLSearchParams(text));++ else body = text;++ }++ const headers = {};++ for (const h of ['content-type', 'accept', 'accept-language', 'user-agent']) if (typeof req.headers[h] === 'string') headers[h] = req.headers[h].slice(0, 300);++ const auth = /^Bearer\s+([A-Za-z0-9_-]{20,100})$/.exec(String(req.headers.authorization || ''));++ const request = { method: req.method === 'HEAD' ? 'GET' : req.method, path: rawPath, query: Object.fromEntries(new URLSearchParams(search)), headers, body };++ let out;++ try {++ out = await apps().runFunction(space, name, code, request, { bearer: auth ? auth[1] : null, caller: req.ip || (req.socket && req.socket.remoteAddress) || '' });++ } catch (err) {++ if (err && Number.isInteger(err.status) && err.status < 500) return plain(res, err.status, err.message);++ log.error('[Space] function failed:', space.id, name, err && err.stack ? err.stack : err);++ return plain(res, 500, 'This function could not run. Try again later.');++ }++ res.statusCode = out.status;++ res.setHeader('Content-Type', out.headers['content-type'] || 'text/plain; charset=utf-8');++ res.setHeader('Cache-Control', auth || req.method === 'POST' || !out.headers['cache-control'] ? 'private, no-store' : out.headers['cache-control']);++ res.setHeader('Content-Length', Buffer.byteLength(out.body));++ return res.end(req.method === 'HEAD' ? undefined : out.body);++ }++/** The absolute origin for the host this request arrived on (tenant hosts are https in production). */function originOf(req) {const host = normaliseHost(req.headers.host);@@@ -344,7 +407,8 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig// A published space: the app policy, and the space SDK at /_openvibe/sdk.js (never one of its files).const space = apps && apps() ? await apps().forSite(site.id) : null;if (space) res.setHeader('Content-Security-Policy', appCsp(config));-- if (req.method !== 'GET' && req.method !== 'HEAD') {++ // A space's function (/api/<name>) also answers POST; everything else here is static.++ if (req.method !== 'GET' && req.method !== 'HEAD' && !(space && req.method === 'POST' && FN_PATH.test(String(req.url || '').split('?')[0]))) {return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });}// Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.@@@ -370,6 +434,10 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfigres.setHeader('Cache-Control', 'no-cache');return res.end(req.method === 'HEAD' ? undefined : apps().sdk(space));}++ if (space) {++ const fn = FN_PATH.exec(rawPath);++ if (fn) return await spaceFunction(req, res, site, space, fn[1], rawPath, search);++ }// A configured redirect: an exact path to a local path (validated on write, never an open redirect).// It does not need an active deploy, and it is checked before anything is read from one.if (cfg && cfg.redirects.length) {@@@ -385,6 +453,8 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfigconst c = candidates(rawPath);if (!c) return await notFound(req, res, site, 'invalid');const deployId = site.active_deploy_id;++ // A space's functions/ are its server code: they run at /api/<name> and are never served as files.++ if (space) c.list = c.list.filter((p) => !/^functions(\/|$)/i.test(p));for (const p of c.list) {const row = await q.file.get(deployId, p);if (row && row.project_id === site.project_id) {@@@ -396,7 +466,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfigreturn send(req, res, site, row, { cache: hashed ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate' });}}-- if (!c.dir && c.dirIndex && await q.file.get(deployId, c.dirIndex)) {++ if (!c.dir && c.dirIndex && !(space && /^functions(\/|$)/i.test(c.dirIndex)) && await q.file.get(deployId, c.dirIndex)) {// /docs → /docs/ so relative links inside docs/index.html resolve; built from the raw path.res.statusCode = 301;res.setHeader('Location', `${rawPath}/${search}`);@@@ -69,6 +69,8 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {if (windows.size > 5000) for (const [k, v] of windows) if (!v.some((x) => t - x < 60000)) windows.delete(k);}++ // The space's own writes (its functions, as `space:<id>`) serve every visitor at once: ten times one person's.++ const writeLimit = (person) => (String(person.subject).startsWith('space:') ? limits.writesPerMinute * 10 : limits.writesPerMinute);const checkName = (kind, v) => {if (typeof v !== 'string' || !NAME_RE.test(v)) throw new ApiError(422, 'space.invalid_name', `${kind} names are 1-64 characters: letters, digits, ".", "-" and "_"`);return v;@@@ -101,7 +103,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {async function addItem(space, person, collection, value) {checkName('collection', collection);const v = jsonOf(value, limits.itemBytes);-- throttle(`w:${space.id}:${person.subject}`, limits.writesPerMinute);++ throttle(`w:${space.id}:${person.subject}`, writeLimit(person));if ((await q.count.get(space.id)).n >= limits.itemsPerSpace) throw new ApiError(429, 'quota', `this space holds ${limits.itemsPerSpace} items at most`);const t = now();const r = await q.add.get(space.id, collection, person.subject, person.username, person.name, v, t, t);@@@ -110,7 +112,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {async function setItem(space, person, collection, value) {checkName('collection', collection);const v = jsonOf(value, limits.itemBytes);-- throttle(`w:${space.id}:${person.subject}`, limits.writesPerMinute);++ throttle(`w:${space.id}:${person.subject}`, writeLimit(person));const t = now();const r = await q.setMine.get(space.id, collection, person.subject, person.username, person.name, v, t, t);return itemOut({ id: r.id, author_subject: person.subject, author_username: person.username, author_name: person.name, value: v, created_at: r.created_at, updated_at: t }, person);@@@ -133,7 +135,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {async function kvSet(space, person, key, value) {checkName('key', key);const v = jsonOf(value, limits.kvBytes);-- throttle(`w:${space.id}:${person.subject}`, limits.writesPerMinute);++ throttle(`w:${space.id}:${person.subject}`, writeLimit(person));if (!(await q.kvGet.get(space.id, person.subject, key)) && (await q.kvCount.get(space.id, person.subject)).n >= limits.kvKeysPerPerson) {throw new ApiError(429, 'quota', `a person keeps ${limits.kvKeysPerPerson} keys at most in one space`);}@@@ -221,7 +223,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {}async function revoke(bearer) { if (typeof bearer === 'string') await q.dropToken.run(sha(bearer)); }-- return { run, listItems, grantedFor, approve, exchange, verify, revoke, CODE_TTL, TOKEN_TTL };++ return { run, listItems, addItem, setItem, removeItem, kvGet, kvSet, kvRemove, grantedFor, approve, exchange, verify, revoke, CODE_TTL, TOKEN_TTL };}module.exports = { createSpaceData, NAME_RE };nnew file mode 100644@@@ -0,0 +1,155 @@++'use strict';++/**++ * One worker thread of the space functions pool (server/spaces/functions.js): it runs a function's code in QuickJS++ * (WebAssembly), one fresh runtime per call, so a slow or hostile function never holds Space's own event loop. The++ * function's `ov` calls come back to the main thread as messages; nothing else leaves the sandbox.++ *++ * main → worker { type: 'run', id, name, code, request, context, limits: { memory, deadlineMs, cpuMs } }++ * worker → main { type: 'op', id, opId, op, args } main → worker { type: 'reply', id, opId, reply }++ * worker → main { type: 'done', id, result: { out } | { failed } | { error }, logs }++ */++const { parentPort } = require('worker_threads');++++let quickjs = null;++const engine = async () => (quickjs ||= await require('quickjs-emscripten').getQuickJS());++const pendingOps = new Map(); // `${id}:${opId}` → (reply) => void++++const PRELUDE = String.raw`++const __logs = [];++const __line = (a) => a.map((x) => typeof x === 'string' ? x : (() => { try { return JSON.stringify(x); } catch { return String(x); } })()).join(' ').slice(0, 500);++const __log = (level) => (...a) => { if (__logs.length < 50) __logs.push([level, __line(a)]); };++globalThis.console = { log: __log('log'), info: __log('log'), warn: __log('warn'), error: __log('error') };++const __call = async (op, args) => {++ const r = JSON.parse(await __host(op, JSON.stringify(args === undefined ? {} : args)));++ if (r.error) { const e = new Error(r.error.message); e.code = r.error.code; throw e; }++ return r.result;++};++const __ctx = JSON.parse(__context);++globalThis.ov = Object.freeze({++ user: __ctx.user ? Object.freeze(__ctx.user) : null,++ space: Object.freeze(__ctx.space),++ items: Object.freeze({++ list: (collection, o = {}) => __call('items.list', { collection, limit: o.limit, before: o.before }),++ add: (collection, value) => __call('items.add', { collection, value }),++ set: (collection, value) => __call('items.set', { collection, value }),++ remove: (collection, id) => __call('items.remove', { collection, id: String(id) }),++ }),++ store: Object.freeze({++ get: (key) => __call('store.get', { key }),++ set: (key, value) => __call('store.set', { key, value }),++ remove: (key) => __call('store.remove', { key }),++ }),++ ai: Object.freeze({ ask: (input) => __call('ai.ask', { input }) }),++});++`;++++function errText(e) {++ if (!e) return 'unknown error';++ if (typeof e === 'string') return e.slice(0, 300);++ return `${e.name ? `${e.name}: ` : ''}${e.message || JSON.stringify(e)}`.slice(0, 300);++}++++async function run({ id, name, code, request, context, limits }) {++ const Q = await engine();++ const runtime = Q.newRuntime();++ runtime.setMemoryLimit(limits.memory);++ runtime.setMaxStackSize(512 * 1024);++ const deadline = Date.now() + limits.deadlineMs;++ // CPU: the time spent inside QuickJS (evaluating, running jobs), apart from waiting on `ov` calls.++ let cpu = 0;++ let since = 0;++ const enter = () => { since = Date.now(); };++ const leave = () => { if (since) cpu += Date.now() - since; since = 0; };++ let stopped = null;++ runtime.setInterruptHandler(() => {++ const t = Date.now();++ if (t > deadline) stopped = 'time';++ else if (since && cpu + (t - since) > limits.cpuMs) stopped = 'cpu';++ return Boolean(stopped);++ });++ const vm = runtime.newContext();++ const deferred = new Set();++ let opSeq = 0;++ const jobs = () => { enter(); try { runtime.executePendingJobs(); } finally { leave(); } };++ try {++ vm.newString(JSON.stringify(context)).consume((h) => vm.setProp(vm.global, '__context', h));++ vm.newString(JSON.stringify(request)).consume((h) => vm.setProp(vm.global, '__request', h));++ vm.newFunction('__host', (opHandle, argsHandle) => {++ const op = vm.getString(opHandle);++ const args = vm.getString(argsHandle);++ const promise = vm.newPromise();++ deferred.add(promise);++ const opId = ++opSeq;++ pendingOps.set(`${id}:${opId}`, (reply) => {++ if (!vm.alive || !promise.alive) return;++ vm.newString(reply).consume((h) => promise.resolve(h));++ jobs();++ });++ parentPort.postMessage({ type: 'op', id, opId, op, args });++ return promise.handle;++ }).consume((h) => vm.setProp(vm.global, '__host', h));++++ enter();++ let loaded;++ try {++ vm.unwrapResult(vm.evalCode(PRELUDE, 'prelude.js')).dispose();++ loaded = vm.evalCode(code, `functions/${name}.js`);++ } finally { leave(); }++ if (loaded.error) {++ const e = vm.dump(loaded.error); loaded.error.dispose();++ return { error: stopped ? `ran out of ${stopped === 'cpu' ? 'CPU time' : 'time'} while loading` : `did not load: ${errText(e)}` };++ }++ loaded.value.dispose();++ enter();++ let started;++ try {++ started = vm.evalCode(`(async () => {++ if (typeof handle !== 'function') throw new Error('it must define handle(request, ov)');++ const out = await handle(JSON.parse(__request), ov);++ return JSON.stringify({ out: out === undefined ? null : out, logs: __logs });++})().catch((e) => JSON.stringify({ failed: String((e && e.message) || e), logs: __logs }))`, 'run.js');++ } finally { leave(); }++ const promiseHandle = vm.unwrapResult(started);++ const settled = vm.resolvePromise(promiseHandle);++ jobs();++ let timer;++ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - Date.now()) + 20); });++ const out = await Promise.race([settled, timeout]);++ clearTimeout(timer);++ promiseHandle.dispose();++ if (out === 'timeout' || stopped) {++ if (out !== 'timeout' && out.error) out.error.dispose();++ if (out !== 'timeout' && out.value) out.value.dispose();++ return { error: `ran out of ${stopped === 'cpu' ? 'CPU time' : 'time'}` };++ }++ if (out.error) { const e = vm.dump(out.error); out.error.dispose(); return { error: `failed: ${errText(e)}` }; }++ const raw = vm.getString(out.value); out.value.dispose();++ const parsed = JSON.parse(raw);++ return parsed.failed !== undefined ? { failed: parsed.failed, logs: parsed.logs } : { out: parsed.out, logs: parsed.logs };++ } catch (err) {++ const msg = String((err && err.message) || err);++ if (stopped) return { error: `ran out of ${stopped === 'cpu' ? 'CPU time' : 'time'}` };++ if (/out of memory|memory/i.test(msg)) return { error: 'ran out of memory' };++ if (/stack overflow|too much recursion/i.test(msg)) return { error: 'recursed too deeply' };++ return { error: `failed: ${msg.slice(0, 300)}` };++ } finally {++ for (const key of [...pendingOps.keys()]) if (key.startsWith(`${id}:`)) pendingOps.delete(key);++ for (const p of deferred) { try { if (p.alive) p.dispose(); } catch { /* gone */ } }++ try { vm.dispose(); } catch { /* a leak is not worth crashing the worker for */ }++ try { runtime.dispose(); } catch { /* same */ }++ }++}++++parentPort.on('message', (msg) => {++ if (msg.type === 'reply') {++ const fn = pendingOps.get(`${msg.id}:${msg.opId}`);++ if (fn) { pendingOps.delete(`${msg.id}:${msg.opId}`); fn(msg.reply); }++ return;++ }++ if (msg.type === 'run') {++ run(msg).then(++ (result) => parentPort.postMessage({ type: 'done', id: msg.id, result }),++ (err) => parentPort.postMessage({ type: 'done', id: msg.id, result: { error: `failed: ${String((err && err.message) || err).slice(0, 300)}` } }),++ );++ }++});nnew file mode 100644@@@ -0,0 +1,187 @@++'use strict';++/**++ * Space functions: a space's server-side code (plan T20 step 2, "dynamic pages"). A file functions/<name>.js defines++ * `async function handle(request, ov)`; a published space serves it at https://<slug>.openvibe.website/api/<name>++ * (GET or POST, the published deploy's copy), the editor's preview runs the unsaved copy as the maker, and the space SDK++ * calls it with OV.fn(name, body).++ *++ * The code runs in QuickJS compiled to WebAssembly (quickjs-emscripten) on a small pool of worker threads++ * (functions-worker.js), a fresh runtime per call: no network, no files, no Node, at most FN_MEMORY bytes, FN_CPU_MS of++ * its own computing and FN_DEADLINE_MS in all; a worker that overstays is terminated and replaced. Its only way out is++ * `ov`, answered here:++ *++ * ov.user the signed-in visitor ({ username, display_name }) or null++ * ov.space { id, slug, name }++ * ov.items.list|add|set|remove the space's shared lists; an add or set is the visitor's when they signed in and++ * approved "data", otherwise the space's own; a function may remove any item++ * ov.store.get|set|remove the space's own server-side storage (visitors never read it directly)++ * ov.ai.ask the space's AI, for a signed-in visitor who approved "ai"++ * console.log|warn|error kept with the call (the editor shows them)++ *++ * What handle returns becomes the response: a string (HTML when it starts with "<", else text), { status, headers,++ * body } (only content-type and cache-control headers), any other value as JSON, nothing as 204.++ */++const path = require('path');++const { Worker } = require('worker_threads');++const { ApiError } = require('../http/errors');++++const NAME_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;++const FN_MEMORY = 32 * 1024 * 1024;++const FN_CPU_MS = 1000;++const FN_DEADLINE_MS = 5000;++const MAX_SOURCE = 64 * 1024;++const MAX_OPS = 100;++const MAX_BODY = 256 * 1024;++++function createSpaceFunctions({ data, workers = 2, queueMax = 32, log = console, now = Date.now } = {}) {++ const pool = [];++ const queue = [];++ let seq = 0;++ let closed = false;++++ function spawn() {++ const w = new Worker(path.join(__dirname, 'functions-worker.js'));++ w.unref();++ const slot = { w, job: null };++ w.on('message', (msg) => onMessage(slot, msg));++ w.on('error', (err) => { log.warn('[Spaces functions] worker failed:', err && err.message); retire(slot, 'the function runner stopped'); });++ w.on('exit', () => retire(slot, 'the function runner stopped'));++ pool.push(slot);++ return slot;++ }++ function retire(slot, why) {++ const i = pool.indexOf(slot);++ if (i < 0) return;++ pool.splice(i, 1);++ if (slot.job) { const job = slot.job; slot.job = null; clearTimeout(job.kill); job.resolve({ error: why }); }++ slot.w.terminate().catch(() => {});++ pump();++ }++ function onMessage(slot, msg) {++ const job = slot.job;++ if (!job || msg.id !== job.id) return;++ if (msg.type === 'op') {++ if (++job.ops > MAX_OPS) return slot.w.postMessage({ type: 'reply', id: job.id, opId: msg.opId, reply: JSON.stringify({ error: { code: 'quota', message: `a call makes at most ${MAX_OPS} ov calls` } }) });++ let args = {};++ try { args = JSON.parse(msg.args); } catch { /* {} */ }++ Promise.resolve()++ .then(() => { const fn = job.ops_ && job.ops_[msg.op]; if (!fn) throw new ApiError(400, 'space.unknown_op', `unknown ov call ${String(msg.op).slice(0, 40)}`); return fn(args && typeof args === 'object' ? args : {}); })++ .then((result) => ({ result: result === undefined ? null : result }), (err) => ({ error: { code: (err && err.code) || 'error', message: (err && err.message) || 'failed' } }))++ .then((reply) => { if (slot.job === job) slot.w.postMessage({ type: 'reply', id: job.id, opId: msg.opId, reply: JSON.stringify(reply) }); });++ return;++ }++ if (msg.type === 'done') {++ slot.job = null;++ clearTimeout(job.kill);++ job.resolve(msg.result || { error: 'failed' });++ pump();++ }++ }++ function pump() {++ if (closed) return;++ while (queue.length) {++ let slot = pool.find((s) => !s.job);++ if (!slot && pool.length < workers) slot = spawn();++ if (!slot) return;++ const job = queue.shift();++ slot.job = job;++ // The worker stops the code itself at the deadline; this is the backstop if it cannot.++ job.kill = setTimeout(() => { if (slot.job === job) retire(slot, `ran out of time (${FN_DEADLINE_MS / 1000} s)`); }, FN_DEADLINE_MS + 1500);++ slot.w.postMessage({ type: 'run', id: job.id, name: job.name, code: job.code, request: job.request, context: job.context,++ limits: { memory: FN_MEMORY, deadlineMs: FN_DEADLINE_MS, cpuMs: FN_CPU_MS } });++ }++ }++ function submit(job) {++ if (closed) return Promise.resolve({ error: 'the function runner is stopped' });++ if (queue.length >= queueMax) return Promise.resolve({ busy: true });++ return new Promise((resolve) => { queue.push({ ...job, id: ++seq, ops: 0, resolve }); pump(); });++ }++++ // Calls per minute: each space, and each caller of a space.++ const windows = new Map();++ function throttle(key, max) {++ const t = now();++ const list = (windows.get(key) || []).filter((x) => t - x < 60000);++ if (list.length >= max) throw new ApiError(429, 'quota', 'this space\'s functions are busy; wait a minute and try again');++ list.push(t);++ windows.set(key, list);++ if (windows.size > 5000) for (const [k, v] of windows) if (!v.some((x) => t - x < 60000)) windows.delete(k);++ }++++ /** The `ov` calls of one run: the space acts as itself unless the visitor signed in and approved. */++ function opsFor(space, visitor) {++ const self = { subject: `space:${space.id}`, username: space.slug, name: space.name };++ const caps = visitor ? visitor.capabilities : [];++ const writer = visitor && caps.includes('data') ? visitor.person : self;++ return {++ 'items.list': (a) => data.listItems(space, a.collection, { limit: a.limit, before: a.before }, visitor ? visitor.person : null),++ 'items.add': (a) => data.addItem(space, writer, a.collection, a.value),++ 'items.set': (a) => data.setItem(space, writer, a.collection, a.value),++ 'items.remove': (a) => data.removeItem(space, writer, a.collection, a.id, true),++ 'store.get': (a) => data.kvGet(space, self, a.key),++ 'store.set': (a) => data.kvSet(space, self, a.key, a.value),++ 'store.remove': (a) => data.kvRemove(space, self, a.key),++ 'ai.ask': async (a) => {++ if (!visitor) throw new ApiError(401, 'auth.required', 'the visitor has to sign in for the space\'s AI');++ if (!caps.includes('ai')) throw new ApiError(403, 'space.capability', 'the visitor did not approve "ai" for this space');++ return (await data.run('ai.ask', { input: a.input }, { space, person: visitor.person, capabilities: caps, owner: false })).text;++ },++ };++ }++++ /**++ * Run `code` (functions/<name>.js of `space`) for `request` ({ method, path, query, headers, body }). `visitor`:++ * { person, capabilities } or null; `caller`: a key for the per-caller limit (an address, a person).++ * → { status, headers, body (string), logs: [[level, line]] }++ */++ async function call(space, name, code, request, { visitor = null, caller = 'anon', perMinute = 120, perCallerPerMinute = 30 } = {}) {++ if (!NAME_RE.test(String(name))) throw new ApiError(404, 'space.function_not_found', 'no such function');++ if (typeof code !== 'string' || Buffer.byteLength(code) > MAX_SOURCE) throw new ApiError(413, 'space.function_too_large', 'a function is at most 64 KB');++ throttle(`s:${space.id}`, perMinute);++ throttle(`c:${space.id}:${caller}`, perCallerPerMinute);++ const context = {++ user: visitor ? { username: visitor.person.username, display_name: visitor.person.name || visitor.person.username } : null,++ space: { id: space.id, slug: space.slug, name: space.name },++ };++ const r = await submit({ name, code, request, context, ops_: opsFor(space, visitor) });++ const fail = (status, text, logs = []) => ({ status, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: text, logs });++ if (r.busy) return fail(503, 'Space functions are busy right now; try again in a moment.');++ if (r.error) {++ const status = /time/.test(r.error) ? 504 : /memory|recursed/.test(r.error) ? 503 : 500;++ return fail(status, `functions/${name}.js ${r.error}`, r.logs || []);++ }++ if (r.failed !== undefined) return fail(500, `functions/${name}.js failed: ${String(r.failed).slice(0, 300)}`, r.logs || []);++ const out = toResponse(r.out);++ if (Buffer.byteLength(out.body) > MAX_BODY) return fail(500, `functions/${name}.js answered more than ${MAX_BODY / 1024} KB`, r.logs || []);++ return { ...out, logs: r.logs || [] };++ }++++ async function close() {++ closed = true;++ for (const job of queue.splice(0)) job.resolve({ error: 'the function runner is stopped' });++ await Promise.all(pool.splice(0).map((s) => { if (s.job) { clearTimeout(s.job.kill); s.job.resolve({ error: 'the function runner is stopped' }); } return s.w.terminate().catch(() => {}); }));++ }++++ return { call, close, NAME_RE };++}++++/** What handle returned → { status, headers, body (string) }. */++function toResponse(out) {++ const kind = (s) => (/^\s*</.test(s) ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8');++ if (out === null || out === undefined) return { status: 204, headers: {}, body: '' };++ if (typeof out === 'string') return { status: 200, headers: { 'content-type': kind(out) }, body: out };++ if (typeof out === 'object' && !Array.isArray(out) && 'body' in out && ('status' in out || 'headers' in out)) {++ const status = Number.isInteger(out.status) && out.status >= 200 && out.status <= 599 ? out.status : 200;++ const headers = {};++ for (const [k, v] of Object.entries(out.headers && typeof out.headers === 'object' ? out.headers : {})) {++ const key = String(k).toLowerCase();++ if ((key === 'content-type' || key === 'cache-control') && typeof v === 'string' && v.length < 200 && !/[\r\n]/.test(v)) headers[key] = v;++ }++ const body = out.body == null ? '' : typeof out.body === 'string' ? out.body : JSON.stringify(out.body);++ if (!headers['content-type']) headers['content-type'] = typeof out.body === 'string' ? kind(out.body) : 'application/json; charset=utf-8';++ return { status, headers, body };++ }++ return { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify(out) };++}++++module.exports = { createSpaceFunctions, toResponse, NAME_RE, FN_DEADLINE_MS, FN_CPU_MS, MAX_SOURCE };@@@ -199,6 +199,22 @@ function createSpacesPages(ctx) {const space = await own(req);const b = req.body || {};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') {++ const a = b.args && typeof b.args === 'object' ? b.args : {};++ const name = String(a.name || '');++ if (!ctx.spaceFunctions.NAME_RE.test(name)) throw new ApiError(404, 'space.function_not_found', 'function names are 1-40 characters: a-z, 0-9 and "-"');++ const saved = (await spaces.files(space)).find((f) => f.path === `functions/${name}.js`);++ const source = typeof a.source === 'string' ? a.source : saved && saved.content;++ if (typeof source !== 'string') throw new ApiError(404, 'space.function_not_found', `this space has no functions/${name}.js`);++ const query = {};++ if (a.query && typeof a.query === 'object') for (const [k, v] of Object.entries(a.query).slice(0, 50)) query[String(k).slice(0, 100)] = String(v).slice(0, 1000);++ const request = { method: a.method === 'POST' ? 'POST' : 'GET', path: `/api/${name}`, query, headers: {}, body: a.body === undefined ? null : a.body };++ return { result: await ctx.spaceFunctions.call(space, name, source, request, {++ visitor: { person, capabilities: spaces.CAPABILITIES }, caller: person.subject,++ perMinute: limits.fnCallsPerMinute, perCallerPerMinute: limits.fnCallsPerCallerPerMinute,++ }) };++ }return { result: await data.run(String(b.op || ''), b.args || {}, { space, person, capabilities: spaces.CAPABILITIES, owner: true }) };}));@@@ -177,6 +177,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem<section class="sp-ed-preview" aria-label="Preview"><div class="sp-ed-preview-bar"><span><i class="fa-solid fa-eye" aria-hidden="true"></i> Preview <span class="sp-muted">(runs as you)</span></span><button class="sp-btn sp-btn-quiet sp-btn-sm" id="ed-run" type="button"><i class="fa-solid fa-rotate-right" aria-hidden="true"></i> Run</button></div><iframe id="ed-frame" title="Preview of the space" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock"></iframe>++ <details class="sp-ed-fnlog" id="ed-fnlog" hidden><summary><i class="fa-solid fa-terminal" aria-hidden="true"></i> Function log <span class="sp-muted" id="ed-fnlog-last"></span></summary><ol id="ed-fnlog-list"></ol></details></section></div><div class="sp-ed-building" id="ed-building" hidden><div><i class="fa-solid fa-wand-magic-sparkles fa-bounce" aria-hidden="true"></i><h2>OpenVibe AI is building…</h2><p id="ed-building-text"></p><p class="sp-muted">This usually takes under a minute or two. You can leave and come back.</p></div></div>@@@ -10,6 +10,9 @@* origin with no storage), so every call is a postMessage to the editor,* which runs it as the signed-in maker.*++ * OV.fn(name, body) runs the space's functions/<name>.js (server/spaces/functions.js): at /api/<name> on the space's++ * own address when live, through the editor (its unsaved copy) in the preview.++ ** The API is the one the AI builder is told about (OpenVibe.AI server/workflows/space.js SDK_REFERENCE).*/const SDK = String.raw`(function () {@@@ -23,7 +26,7 @@ const SDK = String.raw`(function () {function fail(code, message) { var e = new Error(message || code); e.code = code; return e; }OV.on = function (name, fn) { if (name === 'user' && typeof fn === 'function') listeners.push(fn); return OV; };-- var call;++ var call, fnCall;if (CFG.mode === 'preview') {var seq = 0, waiting = {};window.addEventListener('message', function (ev) {@@@ -38,6 +41,10 @@ const SDK = String.raw`(function () {window.parent.postMessage({ ovSpacePreview: 1, id: id, op: op, args: args || {} }, '*');});};++ fnCall = function (name, method, body, query) {++ return call('fn.call', { name: name, method: method, body: method === 'POST' ? body : undefined, query: query })++ .then(function (r) { return fnResult(r.status, r.headers && r.headers['content-type'], r.body); });++ };OV.signIn = function () { return call('me').then(function (u) { setUser(u); return u; }); };OV.signOut = function () { setUser(null); return Promise.resolve(); };OV.ready = call('me').then(function (u) { setUser(u); return u; }, function () { return null; });@@@ -63,6 +70,15 @@ const SDK = String.raw`(function () {}); }, function () { throw fail('network', 'Could not reach OpenVibe. Check the connection and try again.'); });}call = function (op, args) { return api('/run', 'POST', { op: op, args: args || {} }).then(function (j) { return j.result; }); };++ // The space's own functions answer on its own address (/api/<name>), with the visitor's token when signed in.++ fnCall = function (name, method, body, query) {++ var headers = {}; var t = token(); if (t) headers.Authorization = 'Bearer ' + t;++ if (method === 'POST') headers['Content-Type'] = 'application/json';++ var qs = query ? '?' + new URLSearchParams(query).toString() : '';++ return fetch('/api/' + name + qs, { method: method, headers: headers, body: method === 'POST' ? JSON.stringify(body === undefined ? null : body) : undefined, credentials: 'omit' })++ .then(function (r) { return r.text().then(function (text) { return fnResult(r.status, r.headers.get('content-type'), text); }); },++ function () { throw fail('network', 'Could not reach the space. Check the connection and try again.'); });++ };OV.signIn = function () {var verifier = random(), state = random();store.set(':verifier', verifier); store.set(':state', state);@@@ -108,6 +124,22 @@ const SDK = String.raw`(function () {set: guard(function (k, value) { return call('kv.set', { key: named('key', k), value: value }); }),remove: guard(function (k) { return call('kv.remove', { key: named('key', k) }); }),};++ function fnResult(status, type, text) {++ var value = text;++ if (/application\/json/i.test(type || '')) { try { value = JSON.parse(text); } catch (e) { /* keep the text */ } }++ if (status >= 400) {++ var e = fail(status === 429 ? 'quota' : 'fn.failed', typeof value === 'string' && value ? value.slice(0, 300) : 'The function failed (' + status + ')');++ e.status = status; e.body = value; throw e;++ }++ return status === 204 ? null : value;++ }++ // OV.fn(name[, body][, { method, query }]): run functions/<name>.js on the server (POST when there is a body).++ OV.fn = guard(function (name, body, o) {++ if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(name)) throw fail('space.invalid_name', 'function names are 1-40 characters: a-z, 0-9 and "-"');++ o = o || {};++ var method = String(o.method || (body === undefined ? 'GET' : 'POST')).toUpperCase() === 'POST' ? 'POST' : 'GET';++ return fnCall(name, method, body, o.query || null);++ });OV.ai = { ask: function (input) { return call('ai.ask', { input: input }).then(function (r) { return r.text; }); } };OV.me = function () { return call('me'); };window.OV = OV;@@@ -2,7 +2,8 @@/*** The starters a space can begin from (templates/spaces/*.html). Each is one self-contained index.html built on the* space SDK, so it doubles as an example of every SDK feature: shared lists (guestbook, poll, leaderboard), private-- * storage (to-dos, the AI buddy's history) and OpenVibe AI (the AI buddy).++ * storage (to-dos, the AI buddy's history), OpenVibe AI (the AI buddy) and server functions (trivia's hidden answers, in++ * templates/spaces/trivia.check.js, which a new space gets as functions/check.js).*/const fs = require('fs');const path = require('path');@@@ -14,6 +15,7 @@ const TEMPLATES = [instructions: 'You are a friendly, curious and slightly funny companion. Keep answers short and warm, ask a question back now and then, and never pretend to be a person.' },{ id: 'poll', name: 'Quick poll', icon: 'fa-square-poll-vertical', accent: '#059669', description: 'One question, a few answers, one vote each, live results.', capabilities: ['profile', 'data'] },{ id: 'reaction', name: 'Reaction game', icon: 'fa-bolt', accent: '#16a34a', description: 'Click when it turns green; best times go on a leaderboard.', capabilities: ['profile', 'data'] },++ { id: 'trivia', name: 'Trivia', icon: 'fa-circle-question', accent: '#d97706', description: 'Questions whose answers stay on the server (a space function), with how many got each right.', capabilities: [], functions: ['check'] },{ id: 'todo', name: 'Private to-dos', icon: 'fa-list-check', accent: '#ca8a04', description: 'A to-do list saved to each visitor\'s own account.', capabilities: ['profile', 'kv'] },{ id: 'blank', name: 'Blank', icon: 'fa-file', accent: '#7c3aed', description: 'An empty page with sign-in, to build from scratch.', capabilities: ['profile'] },];@@@ -25,8 +27,14 @@ function get(id) { return TEMPLATES.find((t) => t.id === id) || null; }function files(id) {const t = get(id);if (!t) return null;-- if (!cache.has(id)) cache.set(id, fs.readFileSync(path.join(DIR, `${id}.html`), 'utf8'));-- return [{ path: 'index.html', content: cache.get(id) }];++ if (!cache.has(id)) {++ cache.set(id, [++ { path: 'index.html', content: fs.readFileSync(path.join(DIR, `${id}.html`), 'utf8') },++ // Its server functions: templates/spaces/<id>.<name>.js → functions/<name>.js.++ ...(t.functions || []).map((fn) => ({ path: `functions/${fn}.js`, content: fs.readFileSync(path.join(DIR, `${id}.${fn}.js`), 'utf8') })),++ ]);++ }++ return cache.get(id).map((f) => ({ ...f }));}module.exports = { list, get, files };nnew file mode 100644@@@ -0,0 +1,23 @@++// functions/check.js runs on the server, so the answers below never reach a visitor's browser (try View Source).++// The page asks with OV.fn('check', { id, answer }) and gets back { right, solved }.++const ANSWERS = {++ sun: ['mercury'],++ moon: ['1969'],++ ocean: ['pacific', 'pacific ocean', 'the pacific'],++ bones: ['206'],++ hearts: ['3', 'three'],++};++++async function handle(request, ov) {++ if (request.method !== 'POST') return { questions: Object.keys(ANSWERS).length };++ const { id, answer } = request.body || {};++ if (!Object.prototype.hasOwnProperty.call(ANSWERS, id)) return { status: 400, body: { error: 'unknown question' } };++ const guess = String(answer || '').trim().toLowerCase().replace(/\s+/g, ' ');++ const right = ANSWERS[id].includes(guess);++ // How many right answers each question has had, kept in the space's own server storage.++ const key = 'solved-' + id;++ let solved = (await ov.store.get(key)) || 0;++ if (right) { solved += 1; await ov.store.set(key, solved); }++ console.log(id, right ? 'right' : 'wrong');++ return { right, solved };++}nnew file mode 100644@@@ -0,0 +1,92 @@++<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>Trivia</title>++<style>++ :root { --bg: #0f1020; --panel: #181a30; --ink: #f4f4fb; --muted: #a3a6c4; --line: #2a2d4a; --accent: #f59e0b; --right: #22c55e; --wrong: #f87171; }++ * { box-sizing: border-box; }++ body { margin: 0; background: radial-gradient(1200px 500px at 50% -200px, #2a2150, var(--bg)); color: var(--ink); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; min-height: 100vh; }++ main { max-width: 640px; margin: 0 auto; padding: 28px 16px 56px; }++ header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; margin-bottom: 18px; }++ h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.01em; }++ .sub { margin: 4px 0 0; color: var(--muted); }++ .score { flex: none; text-align: center; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 8px 14px; }++ .score b { display: block; font-size: 1.5rem; color: var(--accent); font-variant-numeric: tabular-nums; line-height: 1.1; }++ .score span { font-size: .8rem; color: var(--muted); }++ ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; counter-reset: q; }++ li { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 16px; counter-increment: q; transition: border-color .2s; }++ li.right { border-color: color-mix(in srgb, var(--right) 60%, var(--line)); }++ .q { margin: 0 0 10px; font-weight: 650; }++ .q::before { content: counter(q) ". "; color: var(--accent); }++ form { display: flex; gap: 8px; }++ input { flex: 1; min-width: 0; border: 1px solid var(--line); background: #0d0e1c; color: inherit; border-radius: 12px; padding: 10px 12px; font: inherit; }++ input:focus-visible, button:focus-visible { outline: 3px solid #818cf8; outline-offset: 2px; }++ button { border: 0; border-radius: 12px; padding: 10px 16px; font: 650 1rem/1.2 inherit; background: var(--accent); color: #1a1205; cursor: pointer; }++ button:disabled { opacity: .6; cursor: default; }++ .result { margin: 8px 0 0; font-size: .92rem; color: var(--muted); }++ .result:empty { display: none; }++ .result.right { color: var(--right); } .result.wrong { color: var(--wrong); }++ footer { margin-top: 22px; color: var(--muted); font-size: .88rem; text-align: center; }++</style>++</head>++<body>++<main>++ <header>++ <div><h1>Trivia</h1><p class="sub">Five questions. The answers are kept on the server, so no peeking.</p></div>++ <div class="score" aria-live="polite"><b id="score">0/5</b><span>right</span></div>++ </header>++ <ol id="questions"></ol>++ <footer>Made on OpenVibe.Space. Edit the questions in index.html and the answers in functions/check.js.</footer>++</main>++<script src="/_openvibe/sdk.js"></script>++<script>++ const QUESTIONS = [++ { id: 'sun', text: 'Which planet is closest to the Sun?' },++ { id: 'moon', text: 'In what year did people first walk on the Moon?' },++ { id: 'ocean', text: 'What is the largest ocean on Earth?' },++ { id: 'bones', text: 'How many bones are in an adult human body?' },++ { id: 'hearts', text: 'How many hearts does an octopus have?' },++ ];++ const solved = new Set();++ const list = document.getElementById('questions');++ const score = () => { document.getElementById('score').textContent = solved.size + '/' + QUESTIONS.length; };++++ for (const q of QUESTIONS) {++ const li = document.createElement('li');++ const p = document.createElement('p'); p.className = 'q'; p.textContent = q.text;++ const form = document.createElement('form');++ const input = document.createElement('input'); input.placeholder = 'Your answer'; input.autocomplete = 'off'; input.setAttribute('aria-label', q.text);++ const button = document.createElement('button'); button.type = 'submit'; button.textContent = 'Check';++ const result = document.createElement('p'); result.className = 'result'; result.setAttribute('aria-live', 'polite');++ form.append(input, button);++ li.append(p, form, result);++ list.append(li);++ form.addEventListener('submit', async (e) => {++ e.preventDefault();++ if (!input.value.trim()) return input.focus();++ button.disabled = true;++ try {++ const r = await OV.fn('check', { id: q.id, answer: input.value });++ if (r.right) {++ solved.add(q.id); score();++ li.classList.add('right'); input.disabled = true;++ result.className = 'result right';++ result.textContent = 'Right! ' + (r.solved === 1 ? 'You are the first to get it.' : r.solved + ' right answers so far.');++ return;++ }++ result.className = 'result wrong';++ result.textContent = 'Not quite. Try again.';++ button.disabled = false; input.select();++ } catch (err) {++ result.className = 'result wrong';++ result.textContent = 'Could not check: ' + err.message;++ button.disabled = false;++ }++ });++ }++ score();++</script>++</body>++</html>nnew file mode 100644@@@ -0,0 +1,155 @@++'use strict';++/**++ * Space functions (server/spaces/functions.js): a space's functions/<name>.js runs on the server in a QuickJS sandbox.++ * The runner answers JSON, HTML and full responses (only content-type and cache-control headers pass), stops a++ * runaway loop on its CPU budget and a stuck promise on its deadline, exposes no Node, network or files, and caps the++ * `ov` calls of one run and the calls per caller. The editor's preview runs the unsaved copy as the maker; a published++ * space serves the published copy at /api/<name> (GET and POST, JSON and form bodies) and never serves functions/ as++ * files; a signed-in visitor's token reaches the function as ov.user and their approved writes; the space's own store++ * is apart from every visitor's.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./stageb/boot');++const { createSpaceFunctions, toResponse } = require('../server/spaces/functions');++++const ORIGIN = 'https://openvibe.space';++const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');++++(async () => {++ // ── The runner alone, over a fake store ──++ const kv = new Map();++ const fake = {++ listItems: async () => [],++ addItem: async (space, person, collection, value) => ({ id: '1', value, author: { username: person.username } }),++ kvGet: async (space, person, key) => (kv.has(`${person.subject}/${key}`) ? kv.get(`${person.subject}/${key}`) : null),++ kvSet: async (space, person, key, value) => { kv.set(`${person.subject}/${key}`, value); return true; },++ };++ const fns = createSpaceFunctions({ data: fake, workers: 1 });++ const space = { id: 'spc_T', slug: 'demo', name: 'Demo' };++ const req = { method: 'GET', path: '/api/x', query: {}, headers: {}, body: null };++ const run = (code, o = {}) => fns.call(space, 'x', code, req, { caller: o.caller || `c${Math.random()}`, ...o });++++ await check('the runner answers JSON, HTML and full responses, keeping only content-type and cache-control', async () => {++ const json = await run('async function handle(r, ov) { const n = (await ov.store.get("n")) || 0; await ov.store.set("n", n + 1); console.log("n is", n); return { n: n + 1 }; }');++ assert.deepStrictEqual([json.status, json.headers['content-type'], json.body, json.logs], [200, 'application/json; charset=utf-8', '{"n":1}', [['log', 'n is 0']]]);++ assert.strictEqual(kv.get('space:spc_T/n'), 1, 'the space\'s own store');++ assert.deepStrictEqual([(await run('function handle() { return "<h1>hi</h1>"; }')).headers['content-type']], ['text/html; charset=utf-8']);++ const full = await run('function handle() { return { status: 201, headers: { "Content-Type": "text/csv", "Set-Cookie": "a=1", "Cache-Control": "public, max-age=60" }, body: "a,b" }; }');++ assert.deepStrictEqual([full.status, full.headers, full.body], [201, { 'content-type': 'text/csv', 'cache-control': 'public, max-age=60' }, 'a,b']);++ assert.strictEqual((await run('function handle() {}')).status, 204);++ assert.deepStrictEqual(toResponse({ status: 99, body: { a: 1 } }), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' }, body: '{"a":1}' });++ });++++ await check('a runaway loop stops on its CPU budget, a stuck promise on its deadline; nothing of Node is reachable', async () => {++ let t0 = Date.now();++ const loop = await run('function handle() { while (true) {} }');++ assert.deepStrictEqual([loop.status, /CPU time/.test(loop.body)], [504, true]);++ assert.ok(Date.now() - t0 < 3000, `the loop stopped in ${Date.now() - t0} ms`);++ t0 = Date.now();++ const stuck = await run('async function handle() { await new Promise(() => {}); }');++ assert.deepStrictEqual([stuck.status, /ran out of time/.test(stuck.body)], [504, true]);++ assert.ok(Date.now() - t0 < 7000);++ const escape = await run('function handle() { return [typeof require, typeof process, typeof fetch, typeof XMLHttpRequest, typeof setTimeout, typeof WebAssembly].join(); }');++ assert.strictEqual(escape.body, 'undefined,undefined,undefined,undefined,undefined,undefined');++ assert.match((await run('function handle( {')).body, /did not load: SyntaxError/);++ assert.match((await run('const x = 1;')).body, /must define handle/);++ assert.match((await run('function f() { return f() + 1; } function handle() { return f(); }')).body, /stack overflow|recursed/);++ assert.strictEqual((await run('function handle() { return "still fine"; }')).body, 'still fine', 'the worker survives all of it');++ });++++ await check('one run makes at most 100 ov calls; a caller makes a limited number of calls a minute', async () => {++ const many = await run('async function handle(r, ov) { for (let i = 0; i < 150; i++) await ov.store.get("k"); return "done"; }');++ assert.deepStrictEqual([many.status, /at most 100 ov calls/.test(many.body)], [500, true]);++ await run('function handle() { return 1; }', { caller: 'same', perCallerPerMinute: 2 });++ await run('function handle() { return 1; }', { caller: 'same', perCallerPerMinute: 2 });++ await assert.rejects(run('function handle() { return 1; }', { caller: 'same', perCallerPerMinute: 2 }), (e) => e.status === 429);++ await assert.rejects(fns.call(space, 'Bad Name', 'function handle() {}', req), (e) => e.status === 404);++ });++ await fns.close();++++ // ── In Space: the preview, the published space, a signed-in visitor ──++ const t = await boot();++ const { csrfToken } = require('../server/auth/forms');++ const alice = t.user('alice'), bob = t.user('bob');++ const csrf = (u) => csrfToken(t.config, { subject: u.subject });++ const form = (u, path, fields) => t.api('POST', path, { session: u, form: { csrf: csrf(u), ...fields }, headers: { origin: ORIGIN } });++ const edit = (u, method, path, json) => t.api(method, path, { session: u, json, headers: { origin: ORIGIN, 'x-csrf-token': csrf(u) } });++ let slug, host, spaceRow;++ try {++ await check('a space made from the trivia starter has its function; the preview runs it (and the unsaved copy) as the maker', async () => {++ const r = await form(alice, '/spaces/new', { template: 'trivia' });++ slug = /^\/spaces\/([^/]+)\/edit$/.exec(r.headers.location)[1];++ host = `${slug}.openvibe.website`;++ spaceRow = await t.ctx.spaces.getBySlug(slug);++ assert.deepStrictEqual((await t.ctx.spaces.files(spaceRow)).map((f) => f.path).sort(), ['functions/check.js', 'index.html']);++ const fn = (args) => edit(alice, 'POST', `/spaces/${slug}/preview`, { op: 'fn.call', args });++ const right = (await fn({ name: 'check', method: 'POST', body: { id: 'sun', answer: ' Mercury ' } })).json().result;++ assert.deepStrictEqual([right.status, JSON.parse(right.body), right.logs], [200, { right: true, solved: 1 }, [['log', 'sun right']]]);++ const draft = (await fn({ name: 'check', source: 'function handle(r, ov) { return { who: ov.user.username, x: r.query.x, m: r.method }; }', query: { x: 1 } })).json().result;++ assert.deepStrictEqual(JSON.parse(draft.body), { who: 'alice', x: '1', m: 'GET' });++ assert.strictEqual((await fn({ name: 'missing' })).json().error.code, 'space.function_not_found');++ assert.strictEqual((await edit(bob, 'POST', `/spaces/${slug}/preview`, { op: 'fn.call', args: { name: 'check' } })).status, 404, 'only the maker (a draft is hidden from others)');++ });++++ await check('published, /api/<name> runs the published copy for GET and POST; functions/ is never served as a file', async () => {++ assert.strictEqual((await edit(alice, 'POST', `/spaces/${slug}/publish`)).status, 200);++ const get = await t.get(host, '/api/check');++ assert.deepStrictEqual([get.status, get.json(), get.headers['cache-control']], [200, { questions: 5 }, 'private, no-store']);++ assert.match(get.headers['content-security-policy'], /connect-src 'self'/, 'the app policy');++ const post = (body, headers = { 'content-type': 'application/json' }) => t.get(host, '/api/check', { method: 'POST', body, headers });++ assert.deepStrictEqual((await post(JSON.stringify({ id: 'moon', answer: '1969' }))).json(), { right: true, solved: 1 });++ assert.deepStrictEqual((await post(JSON.stringify({ id: 'moon', answer: '1970' }))).json(), { right: false, solved: 1 });++ assert.deepStrictEqual((await post('id=ocean&answer=Pacific', { 'content-type': 'application/x-www-form-urlencoded' })).json(), { right: true, solved: 1 });++ assert.strictEqual((await post('{nope')).status, 400);++ assert.strictEqual((await post(JSON.stringify({ x: 'a'.repeat(70 * 1024) }))).status, 413);++ assert.strictEqual((await post(JSON.stringify({ id: 'nope' }))).status, 400, 'the function\'s own status');++ for (const p of ['/functions/check.js', '/function%73/check.js', '/functions/', '/functions']) {++ const f = await t.get(host, p);++ assert.strictEqual(f.status, 404, p);++ assert.ok(!f.text.includes('mercury'), `${p} does not leak the answers`);++ }++ assert.strictEqual((await t.get(host, '/api/nope')).status, 404);++ assert.strictEqual((await t.get(host, '/api/nope', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } })).status, 404);++ assert.strictEqual((await t.get(host, '/api/check', { method: 'PUT', body: '{}' })).status, 405);++ assert.strictEqual((await t.get(host, '/index.html', { method: 'POST', body: '{}' })).status, 405, 'the rest stays static');++ // A saved but unpublished change does not run until it is published.++ await edit(alice, 'PUT', `/spaces/${slug}/files`, { files: [...(await t.ctx.spaces.files(spaceRow)).filter((f) => f.path !== 'functions/check.js'), { path: 'functions/check.js', content: 'function handle() { return "draft"; }' }] });++ assert.deepStrictEqual((await t.get(host, '/api/check')).json(), { questions: 5 });++ });++++ await check('a signed-in visitor reaches the function as ov.user and their approved writes; the space\'s store is its own', async () => {++ await edit(alice, 'PUT', `/spaces/${slug}/settings`, { capabilities: ['profile', 'data', 'kv'] });++ const files = (await t.ctx.spaces.files(spaceRow)).filter((f) => !f.path.startsWith('functions/'));++ await edit(alice, 'PUT', `/spaces/${slug}/files`, { files: [...files,++ { path: 'functions/check.js', content: 'function handle() { return { questions: 5 }; }' },++ { path: 'functions/whoami.js', content: 'async function handle(r, ov) { const item = r.method === "POST" ? await ov.items.add("notes", { text: r.body.text }) : null; return { user: ov.user, by: item && item.author.username, secret: await ov.store.get("solved-moon") }; }' },++ { path: 'functions/cached.js', content: 'function handle() { return { status: 200, headers: { "cache-control": "public, max-age=60" }, body: "<p>hello</p>" }; }' },++ ] });++ assert.strictEqual((await edit(alice, 'POST', `/spaces/${slug}/publish`)).status, 200);++ const verifier = b64url(crypto.randomBytes(32));++ const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());++ const r = await form(bob, `/spaces/${slug}/connect`, { challenge, state: 'st4te-abc', return: '/', decision: 'allow' });++ const code = /#ov_code=([A-Za-z0-9_-]+)/.exec(r.headers.location)[1];++ const token = (await t.api('POST', `/api/v1/apps/${spaceRow.id}/token`, { json: { code, code_verifier: verifier }, headers: { origin: `https://${host}` } })).json().token;++ const anon = (await t.get(host, '/api/whoami')).json();++ assert.deepStrictEqual(anon, { user: null, by: null, secret: 1 }, 'the function reads the space\'s store');++ const signed = await t.get(host, '/api/whoami', { method: 'POST', body: JSON.stringify({ text: 'hi' }), headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` } });++ assert.deepStrictEqual(signed.json(), { user: { username: 'bob', display_name: 'bob' }, by: 'bob', secret: 1 });++ const anonPost = await t.get(host, '/api/whoami', { method: 'POST', body: JSON.stringify({ text: 'anon' }), headers: { 'content-type': 'application/json' } });++ assert.strictEqual(anonPost.json().by, slug, 'signed out, the space writes as itself');++ const visitorKv = await t.api('POST', `/api/v1/apps/${spaceRow.id}/run`, { json: { op: 'kv.get', args: { key: 'solved-moon' } }, headers: { origin: `https://${host}`, authorization: `Bearer ${token}` } });++ assert.strictEqual(visitorKv.json().result, null, 'a visitor never reads the space\'s store');++ const cached = await t.get(host, '/api/cached');++ assert.deepStrictEqual([cached.headers['cache-control'], cached.headers['content-type'], cached.text], ['public, max-age=60', 'text/html; charset=utf-8', '<p>hello</p>']);++ const cachedSigned = await t.get(host, '/api/cached', { headers: { authorization: `Bearer ${token}` } });++ assert.strictEqual(cachedSigned.headers['cache-control'], 'private, no-store', 'never cached for a signed-in visitor');++ });++++ await check('the trivia starter\'s function compiles', async () => {++ const tpl = require('../server/spaces/templates').files('trivia');++ new Function(tpl.find((f) => f.path === 'functions/check.js').content);++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -9,11 +9,12 @@ Space owns static hosting. The Stage B engine moved here from OpenVibe.Host as c
@@ -9,11 +9,12 @@ Space owns static hosting. The Stage B engine moved here from OpenVibe.Host as c
Public hosting remains off by default (`SPACE_PUBLIC_HOSTING=off`). Only OpenVibe staff can create projects. Abuse handling and a designated DMCA agent precede public launch.
Public hosting remains off by default (`SPACE_PUBLIC_HOSTING=off`). Only OpenVibe staff can create projects. Abuse handling and a designated DMCA agent precede public launch.
**Spaces** (plan T20 step 2; `server/spaces/`, `docs/spaces.md`) are small web apps people make here: described to the AI
**Spaces** (plan T20 step 2; `server/spaces/`, `docs/spaces.md`) are small web apps people make here: described to the AI
-builder (OpenVibe.AI `space.app.build`), started from a template (guestbook, AI buddy, poll, reaction game, private
-to-dos, blank) or forked, then edited in a browser editor with a live preview. A space uses the visitor's OpenVibe
-account through the space SDK (`/_openvibe/sdk.js`): sign-in that the visitor approves once, shared lists
-(`OV.items`), private storage (`OV.kv`) and the space's own AI (`OV.ai.ask`, OpenVibe.AI `space.app.ask` on behalf of
-the visitor). Making, editing, previewing and the AI builder work for everyone signed in. Publishing deploys a space to
+builder (OpenVibe.AI `space.app.build`), started from a template (guestbook, AI buddy, poll, reaction game, trivia,
+private to-dos, blank) or forked, then edited in a browser editor with a live preview. A space uses the visitor's
+OpenVibe account through the space SDK (`/_openvibe/sdk.js`): sign-in that the visitor approves once, shared lists
+(`OV.items`), private storage (`OV.kv`), the space's own AI (`OV.ai.ask`, OpenVibe.AI `space.app.ask` on behalf of
+the visitor) and its own server code (`functions/<name>.js`, run in a QuickJS WebAssembly sandbox at `/api/<name>`
+and from `OV.fn`). Making, editing, previewing and the AI builder work for everyone signed in. Publishing deploys a space to
a site of its maker's Spaces project at `<slug>.openvibe.website`, so it follows the same launch switch: staff until
a site of its maker's Spaces project at `<slug>.openvibe.website`, so it follows the same launch switch: staff until
`SPACE_PUBLIC_HOSTING=on`.
`SPACE_PUBLIC_HOSTING=on`.
@@ -18,6 +18,23 @@ server {
@@ -18,6 +18,23 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 1k;
client_max_body_size 1k;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
+ # A space's functions (/api/<name>, server/spaces/functions.js) also take POST, a small body and the page's
+ # Bearer token (the space SDK's sign-in); cookies never pass, as everywhere on a tenant host.
+ location ~ ^/api/[a-z0-9][a-z0-9-]{0,39}/?$ {
+ limit_except GET HEAD POST { deny all; }
+ client_max_body_size 64k;
+ limit_req zone=ovspace_sites burst=200 nodelay;
+ proxy_pass http://127.0.0.1:4940;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header Cookie "";
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_hide_header Set-Cookie;
+ proxy_read_timeout 60s;
+ }
location / {
location / {
limit_except GET HEAD { deny all; }
limit_except GET HEAD { deny all; }
limit_req zone=ovspace_sites burst=200 nodelay;
limit_req zone=ovspace_sites burst=200 nodelay;
@@ -51,11 +51,50 @@ Load it before your own script:
@@ -51,11 +51,50 @@ Load it before your own script:
| `OV.items.remove(collection, id)` | Removes the visitor's own item (the space's maker may remove any). |
| `OV.items.remove(collection, id)` | Removes the visitor's own item (the space's maker may remove any). |
| `OV.kv.get(key)`, `OV.kv.set(key, value)`, `OV.kv.remove(key)` | The visitor's private storage in this space (up to 16 KB a value, 200 keys). |
| `OV.kv.get(key)`, `OV.kv.set(key, value)`, `OV.kv.remove(key)` | The visitor's private storage in this space (up to 16 KB a value, 200 keys). |
| `OV.ai.ask(text \| { messages })` | An answer from OpenVibe AI that follows the space's instructions, on behalf of the visitor. |
| `OV.ai.ask(text \| { messages })` | An answer from OpenVibe AI that follows the space's instructions, on behalf of the visitor. |
+| `OV.fn(name[, body][, { method, query }])` | Runs the space's `functions/<name>.js` on the server (POST when there is a body) and resolves with what it answered: JSON parsed, anything else as text. |
Every call returns a Promise. A failure is an `Error` with `.code`: `auth.required` (call `OV.signIn()`), `quota`
Every call returns a Promise. A failure is an `Error` with `.code`: `auth.required` (call `OV.signIn()`), `quota`
(try later), `space.capability` (the space did not ask for it), and so on. Collection names and keys are 1-64
(try later), `space.capability` (the space did not ask for it), and so on. Collection names and keys are 1-64
characters of letters, digits, `.`, `-` and `_`.
characters of letters, digits, `.`, `-` and `_`.
+## Functions (server code)
+
+A space may keep code that runs on the server instead of in the visitor's browser: answers nobody should see, a
+counter every visitor shares, a check before something is saved, a page put together per request. Each file
+`functions/<name>.js` (a name of 1-40 characters: `a-z`, `0-9`, `-`) defines one handler:
+
+```js
+// functions/hello.js
+async function handle(request, ov) {
+ const visits = ((await ov.store.get('visits')) || 0) + 1;
+ await ov.store.set('visits', visits);
+ return { hello: ov.user ? ov.user.display_name : 'stranger', visits };
+}
+```
+
+- **Where it runs:** on a published space at `https://<slug>.openvibe.website/api/<name>` (GET, HEAD and POST; it is
+ the published copy that runs, so a saved change waits for the next publish). The page calls it with
+ `OV.fn('hello')`, and a link or form may point at `/api/hello` directly. In the editor, the preview runs the copy in
+ the editor (unsaved changes too) as the maker, and each call's status and console lines show in **Function log**
+ under the preview. The files under `functions/` are never served as files, so their source stays private.
+- **`request`:** `{ method, path, query, headers, body }`. `headers` carries only `content-type`, `accept`,
+ `accept-language` and `user-agent`; `body` is parsed JSON, a form as an object, or text (64 KB at most). The
+ visitor's address and cookies never reach a function.
+- **`ov`:** `ov.user` (the visitor signed in through the SDK, or `null`), `ov.space` (`{ id, slug, name }`),
+ `ov.items.list/add/set/remove` (the shared lists: an add or set is the visitor's when they approved "data", otherwise
+ the space's own; a function may remove any item), `ov.store.get/set/remove` (the space's own storage on the server,
+ which no visitor reads directly), `ov.ai.ask` (for a signed-in visitor who approved "ai") and
+ `console.log/warn/error`.
+- **What it returns** becomes the answer: a string (HTML when it starts with `<`, text otherwise), any other value as
+ JSON, nothing as 204, or `{ status, headers, body }` (only `content-type` and `cache-control` headers are kept). An
+ answer to a signed-in visitor or to a POST is never cached.
+- **The sandbox:** each call runs in a fresh QuickJS runtime compiled to WebAssembly, on a worker thread: no network,
+ no files, no timers, no Node. It has 32 MB of memory, 1 s of its own computing and 5 s in all (time spent waiting on
+ `ov` is not computing), and at most 100 `ov` calls. A call that overstays answers 504; a thrown error answers 500
+ with its message.
+
+Starting from the **Trivia** template gives a working example: its answers live in `functions/check.js`.
+
## Sign-in, step by step
## Sign-in, step by step
1. `OV.signIn()` makes a PKCE verifier and state, keeps them in `sessionStorage`, and goes to
1. `OV.signIn()` makes a PKCE verifier and state, keeps them in `sessionStorage`, and goes to
@@ -76,6 +115,8 @@ These are per person or per space, set in `server/hosting-config.js`:
@@ -76,6 +115,8 @@ These are per person or per space, set in `server/hosting-config.js`:
- 25 spaces per person, 20 files and 1 MB per space.
- 25 spaces per person, 20 files and 1 MB per space.
- 20,000 list items per space, 30 writes a minute per person and space.
- 20,000 list items per space, 30 writes a minute per person and space.
- 20 AI builds per person a day, 6 AI questions a minute per person and space, 500 AI answers per space a day.
- 20 AI builds per person a day, 6 AI questions a minute per person and space, 500 AI answers per space a day.
+- Functions: 240 calls a minute per space and 60 per caller (a signed-in visitor, or an address), two worker threads
+ (`SPACE_FN_WORKERS`), and 300 writes a minute for the space's own writes (ten times a person's).
- Each OpenVibe AI run also counts against the person's own daily allowance in OpenVibe.AI.
- Each OpenVibe AI run also counts against the person's own daily allowance in OpenVibe.AI.
## Code
## Code
@@ -87,7 +128,8 @@ These are per person or per space, set in `server/hosting-config.js`:
@@ -87,7 +128,8 @@ These are per person or per space, set in `server/hosting-config.js`:
| `server/spaces/api.js` | The API a space's page calls. |
| `server/spaces/api.js` | The API a space's page calls. |
| `server/spaces/pages.js` and `render.js` | The pages, the editor's calls and the connect flow. |
| `server/spaces/pages.js` and `render.js` | The pages, the editor's calls and the connect flow. |
| `server/spaces/sdk.js` | The SDK. |
| `server/spaces/sdk.js` | The SDK. |
+| `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. |
| `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` | The tests. |
+| `test/spaces.test.js`, `test/space-functions.test.js` | The tests. |
@@ -18,7 +18,8 @@
@@ -18,7 +18,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "quickjs-emscripten": "0.32.0"
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -35,6 +36,48 @@
@@ -35,6 +36,48 @@
"devOptional": true,
"devOptional": true,
"license": "Apache-2.0"
"license": "Apache-2.0"
},
},
+ "node_modules/@jitl/quickjs-ffi-types": {
+ "version": "0.32.0",
+ "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
+ "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
+ "license": "MIT"
+ },
+ "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
+ "version": "0.32.0",
+ "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz",
+ "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jitl/quickjs-ffi-types": "0.32.0"
+ }
+ },
+ "node_modules/@jitl/quickjs-wasmfile-debug-sync": {
+ "version": "0.32.0",
+ "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz",
+ "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@jitl/quickjs-ffi-types": "0.32.0"
+ }
+ },
+ "node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
+ "version": "0.32.0",
+ "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz",
+ "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@jitl/quickjs-ffi-types": "0.32.0"
+ }
+ },
+ "node_modules/@jitl/quickjs-wasmfile-release-sync": {
+ "version": "0.32.0",
+ "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz",
+ "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jitl/quickjs-ffi-types": "0.32.0"
+ }
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -1032,6 +1075,31 @@
@@ -1032,6 +1075,31 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
+ "node_modules/quickjs-emscripten": {
+ "version": "0.32.0",
+ "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz",
+ "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0",
+ "@jitl/quickjs-wasmfile-debug-sync": "0.32.0",
+ "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0",
+ "@jitl/quickjs-wasmfile-release-sync": "0.32.0",
+ "quickjs-emscripten-core": "0.32.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/quickjs-emscripten-core": {
+ "version": "0.32.0",
+ "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
+ "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jitl/quickjs-ffi-types": "0.32.0"
+ }
+ },
"node_modules/range-parser": {
"node_modules/range-parser": {
"version": "1.2.1",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -30,7 +30,8 @@
@@ -30,7 +30,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "quickjs-emscripten": "0.32.0"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -115,7 +115,14 @@ body[data-page="spaces"] .page:has(.sp-editor) { padding-bottom: 0; }
@@ -115,7 +115,14 @@ body[data-page="spaces"] .page:has(.sp-editor) { padding-bottom: 0; }
.sp-ed-code .cm-s-default .cm-meta, .sp-ed-code .cm-s-default .cm-bracket, .sp-ed-code .cm-s-default .cm-operator { color: #a5b4c8; }
.sp-ed-code .cm-s-default .cm-meta, .sp-ed-code .cm-s-default .cm-bracket, .sp-ed-code .cm-s-default .cm-operator { color: #a5b4c8; }
.sp-ed-code .cm-s-default .cm-error { color: #f87171; }
.sp-ed-code .cm-s-default .cm-error { color: #f87171; }
.sp-ed-code .CodeMirror-matchingbracket { color: #4ade80 !important; outline: 1px solid color-mix(in srgb, #4ade80 50%, transparent); }
.sp-ed-code .CodeMirror-matchingbracket { color: #4ade80 !important; outline: 1px solid color-mix(in srgb, #4ade80 50%, transparent); }
-.sp-ed-preview { display: grid; grid-template-rows: auto 1fr; min-height: 0; }
+.sp-ed-preview { display: grid; grid-template-rows: auto 1fr auto; min-height: 0; }
+/* The preview's server function calls (OV.fn): each call's status and its console lines. */
+.sp-ed-fnlog { border-top: 1px solid var(--border, #2c3d5c); font-size: 0.84rem; max-height: 34vh; overflow: auto; }
+.sp-ed-fnlog summary { cursor: pointer; padding: 6px 10px; }
+.sp-ed-fnlog ol { list-style: none; margin: 0; padding: 0 10px 8px; display: grid; gap: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+.sp-ed-fnlog li { white-space: pre-wrap; overflow-wrap: anywhere; }
+.sp-ed-fnlog .is-call { color: var(--text-secondary, #9fb0c8); margin-top: 4px; }
+.sp-ed-fnlog .is-warn { color: #fbbf24; } .sp-ed-fnlog .is-error { color: #f87171; }
.sp-ed-preview-bar { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid var(--border, #2c3d5c); font-size: 0.88rem; }
.sp-ed-preview-bar { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid var(--border, #2c3d5c); font-size: 0.88rem; }
.sp-ed-preview iframe { width: 100%; height: 100%; border: 0; background: #fff; }
.sp-ed-preview iframe { width: 100%; height: 100%; border: 0; background: #fff; }
.sp-ed-building { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; background: color-mix(in srgb, var(--bg-primary, #0b1220) 88%, transparent); backdrop-filter: blur(4px); z-index: 5; padding: 24px; }
.sp-ed-building { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; background: color-mix(in srgb, var(--bg-primary, #0b1220) 88%, transparent); backdrop-filter: blur(4px); z-index: 5; padding: 24px; }
@@ -36,9 +36,11 @@
@@ -36,9 +36,11 @@
function fileOf(path) { return files.find(function (f) { return f.path === path; }); }
function fileOf(path) { return files.find(function (f) { return f.path === path; }); }
function stash() { var f = fileOf(current); if (f) f.content = getText(); }
function stash() { var f = fileOf(current); if (f) f.content = getText(); }
+ // Tabs: index.html, then the other top-level files, then folders (functions/…), each by name.
+ function tabRank(p) { return p === 'index.html' ? 0 : p.indexOf('/') < 0 ? 1 : 2; }
function renderTabs() {
function renderTabs() {
tabs.replaceChildren();
tabs.replaceChildren();
- files.forEach(function (f) {
+ files.slice().sort(function (a, b) { return tabRank(a.path) - tabRank(b.path) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0); }).forEach(function (f) {
var b = document.createElement('button');
var b = document.createElement('button');
b.type = 'button'; b.className = 'sp-ed-tab'; b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', f.path === current ? 'true' : 'false');
b.type = 'button'; b.className = 'sp-ed-tab'; b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', f.path === current ? 'true' : 'false');
b.append(f.path);
b.append(f.path);
@@ -87,10 +89,45 @@
@@ -87,10 +89,45 @@
var d = ev.data;
var d = ev.data;
if (ev.source !== frame.contentWindow || !d || d.ovSpacePreview !== 1 || typeof d.op !== 'string') return;
if (ev.source !== frame.contentWindow || !d || d.ovSpacePreview !== 1 || typeof d.op !== 'string') return;
var reply = function (msg) { try { frame.contentWindow.postMessage(Object.assign({ ovSpacePreview: 1, id: d.id }, msg), '*'); } catch (e) { /* the frame reloaded */ } };
var reply = function (msg) { try { frame.contentWindow.postMessage(Object.assign({ ovSpacePreview: 1, id: d.id }, msg), '*'); } catch (e) { /* the frame reloaded */ } };
- call('POST', '/preview', { op: d.op, args: d.args || {} })
- .then(function (j) { reply({ ok: true, result: j.result }); }, function (e) { reply({ ok: false, code: e.code || 'error', message: e.message }); });
+ var args = d.args || {};
+ if (d.op === 'fn.call' && typeof args.name === 'string') {
+ // The function as it is in the editor now, saved or not.
+ stash();
+ var src = fileOf('functions/' + args.name + '.js');
+ if (src) args = Object.assign({}, args, { source: src.content });
+ }
+ var started = Date.now();
+ call('POST', '/preview', { op: d.op, args: args })
+ .then(function (j) {
+ if (d.op === 'fn.call') fnLog(args.name, j.result, Date.now() - started);
+ reply({ ok: true, result: j.result });
+ }, function (e) {
+ if (d.op === 'fn.call') fnLog(args.name, { status: e.status || 0, body: e.message, logs: [] }, Date.now() - started);
+ reply({ ok: false, code: e.code || 'error', message: e.message });
+ });
});
});
+ // ── The function log: each OV.fn call in the preview, newest first, with what it logged ──
+ var fnLogBox = $('ed-fnlog'), fnLogList = $('ed-fnlog-list');
+ function fnLog(name, r, ms) {
+ if (!fnLogBox || !r) return;
+ var lines = [];
+ var head = document.createElement('li');
+ head.className = 'is-call' + (r.status >= 400 || !r.status ? ' is-error' : '');
+ head.textContent = 'functions/' + name + '.js → ' + (r.status || 'error') + ' · ' + ms + ' ms' + (r.status >= 400 || !r.status ? ' · ' + String(r.body || '').slice(0, 300) : '');
+ lines.push(head);
+ (r.logs || []).forEach(function (l) {
+ var li = document.createElement('li');
+ li.className = l[0] === 'warn' ? 'is-warn' : l[0] === 'error' ? 'is-error' : '';
+ li.textContent = l[1];
+ lines.push(li);
+ });
+ for (var i = lines.length - 1; i >= 0; i--) fnLogList.insertBefore(lines[i], fnLogList.firstChild);
+ while (fnLogList.children.length > 200) fnLogList.removeChild(fnLogList.lastChild);
+ $('ed-fnlog-last').textContent = '· ' + name + ' → ' + (r.status || 'error');
+ fnLogBox.hidden = false;
+ }
+
// ── Saving ──
// ── Saving ──
function save() {
function save() {
stash();
stash();
@@ -166,6 +166,10 @@ function load(env = process.env) {
@@ -166,6 +166,10 @@ function load(env = process.env) {
aiBuildsPerDay: int(env.SPACE_AI_BUILDS_PER_DAY, 20),
aiBuildsPerDay: int(env.SPACE_AI_BUILDS_PER_DAY, 20),
aiAsksPerMinute: int(env.SPACE_AI_ASKS_PER_MINUTE, 6),
aiAsksPerMinute: int(env.SPACE_AI_ASKS_PER_MINUTE, 6),
aiAsksPerSpacePerDay: int(env.SPACE_AI_ASKS_PER_SPACE_PER_DAY, 500),
aiAsksPerSpacePerDay: int(env.SPACE_AI_ASKS_PER_SPACE_PER_DAY, 500),
+ // Space functions (server/spaces/functions.js): worker threads, and calls per minute per space and per caller.
+ fnWorkers: int(env.SPACE_FN_WORKERS, 2),
+ fnCallsPerMinute: int(env.SPACE_FN_CALLS_PER_MINUTE, 240),
+ fnCallsPerCallerPerMinute: int(env.SPACE_FN_CALLS_PER_CALLER_PER_MINUTE, 60),
ai: {
ai: {
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'),
@@ -68,6 +68,7 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
@@ -68,6 +68,7 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
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, 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 });
{
{
const { sdkSource } = require('./spaces/sdk');
const { sdkSource } = require('./spaces/sdk');
const bySite = new Map();
const bySite = new Map();
@@ -83,6 +84,14 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
@@ -83,6 +84,14 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
},
},
forget(siteId) { bySite.delete(siteId); },
forget(siteId) { bySite.delete(siteId); },
sdk: (space) => sdkSource({ mode: 'live', space, api: new URL(config.baseUrl).origin, avatar: `${config.networkUrl}/avatar/{username}` }),
sdk: (space) => sdkSource({ mode: 'live', space, api: new URL(config.baseUrl).origin, avatar: `${config.networkUrl}/avatar/{username}` }),
+ // functions/<name>.js of a published space, for one request to https://<slug>.<sitesDomain>/api/<name>.
+ async runFunction(space, name, code, request, { bearer = null, caller = '' } = {}) {
+ const visitor = bearer ? await ctx.spaceData.verify(space, bearer) : null;
+ return await ctx.spaceFunctions.call(space, name, code, request, {
+ visitor, caller: visitor ? visitor.person.subject : `ip:${caller}`,
+ perMinute: config.spaces.fnCallsPerMinute, perCallerPerMinute: config.spaces.fnCallsPerCallerPerMinute,
+ });
+ },
};
};
}
}
if (app.locals.metrics && typeof app.locals.metrics.gauge === 'function') {
if (app.locals.metrics && typeof app.locals.metrics.gauge === 'function') {
@@ -129,6 +129,22 @@ function baseHeaders(res, { sandbox = false } = {}) {
@@ -129,6 +129,22 @@ function baseHeaders(res, { sandbox = false } = {}) {
if (sandbox) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
if (sandbox) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
}
}
+const FN_PATH = /^\/api\/([a-z0-9][a-z0-9-]{0,39})\/?$/;
+const FN_MAX_SOURCE = 64 * 1024;
+const FN_MAX_BODY = 64 * 1024;
+
+/** A request body up to `max` bytes, or null when it is longer (the rest is drained, not kept). */
+function readBody(req, max) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ let size = 0;
+ let over = false;
+ req.on('data', (chunk) => { size += chunk.length; if (size > max) over = true; else chunks.push(chunk); });
+ req.on('end', () => resolve(over ? null : Buffer.concat(chunks)));
+ req.on('error', reject);
+ });
+}
+
function plain(res, status, text, extra = {}) {
function plain(res, status, text, extra = {}) {
res.statusCode = status;
res.statusCode = status;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
@@ -249,6 +265,53 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -249,6 +265,53 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
stream.pipe(res);
stream.pipe(res);
}
}
+ /**
+ * GET/HEAD/POST /api/<name> on a published space: functions/<name>.js of the deploy the site serves, run in the
+ * functions sandbox (server/spaces/functions.js) for this request. The page's Bearer token (the space SDK sends it)
+ * signs the visitor in; cookies, the address and other headers never reach the function. An answer to a signed-in
+ * visitor or to a POST is never cached; otherwise the function may choose its Cache-Control.
+ */
+ async function spaceFunction(req, res, site, space, name, rawPath, search) {
+ const row = site.active_deploy_id ? await q.file.get(site.active_deploy_id, `functions/${name}.js`) : null;
+ if (!row || row.project_id !== site.project_id) {
+ if (req.method === 'POST') return plain(res, 404, `This space has no function named ${name}.`);
+ return await notFound(req, res, site, 'missing');
+ }
+ if (row.size > FN_MAX_SOURCE) return plain(res, 500, 'This function is too large to run.');
+ let code;
+ try { code = await fs.promises.readFile(await blobs.ensure(row.project_id, row.sha256), 'utf8'); } catch (err) {
+ log.error('[Space] function unreadable:', row.project_id, row.sha256, err.code || err.message);
+ return plain(res, 500, 'This function could not be read. Try again later.');
+ }
+ let body = null;
+ if (req.method === 'POST') {
+ const raw = await readBody(req, FN_MAX_BODY);
+ if (raw === null) return plain(res, 413, `A request to a space function is at most ${FN_MAX_BODY / 1024} KB.`);
+ const type = String(req.headers['content-type'] || '').toLowerCase();
+ const text = raw.toString('utf8');
+ if (/^application\/json/.test(type)) { try { body = JSON.parse(text || 'null'); } catch { return plain(res, 400, 'The request body is not valid JSON.'); } }
+ else if (/^application\/x-www-form-urlencoded/.test(type)) body = Object.fromEntries(new URLSearchParams(text));
+ else body = text;
+ }
+ const headers = {};
+ for (const h of ['content-type', 'accept', 'accept-language', 'user-agent']) if (typeof req.headers[h] === 'string') headers[h] = req.headers[h].slice(0, 300);
+ const auth = /^Bearer\s+([A-Za-z0-9_-]{20,100})$/.exec(String(req.headers.authorization || ''));
+ const request = { method: req.method === 'HEAD' ? 'GET' : req.method, path: rawPath, query: Object.fromEntries(new URLSearchParams(search)), headers, body };
+ let out;
+ try {
+ out = await apps().runFunction(space, name, code, request, { bearer: auth ? auth[1] : null, caller: req.ip || (req.socket && req.socket.remoteAddress) || '' });
+ } catch (err) {
+ if (err && Number.isInteger(err.status) && err.status < 500) return plain(res, err.status, err.message);
+ log.error('[Space] function failed:', space.id, name, err && err.stack ? err.stack : err);
+ return plain(res, 500, 'This function could not run. Try again later.');
+ }
+ res.statusCode = out.status;
+ res.setHeader('Content-Type', out.headers['content-type'] || 'text/plain; charset=utf-8');
+ res.setHeader('Cache-Control', auth || req.method === 'POST' || !out.headers['cache-control'] ? 'private, no-store' : out.headers['cache-control']);
+ res.setHeader('Content-Length', Buffer.byteLength(out.body));
+ return res.end(req.method === 'HEAD' ? undefined : out.body);
+ }
+
/** The absolute origin for the host this request arrived on (tenant hosts are https in production). */
/** The absolute origin for the host this request arrived on (tenant hosts are https in production). */
function originOf(req) {
function originOf(req) {
const host = normaliseHost(req.headers.host);
const host = normaliseHost(req.headers.host);
@@ -344,7 +407,8 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -344,7 +407,8 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
// 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));
- if (req.method !== 'GET' && req.method !== 'HEAD') {
+ // A space's function (/api/<name>) also answers POST; everything else here is static.
+ if (req.method !== 'GET' && req.method !== 'HEAD' && !(space && req.method === 'POST' && FN_PATH.test(String(req.url || '').split('?')[0]))) {
return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
}
}
// Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.
// Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.
@@ -370,6 +434,10 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -370,6 +434,10 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Cache-Control', 'no-cache');
return res.end(req.method === 'HEAD' ? undefined : apps().sdk(space));
return res.end(req.method === 'HEAD' ? undefined : apps().sdk(space));
}
}
+ if (space) {
+ const fn = FN_PATH.exec(rawPath);
+ if (fn) return await spaceFunction(req, res, site, space, fn[1], rawPath, search);
+ }
// A configured redirect: an exact path to a local path (validated on write, never an open redirect).
// A configured redirect: an exact path to a local path (validated on write, never an open redirect).
// It does not need an active deploy, and it is checked before anything is read from one.
// It does not need an active deploy, and it is checked before anything is read from one.
if (cfg && cfg.redirects.length) {
if (cfg && cfg.redirects.length) {
@@ -385,6 +453,8 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -385,6 +453,8 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
const c = candidates(rawPath);
const c = candidates(rawPath);
if (!c) return await notFound(req, res, site, 'invalid');
if (!c) return await notFound(req, res, site, 'invalid');
const deployId = site.active_deploy_id;
const deployId = site.active_deploy_id;
+ // A space's functions/ are its server code: they run at /api/<name> and are never served as files.
+ if (space) c.list = c.list.filter((p) => !/^functions(\/|$)/i.test(p));
for (const p of c.list) {
for (const p of c.list) {
const row = await q.file.get(deployId, p);
const row = await q.file.get(deployId, p);
if (row && row.project_id === site.project_id) {
if (row && row.project_id === site.project_id) {
@@ -396,7 +466,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -396,7 +466,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
return send(req, res, site, row, { cache: hashed ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate' });
return send(req, res, site, row, { cache: hashed ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate' });
}
}
}
}
- if (!c.dir && c.dirIndex && await q.file.get(deployId, c.dirIndex)) {
+ if (!c.dir && c.dirIndex && !(space && /^functions(\/|$)/i.test(c.dirIndex)) && await q.file.get(deployId, c.dirIndex)) {
// /docs → /docs/ so relative links inside docs/index.html resolve; built from the raw path.
// /docs → /docs/ so relative links inside docs/index.html resolve; built from the raw path.
res.statusCode = 301;
res.statusCode = 301;
res.setHeader('Location', `${rawPath}/${search}`);
res.setHeader('Location', `${rawPath}/${search}`);
@@ -69,6 +69,8 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
@@ -69,6 +69,8 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
if (windows.size > 5000) for (const [k, v] of windows) if (!v.some((x) => t - x < 60000)) windows.delete(k);
if (windows.size > 5000) for (const [k, v] of windows) if (!v.some((x) => t - x < 60000)) windows.delete(k);
}
}
+ // The space's own writes (its functions, as `space:<id>`) serve every visitor at once: ten times one person's.
+ const writeLimit = (person) => (String(person.subject).startsWith('space:') ? limits.writesPerMinute * 10 : limits.writesPerMinute);
const checkName = (kind, v) => {
const checkName = (kind, v) => {
if (typeof v !== 'string' || !NAME_RE.test(v)) throw new ApiError(422, 'space.invalid_name', `${kind} names are 1-64 characters: letters, digits, ".", "-" and "_"`);
if (typeof v !== 'string' || !NAME_RE.test(v)) throw new ApiError(422, 'space.invalid_name', `${kind} names are 1-64 characters: letters, digits, ".", "-" and "_"`);
return v;
return v;
@@ -101,7 +103,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
@@ -101,7 +103,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
async function addItem(space, person, collection, value) {
async function addItem(space, person, collection, value) {
checkName('collection', collection);
checkName('collection', collection);
const v = jsonOf(value, limits.itemBytes);
const v = jsonOf(value, limits.itemBytes);
- throttle(`w:${space.id}:${person.subject}`, limits.writesPerMinute);
+ throttle(`w:${space.id}:${person.subject}`, writeLimit(person));
if ((await q.count.get(space.id)).n >= limits.itemsPerSpace) throw new ApiError(429, 'quota', `this space holds ${limits.itemsPerSpace} items at most`);
if ((await q.count.get(space.id)).n >= limits.itemsPerSpace) throw new ApiError(429, 'quota', `this space holds ${limits.itemsPerSpace} items at most`);
const t = now();
const t = now();
const r = await q.add.get(space.id, collection, person.subject, person.username, person.name, v, t, t);
const r = await q.add.get(space.id, collection, person.subject, person.username, person.name, v, t, t);
@@ -110,7 +112,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
@@ -110,7 +112,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
async function setItem(space, person, collection, value) {
async function setItem(space, person, collection, value) {
checkName('collection', collection);
checkName('collection', collection);
const v = jsonOf(value, limits.itemBytes);
const v = jsonOf(value, limits.itemBytes);
- throttle(`w:${space.id}:${person.subject}`, limits.writesPerMinute);
+ throttle(`w:${space.id}:${person.subject}`, writeLimit(person));
const t = now();
const t = now();
const r = await q.setMine.get(space.id, collection, person.subject, person.username, person.name, v, t, t);
const r = await q.setMine.get(space.id, collection, person.subject, person.username, person.name, v, t, t);
return itemOut({ id: r.id, author_subject: person.subject, author_username: person.username, author_name: person.name, value: v, created_at: r.created_at, updated_at: t }, person);
return itemOut({ id: r.id, author_subject: person.subject, author_username: person.username, author_name: person.name, value: v, created_at: r.created_at, updated_at: t }, person);
@@ -133,7 +135,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
@@ -133,7 +135,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
async function kvSet(space, person, key, value) {
async function kvSet(space, person, key, value) {
checkName('key', key);
checkName('key', key);
const v = jsonOf(value, limits.kvBytes);
const v = jsonOf(value, limits.kvBytes);
- throttle(`w:${space.id}:${person.subject}`, limits.writesPerMinute);
+ throttle(`w:${space.id}:${person.subject}`, writeLimit(person));
if (!(await q.kvGet.get(space.id, person.subject, key)) && (await q.kvCount.get(space.id, person.subject)).n >= limits.kvKeysPerPerson) {
if (!(await q.kvGet.get(space.id, person.subject, key)) && (await q.kvCount.get(space.id, person.subject)).n >= limits.kvKeysPerPerson) {
throw new ApiError(429, 'quota', `a person keeps ${limits.kvKeysPerPerson} keys at most in one space`);
throw new ApiError(429, 'quota', `a person keeps ${limits.kvKeysPerPerson} keys at most in one space`);
}
}
@@ -221,7 +223,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
@@ -221,7 +223,7 @@ function createSpaceData({ store, config, spaces, ai, now: clock = null }) {
}
}
async function revoke(bearer) { if (typeof bearer === 'string') await q.dropToken.run(sha(bearer)); }
async function revoke(bearer) { if (typeof bearer === 'string') await q.dropToken.run(sha(bearer)); }
- return { run, listItems, grantedFor, approve, exchange, verify, revoke, CODE_TTL, TOKEN_TTL };
+ return { run, listItems, addItem, setItem, removeItem, kvGet, kvSet, kvRemove, grantedFor, approve, exchange, verify, revoke, CODE_TTL, TOKEN_TTL };
}
}
module.exports = { createSpaceData, NAME_RE };
module.exports = { createSpaceData, NAME_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+'use strict';
+/**
+ * One worker thread of the space functions pool (server/spaces/functions.js): it runs a function's code in QuickJS
+ * (WebAssembly), one fresh runtime per call, so a slow or hostile function never holds Space's own event loop. The
+ * function's `ov` calls come back to the main thread as messages; nothing else leaves the sandbox.
+ *
+ * main → worker { type: 'run', id, name, code, request, context, limits: { memory, deadlineMs, cpuMs } }
+ * worker → main { type: 'op', id, opId, op, args } main → worker { type: 'reply', id, opId, reply }
+ * worker → main { type: 'done', id, result: { out } | { failed } | { error }, logs }
+ */
+const { parentPort } = require('worker_threads');
+
+let quickjs = null;
+const engine = async () => (quickjs ||= await require('quickjs-emscripten').getQuickJS());
+const pendingOps = new Map(); // `${id}:${opId}` → (reply) => void
+
+const PRELUDE = String.raw`
+const __logs = [];
+const __line = (a) => a.map((x) => typeof x === 'string' ? x : (() => { try { return JSON.stringify(x); } catch { return String(x); } })()).join(' ').slice(0, 500);
+const __log = (level) => (...a) => { if (__logs.length < 50) __logs.push([level, __line(a)]); };
+globalThis.console = { log: __log('log'), info: __log('log'), warn: __log('warn'), error: __log('error') };
+const __call = async (op, args) => {
+ const r = JSON.parse(await __host(op, JSON.stringify(args === undefined ? {} : args)));
+ if (r.error) { const e = new Error(r.error.message); e.code = r.error.code; throw e; }
+ return r.result;
+};
+const __ctx = JSON.parse(__context);
+globalThis.ov = Object.freeze({
+ user: __ctx.user ? Object.freeze(__ctx.user) : null,
+ space: Object.freeze(__ctx.space),
+ items: Object.freeze({
+ list: (collection, o = {}) => __call('items.list', { collection, limit: o.limit, before: o.before }),
+ add: (collection, value) => __call('items.add', { collection, value }),
+ set: (collection, value) => __call('items.set', { collection, value }),
+ remove: (collection, id) => __call('items.remove', { collection, id: String(id) }),
+ }),
+ store: Object.freeze({
+ get: (key) => __call('store.get', { key }),
+ set: (key, value) => __call('store.set', { key, value }),
+ remove: (key) => __call('store.remove', { key }),
+ }),
+ ai: Object.freeze({ ask: (input) => __call('ai.ask', { input }) }),
+});
+`;
+
+function errText(e) {
+ if (!e) return 'unknown error';
+ if (typeof e === 'string') return e.slice(0, 300);
+ return `${e.name ? `${e.name}: ` : ''}${e.message || JSON.stringify(e)}`.slice(0, 300);
+}
+
+async function run({ id, name, code, request, context, limits }) {
+ const Q = await engine();
+ const runtime = Q.newRuntime();
+ runtime.setMemoryLimit(limits.memory);
+ runtime.setMaxStackSize(512 * 1024);
+ const deadline = Date.now() + limits.deadlineMs;
+ // CPU: the time spent inside QuickJS (evaluating, running jobs), apart from waiting on `ov` calls.
+ let cpu = 0;
+ let since = 0;
+ const enter = () => { since = Date.now(); };
+ const leave = () => { if (since) cpu += Date.now() - since; since = 0; };
+ let stopped = null;
+ runtime.setInterruptHandler(() => {
+ const t = Date.now();
+ if (t > deadline) stopped = 'time';
+ else if (since && cpu + (t - since) > limits.cpuMs) stopped = 'cpu';
+ return Boolean(stopped);
+ });
+ const vm = runtime.newContext();
+ const deferred = new Set();
+ let opSeq = 0;
+ const jobs = () => { enter(); try { runtime.executePendingJobs(); } finally { leave(); } };
+ try {
+ vm.newString(JSON.stringify(context)).consume((h) => vm.setProp(vm.global, '__context', h));
+ vm.newString(JSON.stringify(request)).consume((h) => vm.setProp(vm.global, '__request', h));
+ vm.newFunction('__host', (opHandle, argsHandle) => {
+ const op = vm.getString(opHandle);
+ const args = vm.getString(argsHandle);
+ const promise = vm.newPromise();
+ deferred.add(promise);
+ const opId = ++opSeq;
+ pendingOps.set(`${id}:${opId}`, (reply) => {
+ if (!vm.alive || !promise.alive) return;
+ vm.newString(reply).consume((h) => promise.resolve(h));
+ jobs();
+ });
+ parentPort.postMessage({ type: 'op', id, opId, op, args });
+ return promise.handle;
+ }).consume((h) => vm.setProp(vm.global, '__host', h));
+
+ enter();
+ let loaded;
+ try {
+ vm.unwrapResult(vm.evalCode(PRELUDE, 'prelude.js')).dispose();
+ loaded = vm.evalCode(code, `functions/${name}.js`);
+ } finally { leave(); }
+ if (loaded.error) {
+ const e = vm.dump(loaded.error); loaded.error.dispose();
+ return { error: stopped ? `ran out of ${stopped === 'cpu' ? 'CPU time' : 'time'} while loading` : `did not load: ${errText(e)}` };
+ }
+ loaded.value.dispose();
+ enter();
+ let started;
+ try {
+ started = vm.evalCode(`(async () => {
+ if (typeof handle !== 'function') throw new Error('it must define handle(request, ov)');
+ const out = await handle(JSON.parse(__request), ov);
+ return JSON.stringify({ out: out === undefined ? null : out, logs: __logs });
+})().catch((e) => JSON.stringify({ failed: String((e && e.message) || e), logs: __logs }))`, 'run.js');
+ } finally { leave(); }
+ const promiseHandle = vm.unwrapResult(started);
+ const settled = vm.resolvePromise(promiseHandle);
+ jobs();
+ let timer;
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - Date.now()) + 20); });
+ const out = await Promise.race([settled, timeout]);
+ clearTimeout(timer);
+ promiseHandle.dispose();
+ if (out === 'timeout' || stopped) {
+ if (out !== 'timeout' && out.error) out.error.dispose();
+ if (out !== 'timeout' && out.value) out.value.dispose();
+ return { error: `ran out of ${stopped === 'cpu' ? 'CPU time' : 'time'}` };
+ }
+ if (out.error) { const e = vm.dump(out.error); out.error.dispose(); return { error: `failed: ${errText(e)}` }; }
+ const raw = vm.getString(out.value); out.value.dispose();
+ const parsed = JSON.parse(raw);
+ return parsed.failed !== undefined ? { failed: parsed.failed, logs: parsed.logs } : { out: parsed.out, logs: parsed.logs };
+ } catch (err) {
+ const msg = String((err && err.message) || err);
+ if (stopped) return { error: `ran out of ${stopped === 'cpu' ? 'CPU time' : 'time'}` };
+ if (/out of memory|memory/i.test(msg)) return { error: 'ran out of memory' };
+ if (/stack overflow|too much recursion/i.test(msg)) return { error: 'recursed too deeply' };
+ return { error: `failed: ${msg.slice(0, 300)}` };
+ } finally {
+ for (const key of [...pendingOps.keys()]) if (key.startsWith(`${id}:`)) pendingOps.delete(key);
+ for (const p of deferred) { try { if (p.alive) p.dispose(); } catch { /* gone */ } }
+ try { vm.dispose(); } catch { /* a leak is not worth crashing the worker for */ }
+ try { runtime.dispose(); } catch { /* same */ }
+ }
+}
+
+parentPort.on('message', (msg) => {
+ if (msg.type === 'reply') {
+ const fn = pendingOps.get(`${msg.id}:${msg.opId}`);
+ if (fn) { pendingOps.delete(`${msg.id}:${msg.opId}`); fn(msg.reply); }
+ return;
+ }
+ if (msg.type === 'run') {
+ run(msg).then(
+ (result) => parentPort.postMessage({ type: 'done', id: msg.id, result }),
+ (err) => parentPort.postMessage({ type: 'done', id: msg.id, result: { error: `failed: ${String((err && err.message) || err).slice(0, 300)}` } }),
+ );
+ }
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
+'use strict';
+/**
+ * Space functions: a space's server-side code (plan T20 step 2, "dynamic pages"). A file functions/<name>.js defines
+ * `async function handle(request, ov)`; a published space serves it at https://<slug>.openvibe.website/api/<name>
+ * (GET or POST, the published deploy's copy), the editor's preview runs the unsaved copy as the maker, and the space SDK
+ * calls it with OV.fn(name, body).
+ *
+ * The code runs in QuickJS compiled to WebAssembly (quickjs-emscripten) on a small pool of worker threads
+ * (functions-worker.js), a fresh runtime per call: no network, no files, no Node, at most FN_MEMORY bytes, FN_CPU_MS of
+ * its own computing and FN_DEADLINE_MS in all; a worker that overstays is terminated and replaced. Its only way out is
+ * `ov`, answered here:
+ *
+ * ov.user the signed-in visitor ({ username, display_name }) or null
+ * ov.space { id, slug, name }
+ * ov.items.list|add|set|remove the space's shared lists; an add or set is the visitor's when they signed in and
+ * approved "data", otherwise the space's own; a function may remove any item
+ * ov.store.get|set|remove the space's own server-side storage (visitors never read it directly)
+ * ov.ai.ask the space's AI, for a signed-in visitor who approved "ai"
+ * console.log|warn|error kept with the call (the editor shows them)
+ *
+ * What handle returns becomes the response: a string (HTML when it starts with "<", else text), { status, headers,
+ * body } (only content-type and cache-control headers), any other value as JSON, nothing as 204.
+ */
+const path = require('path');
+const { Worker } = require('worker_threads');
+const { ApiError } = require('../http/errors');
+
+const NAME_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
+const FN_MEMORY = 32 * 1024 * 1024;
+const FN_CPU_MS = 1000;
+const FN_DEADLINE_MS = 5000;
+const MAX_SOURCE = 64 * 1024;
+const MAX_OPS = 100;
+const MAX_BODY = 256 * 1024;
+
+function createSpaceFunctions({ data, workers = 2, queueMax = 32, log = console, now = Date.now } = {}) {
+ const pool = [];
+ const queue = [];
+ let seq = 0;
+ let closed = false;
+
+ function spawn() {
+ const w = new Worker(path.join(__dirname, 'functions-worker.js'));
+ w.unref();
+ const slot = { w, job: null };
+ w.on('message', (msg) => onMessage(slot, msg));
+ w.on('error', (err) => { log.warn('[Spaces functions] worker failed:', err && err.message); retire(slot, 'the function runner stopped'); });
+ w.on('exit', () => retire(slot, 'the function runner stopped'));
+ pool.push(slot);
+ return slot;
+ }
+ function retire(slot, why) {
+ const i = pool.indexOf(slot);
+ if (i < 0) return;
+ pool.splice(i, 1);
+ if (slot.job) { const job = slot.job; slot.job = null; clearTimeout(job.kill); job.resolve({ error: why }); }
+ slot.w.terminate().catch(() => {});
+ pump();
+ }
+ function onMessage(slot, msg) {
+ const job = slot.job;
+ if (!job || msg.id !== job.id) return;
+ if (msg.type === 'op') {
+ if (++job.ops > MAX_OPS) return slot.w.postMessage({ type: 'reply', id: job.id, opId: msg.opId, reply: JSON.stringify({ error: { code: 'quota', message: `a call makes at most ${MAX_OPS} ov calls` } }) });
+ let args = {};
+ try { args = JSON.parse(msg.args); } catch { /* {} */ }
+ Promise.resolve()
+ .then(() => { const fn = job.ops_ && job.ops_[msg.op]; if (!fn) throw new ApiError(400, 'space.unknown_op', `unknown ov call ${String(msg.op).slice(0, 40)}`); return fn(args && typeof args === 'object' ? args : {}); })
+ .then((result) => ({ result: result === undefined ? null : result }), (err) => ({ error: { code: (err && err.code) || 'error', message: (err && err.message) || 'failed' } }))
+ .then((reply) => { if (slot.job === job) slot.w.postMessage({ type: 'reply', id: job.id, opId: msg.opId, reply: JSON.stringify(reply) }); });
+ return;
+ }
+ if (msg.type === 'done') {
+ slot.job = null;
+ clearTimeout(job.kill);
+ job.resolve(msg.result || { error: 'failed' });
+ pump();
+ }
+ }
+ function pump() {
+ if (closed) return;
+ while (queue.length) {
+ let slot = pool.find((s) => !s.job);
+ if (!slot && pool.length < workers) slot = spawn();
+ if (!slot) return;
+ const job = queue.shift();
+ slot.job = job;
+ // The worker stops the code itself at the deadline; this is the backstop if it cannot.
+ job.kill = setTimeout(() => { if (slot.job === job) retire(slot, `ran out of time (${FN_DEADLINE_MS / 1000} s)`); }, FN_DEADLINE_MS + 1500);
+ slot.w.postMessage({ type: 'run', id: job.id, name: job.name, code: job.code, request: job.request, context: job.context,
+ limits: { memory: FN_MEMORY, deadlineMs: FN_DEADLINE_MS, cpuMs: FN_CPU_MS } });
+ }
+ }
+ function submit(job) {
+ if (closed) return Promise.resolve({ error: 'the function runner is stopped' });
+ if (queue.length >= queueMax) return Promise.resolve({ busy: true });
+ return new Promise((resolve) => { queue.push({ ...job, id: ++seq, ops: 0, resolve }); pump(); });
+ }
+
+ // Calls per minute: each space, and each caller of a space.
+ const windows = new Map();
+ function throttle(key, max) {
+ const t = now();
+ const list = (windows.get(key) || []).filter((x) => t - x < 60000);
+ if (list.length >= max) throw new ApiError(429, 'quota', 'this space\'s functions are busy; wait a minute and try again');
+ list.push(t);
+ windows.set(key, list);
+ if (windows.size > 5000) for (const [k, v] of windows) if (!v.some((x) => t - x < 60000)) windows.delete(k);
+ }
+
+ /** The `ov` calls of one run: the space acts as itself unless the visitor signed in and approved. */
+ function opsFor(space, visitor) {
+ const self = { subject: `space:${space.id}`, username: space.slug, name: space.name };
+ const caps = visitor ? visitor.capabilities : [];
+ const writer = visitor && caps.includes('data') ? visitor.person : self;
+ return {
+ 'items.list': (a) => data.listItems(space, a.collection, { limit: a.limit, before: a.before }, visitor ? visitor.person : null),
+ 'items.add': (a) => data.addItem(space, writer, a.collection, a.value),
+ 'items.set': (a) => data.setItem(space, writer, a.collection, a.value),
+ 'items.remove': (a) => data.removeItem(space, writer, a.collection, a.id, true),
+ 'store.get': (a) => data.kvGet(space, self, a.key),
+ 'store.set': (a) => data.kvSet(space, self, a.key, a.value),
+ 'store.remove': (a) => data.kvRemove(space, self, a.key),
+ 'ai.ask': async (a) => {
+ if (!visitor) throw new ApiError(401, 'auth.required', 'the visitor has to sign in for the space\'s AI');
+ if (!caps.includes('ai')) throw new ApiError(403, 'space.capability', 'the visitor did not approve "ai" for this space');
+ return (await data.run('ai.ask', { input: a.input }, { space, person: visitor.person, capabilities: caps, owner: false })).text;
+ },
+ };
+ }
+
+ /**
+ * Run `code` (functions/<name>.js of `space`) for `request` ({ method, path, query, headers, body }). `visitor`:
+ * { person, capabilities } or null; `caller`: a key for the per-caller limit (an address, a person).
+ * → { status, headers, body (string), logs: [[level, line]] }
+ */
+ async function call(space, name, code, request, { visitor = null, caller = 'anon', perMinute = 120, perCallerPerMinute = 30 } = {}) {
+ if (!NAME_RE.test(String(name))) throw new ApiError(404, 'space.function_not_found', 'no such function');
+ if (typeof code !== 'string' || Buffer.byteLength(code) > MAX_SOURCE) throw new ApiError(413, 'space.function_too_large', 'a function is at most 64 KB');
+ throttle(`s:${space.id}`, perMinute);
+ throttle(`c:${space.id}:${caller}`, perCallerPerMinute);
+ const context = {
+ user: visitor ? { username: visitor.person.username, display_name: visitor.person.name || visitor.person.username } : null,
+ space: { id: space.id, slug: space.slug, name: space.name },
+ };
+ const r = await submit({ name, code, request, context, ops_: opsFor(space, visitor) });
+ const fail = (status, text, logs = []) => ({ status, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: text, logs });
+ if (r.busy) return fail(503, 'Space functions are busy right now; try again in a moment.');
+ if (r.error) {
+ const status = /time/.test(r.error) ? 504 : /memory|recursed/.test(r.error) ? 503 : 500;
+ return fail(status, `functions/${name}.js ${r.error}`, r.logs || []);
+ }
+ if (r.failed !== undefined) return fail(500, `functions/${name}.js failed: ${String(r.failed).slice(0, 300)}`, r.logs || []);
+ const out = toResponse(r.out);
+ if (Buffer.byteLength(out.body) > MAX_BODY) return fail(500, `functions/${name}.js answered more than ${MAX_BODY / 1024} KB`, r.logs || []);
+ return { ...out, logs: r.logs || [] };
+ }
+
+ async function close() {
+ closed = true;
+ for (const job of queue.splice(0)) job.resolve({ error: 'the function runner is stopped' });
+ await Promise.all(pool.splice(0).map((s) => { if (s.job) { clearTimeout(s.job.kill); s.job.resolve({ error: 'the function runner is stopped' }); } return s.w.terminate().catch(() => {}); }));
+ }
+
+ return { call, close, NAME_RE };
+}
+
+/** What handle returned → { status, headers, body (string) }. */
+function toResponse(out) {
+ const kind = (s) => (/^\s*</.test(s) ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8');
+ if (out === null || out === undefined) return { status: 204, headers: {}, body: '' };
+ if (typeof out === 'string') return { status: 200, headers: { 'content-type': kind(out) }, body: out };
+ if (typeof out === 'object' && !Array.isArray(out) && 'body' in out && ('status' in out || 'headers' in out)) {
+ const status = Number.isInteger(out.status) && out.status >= 200 && out.status <= 599 ? out.status : 200;
+ const headers = {};
+ for (const [k, v] of Object.entries(out.headers && typeof out.headers === 'object' ? out.headers : {})) {
+ const key = String(k).toLowerCase();
+ if ((key === 'content-type' || key === 'cache-control') && typeof v === 'string' && v.length < 200 && !/[\r\n]/.test(v)) headers[key] = v;
+ }
+ const body = out.body == null ? '' : typeof out.body === 'string' ? out.body : JSON.stringify(out.body);
+ if (!headers['content-type']) headers['content-type'] = typeof out.body === 'string' ? kind(out.body) : 'application/json; charset=utf-8';
+ return { status, headers, body };
+ }
+ return { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify(out) };
+}
+
+module.exports = { createSpaceFunctions, toResponse, NAME_RE, FN_DEADLINE_MS, FN_CPU_MS, MAX_SOURCE };
@@ -199,6 +199,22 @@ function createSpacesPages(ctx) {
@@ -199,6 +199,22 @@ function createSpacesPages(ctx) {
const space = await own(req);
const space = await own(req);
const b = req.body || {};
const b = req.body || {};
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.
+ if (b.op === 'fn.call') {
+ const a = b.args && typeof b.args === 'object' ? b.args : {};
+ const name = String(a.name || '');
+ if (!ctx.spaceFunctions.NAME_RE.test(name)) throw new ApiError(404, 'space.function_not_found', 'function names are 1-40 characters: a-z, 0-9 and "-"');
+ const saved = (await spaces.files(space)).find((f) => f.path === `functions/${name}.js`);
+ const source = typeof a.source === 'string' ? a.source : saved && saved.content;
+ if (typeof source !== 'string') throw new ApiError(404, 'space.function_not_found', `this space has no functions/${name}.js`);
+ const query = {};
+ if (a.query && typeof a.query === 'object') for (const [k, v] of Object.entries(a.query).slice(0, 50)) query[String(k).slice(0, 100)] = String(v).slice(0, 1000);
+ const request = { method: a.method === 'POST' ? 'POST' : 'GET', path: `/api/${name}`, query, headers: {}, body: a.body === undefined ? null : a.body };
+ return { result: await ctx.spaceFunctions.call(space, name, source, request, {
+ visitor: { person, capabilities: spaces.CAPABILITIES }, caller: person.subject,
+ perMinute: limits.fnCallsPerMinute, perCallerPerMinute: limits.fnCallsPerCallerPerMinute,
+ }) };
+ }
return { result: await data.run(String(b.op || ''), b.args || {}, { space, person, capabilities: spaces.CAPABILITIES, owner: true }) };
return { result: await data.run(String(b.op || ''), b.args || {}, { space, person, capabilities: spaces.CAPABILITIES, owner: true }) };
}));
}));
@@ -177,6 +177,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
@@ -177,6 +177,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
<section class="sp-ed-preview" aria-label="Preview">
<section class="sp-ed-preview" aria-label="Preview">
<div class="sp-ed-preview-bar"><span><i class="fa-solid fa-eye" aria-hidden="true"></i> Preview <span class="sp-muted">(runs as you)</span></span><button class="sp-btn sp-btn-quiet sp-btn-sm" id="ed-run" type="button"><i class="fa-solid fa-rotate-right" aria-hidden="true"></i> Run</button></div>
<div class="sp-ed-preview-bar"><span><i class="fa-solid fa-eye" aria-hidden="true"></i> Preview <span class="sp-muted">(runs as you)</span></span><button class="sp-btn sp-btn-quiet sp-btn-sm" id="ed-run" type="button"><i class="fa-solid fa-rotate-right" aria-hidden="true"></i> Run</button></div>
<iframe id="ed-frame" title="Preview of the space" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock"></iframe>
<iframe id="ed-frame" title="Preview of the space" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock"></iframe>
+ <details class="sp-ed-fnlog" id="ed-fnlog" hidden><summary><i class="fa-solid fa-terminal" aria-hidden="true"></i> Function log <span class="sp-muted" id="ed-fnlog-last"></span></summary><ol id="ed-fnlog-list"></ol></details>
</section>
</section>
</div>
</div>
<div class="sp-ed-building" id="ed-building" hidden><div><i class="fa-solid fa-wand-magic-sparkles fa-bounce" aria-hidden="true"></i><h2>OpenVibe AI is building…</h2><p id="ed-building-text"></p><p class="sp-muted">This usually takes under a minute or two. You can leave and come back.</p></div></div>
<div class="sp-ed-building" id="ed-building" hidden><div><i class="fa-solid fa-wand-magic-sparkles fa-bounce" aria-hidden="true"></i><h2>OpenVibe AI is building…</h2><p id="ed-building-text"></p><p class="sp-muted">This usually takes under a minute or two. You can leave and come back.</p></div></div>
@@ -10,6 +10,9 @@
@@ -10,6 +10,9 @@
* origin with no storage), so every call is a postMessage to the editor,
* origin with no storage), so every call is a postMessage to the editor,
* which runs it as the signed-in maker.
* which runs it as the signed-in maker.
*
*
+ * OV.fn(name, body) runs the space's functions/<name>.js (server/spaces/functions.js): at /api/<name> on the space's
+ * own address when live, through the editor (its unsaved copy) in the preview.
+ *
* The API is the one the AI builder is told about (OpenVibe.AI server/workflows/space.js SDK_REFERENCE).
* The API is the one the AI builder is told about (OpenVibe.AI server/workflows/space.js SDK_REFERENCE).
*/
*/
const SDK = String.raw`(function () {
const SDK = String.raw`(function () {
@@ -23,7 +26,7 @@ const SDK = String.raw`(function () {
@@ -23,7 +26,7 @@ const SDK = String.raw`(function () {
function fail(code, message) { var e = new Error(message || code); e.code = code; return e; }
function fail(code, message) { var e = new Error(message || code); e.code = code; return e; }
OV.on = function (name, fn) { if (name === 'user' && typeof fn === 'function') listeners.push(fn); return OV; };
OV.on = function (name, fn) { if (name === 'user' && typeof fn === 'function') listeners.push(fn); return OV; };
- var call;
+ var call, fnCall;
if (CFG.mode === 'preview') {
if (CFG.mode === 'preview') {
var seq = 0, waiting = {};
var seq = 0, waiting = {};
window.addEventListener('message', function (ev) {
window.addEventListener('message', function (ev) {
@@ -38,6 +41,10 @@ const SDK = String.raw`(function () {
@@ -38,6 +41,10 @@ const SDK = String.raw`(function () {
window.parent.postMessage({ ovSpacePreview: 1, id: id, op: op, args: args || {} }, '*');
window.parent.postMessage({ ovSpacePreview: 1, id: id, op: op, args: args || {} }, '*');
});
});
};
};
+ fnCall = function (name, method, body, query) {
+ return call('fn.call', { name: name, method: method, body: method === 'POST' ? body : undefined, query: query })
+ .then(function (r) { return fnResult(r.status, r.headers && r.headers['content-type'], r.body); });
+ };
OV.signIn = function () { return call('me').then(function (u) { setUser(u); return u; }); };
OV.signIn = function () { return call('me').then(function (u) { setUser(u); return u; }); };
OV.signOut = function () { setUser(null); return Promise.resolve(); };
OV.signOut = function () { setUser(null); return Promise.resolve(); };
OV.ready = call('me').then(function (u) { setUser(u); return u; }, function () { return null; });
OV.ready = call('me').then(function (u) { setUser(u); return u; }, function () { return null; });
@@ -63,6 +70,15 @@ const SDK = String.raw`(function () {
@@ -63,6 +70,15 @@ const SDK = String.raw`(function () {
}); }, function () { throw fail('network', 'Could not reach OpenVibe. Check the connection and try again.'); });
}); }, function () { throw fail('network', 'Could not reach OpenVibe. Check the connection and try again.'); });
}
}
call = function (op, args) { return api('/run', 'POST', { op: op, args: args || {} }).then(function (j) { return j.result; }); };
call = function (op, args) { return api('/run', 'POST', { op: op, args: args || {} }).then(function (j) { return j.result; }); };
+ // The space's own functions answer on its own address (/api/<name>), with the visitor's token when signed in.
+ fnCall = function (name, method, body, query) {
+ var headers = {}; var t = token(); if (t) headers.Authorization = 'Bearer ' + t;
+ if (method === 'POST') headers['Content-Type'] = 'application/json';
+ var qs = query ? '?' + new URLSearchParams(query).toString() : '';
+ return fetch('/api/' + name + qs, { method: method, headers: headers, body: method === 'POST' ? JSON.stringify(body === undefined ? null : body) : undefined, credentials: 'omit' })
+ .then(function (r) { return r.text().then(function (text) { return fnResult(r.status, r.headers.get('content-type'), text); }); },
+ function () { throw fail('network', 'Could not reach the space. Check the connection and try again.'); });
+ };
OV.signIn = function () {
OV.signIn = function () {
var verifier = random(), state = random();
var verifier = random(), state = random();
store.set(':verifier', verifier); store.set(':state', state);
store.set(':verifier', verifier); store.set(':state', state);
@@ -108,6 +124,22 @@ const SDK = String.raw`(function () {
@@ -108,6 +124,22 @@ const SDK = String.raw`(function () {
set: guard(function (k, value) { return call('kv.set', { key: named('key', k), value: value }); }),
set: guard(function (k, value) { return call('kv.set', { key: named('key', k), value: value }); }),
remove: guard(function (k) { return call('kv.remove', { key: named('key', k) }); }),
remove: guard(function (k) { return call('kv.remove', { key: named('key', k) }); }),
};
};
+ function fnResult(status, type, text) {
+ var value = text;
+ if (/application\/json/i.test(type || '')) { try { value = JSON.parse(text); } catch (e) { /* keep the text */ } }
+ if (status >= 400) {
+ var e = fail(status === 429 ? 'quota' : 'fn.failed', typeof value === 'string' && value ? value.slice(0, 300) : 'The function failed (' + status + ')');
+ e.status = status; e.body = value; throw e;
+ }
+ return status === 204 ? null : value;
+ }
+ // OV.fn(name[, body][, { method, query }]): run functions/<name>.js on the server (POST when there is a body).
+ OV.fn = guard(function (name, body, o) {
+ if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(name)) throw fail('space.invalid_name', 'function names are 1-40 characters: a-z, 0-9 and "-"');
+ o = o || {};
+ var method = String(o.method || (body === undefined ? 'GET' : 'POST')).toUpperCase() === 'POST' ? 'POST' : 'GET';
+ return fnCall(name, method, body, o.query || null);
+ });
OV.ai = { ask: function (input) { return call('ai.ask', { input: input }).then(function (r) { return r.text; }); } };
OV.ai = { ask: function (input) { return call('ai.ask', { input: input }).then(function (r) { return r.text; }); } };
OV.me = function () { return call('me'); };
OV.me = function () { return call('me'); };
window.OV = OV;
window.OV = OV;
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
/**
/**
* The starters a space can begin from (templates/spaces/*.html). Each is one self-contained index.html built on the
* The starters a space can begin from (templates/spaces/*.html). Each is one self-contained index.html built on the
* space SDK, so it doubles as an example of every SDK feature: shared lists (guestbook, poll, leaderboard), private
* space SDK, so it doubles as an example of every SDK feature: shared lists (guestbook, poll, leaderboard), private
- * storage (to-dos, the AI buddy's history) and OpenVibe AI (the AI buddy).
+ * storage (to-dos, the AI buddy's history), OpenVibe AI (the AI buddy) and server functions (trivia's hidden answers, in
+ * templates/spaces/trivia.check.js, which a new space gets as functions/check.js).
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -14,6 +15,7 @@ const TEMPLATES = [
@@ -14,6 +15,7 @@ const TEMPLATES = [
instructions: 'You are a friendly, curious and slightly funny companion. Keep answers short and warm, ask a question back now and then, and never pretend to be a person.' },
instructions: 'You are a friendly, curious and slightly funny companion. Keep answers short and warm, ask a question back now and then, and never pretend to be a person.' },
{ id: 'poll', name: 'Quick poll', icon: 'fa-square-poll-vertical', accent: '#059669', description: 'One question, a few answers, one vote each, live results.', capabilities: ['profile', 'data'] },
{ id: 'poll', name: 'Quick poll', icon: 'fa-square-poll-vertical', accent: '#059669', description: 'One question, a few answers, one vote each, live results.', capabilities: ['profile', 'data'] },
{ id: 'reaction', name: 'Reaction game', icon: 'fa-bolt', accent: '#16a34a', description: 'Click when it turns green; best times go on a leaderboard.', capabilities: ['profile', 'data'] },
{ id: 'reaction', name: 'Reaction game', icon: 'fa-bolt', accent: '#16a34a', description: 'Click when it turns green; best times go on a leaderboard.', capabilities: ['profile', 'data'] },
+ { id: 'trivia', name: 'Trivia', icon: 'fa-circle-question', accent: '#d97706', description: 'Questions whose answers stay on the server (a space function), with how many got each right.', capabilities: [], functions: ['check'] },
{ id: 'todo', name: 'Private to-dos', icon: 'fa-list-check', accent: '#ca8a04', description: 'A to-do list saved to each visitor\'s own account.', capabilities: ['profile', 'kv'] },
{ id: 'todo', name: 'Private to-dos', icon: 'fa-list-check', accent: '#ca8a04', description: 'A to-do list saved to each visitor\'s own account.', capabilities: ['profile', 'kv'] },
{ id: 'blank', name: 'Blank', icon: 'fa-file', accent: '#7c3aed', description: 'An empty page with sign-in, to build from scratch.', capabilities: ['profile'] },
{ id: 'blank', name: 'Blank', icon: 'fa-file', accent: '#7c3aed', description: 'An empty page with sign-in, to build from scratch.', capabilities: ['profile'] },
];
];
@@ -25,8 +27,14 @@ function get(id) { return TEMPLATES.find((t) => t.id === id) || null; }
@@ -25,8 +27,14 @@ function get(id) { return TEMPLATES.find((t) => t.id === id) || null; }
function files(id) {
function files(id) {
const t = get(id);
const t = get(id);
if (!t) return null;
if (!t) return null;
- if (!cache.has(id)) cache.set(id, fs.readFileSync(path.join(DIR, `${id}.html`), 'utf8'));
- return [{ path: 'index.html', content: cache.get(id) }];
+ if (!cache.has(id)) {
+ cache.set(id, [
+ { path: 'index.html', content: fs.readFileSync(path.join(DIR, `${id}.html`), 'utf8') },
+ // Its server functions: templates/spaces/<id>.<name>.js → functions/<name>.js.
+ ...(t.functions || []).map((fn) => ({ path: `functions/${fn}.js`, content: fs.readFileSync(path.join(DIR, `${id}.${fn}.js`), 'utf8') })),
+ ]);
+ }
+ return cache.get(id).map((f) => ({ ...f }));
}
}
module.exports = { list, get, files };
module.exports = { list, get, files };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+// functions/check.js runs on the server, so the answers below never reach a visitor's browser (try View Source).
+// The page asks with OV.fn('check', { id, answer }) and gets back { right, solved }.
+const ANSWERS = {
+ sun: ['mercury'],
+ moon: ['1969'],
+ ocean: ['pacific', 'pacific ocean', 'the pacific'],
+ bones: ['206'],
+ hearts: ['3', 'three'],
+};
+
+async function handle(request, ov) {
+ if (request.method !== 'POST') return { questions: Object.keys(ANSWERS).length };
+ const { id, answer } = request.body || {};
+ if (!Object.prototype.hasOwnProperty.call(ANSWERS, id)) return { status: 400, body: { error: 'unknown question' } };
+ const guess = String(answer || '').trim().toLowerCase().replace(/\s+/g, ' ');
+ const right = ANSWERS[id].includes(guess);
+ // How many right answers each question has had, kept in the space's own server storage.
+ const key = 'solved-' + id;
+ let solved = (await ov.store.get(key)) || 0;
+ if (right) { solved += 1; await ov.store.set(key, solved); }
+ console.log(id, right ? 'right' : 'wrong');
+ return { right, solved };
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Trivia</title>
+<style>
+ :root { --bg: #0f1020; --panel: #181a30; --ink: #f4f4fb; --muted: #a3a6c4; --line: #2a2d4a; --accent: #f59e0b; --right: #22c55e; --wrong: #f87171; }
+ * { box-sizing: border-box; }
+ body { margin: 0; background: radial-gradient(1200px 500px at 50% -200px, #2a2150, var(--bg)); color: var(--ink); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; min-height: 100vh; }
+ main { max-width: 640px; margin: 0 auto; padding: 28px 16px 56px; }
+ header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; margin-bottom: 18px; }
+ h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.01em; }
+ .sub { margin: 4px 0 0; color: var(--muted); }
+ .score { flex: none; text-align: center; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 8px 14px; }
+ .score b { display: block; font-size: 1.5rem; color: var(--accent); font-variant-numeric: tabular-nums; line-height: 1.1; }
+ .score span { font-size: .8rem; color: var(--muted); }
+ ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; counter-reset: q; }
+ li { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 16px; counter-increment: q; transition: border-color .2s; }
+ li.right { border-color: color-mix(in srgb, var(--right) 60%, var(--line)); }
+ .q { margin: 0 0 10px; font-weight: 650; }
+ .q::before { content: counter(q) ". "; color: var(--accent); }
+ form { display: flex; gap: 8px; }
+ input { flex: 1; min-width: 0; border: 1px solid var(--line); background: #0d0e1c; color: inherit; border-radius: 12px; padding: 10px 12px; font: inherit; }
+ input:focus-visible, button:focus-visible { outline: 3px solid #818cf8; outline-offset: 2px; }
+ button { border: 0; border-radius: 12px; padding: 10px 16px; font: 650 1rem/1.2 inherit; background: var(--accent); color: #1a1205; cursor: pointer; }
+ button:disabled { opacity: .6; cursor: default; }
+ .result { margin: 8px 0 0; font-size: .92rem; color: var(--muted); }
+ .result:empty { display: none; }
+ .result.right { color: var(--right); } .result.wrong { color: var(--wrong); }
+ footer { margin-top: 22px; color: var(--muted); font-size: .88rem; text-align: center; }
+</style>
+</head>
+<body>
+<main>
+ <header>
+ <div><h1>Trivia</h1><p class="sub">Five questions. The answers are kept on the server, so no peeking.</p></div>
+ <div class="score" aria-live="polite"><b id="score">0/5</b><span>right</span></div>
+ </header>
+ <ol id="questions"></ol>
+ <footer>Made on OpenVibe.Space. Edit the questions in index.html and the answers in functions/check.js.</footer>
+</main>
+<script src="/_openvibe/sdk.js"></script>
+<script>
+ const QUESTIONS = [
+ { id: 'sun', text: 'Which planet is closest to the Sun?' },
+ { id: 'moon', text: 'In what year did people first walk on the Moon?' },
+ { id: 'ocean', text: 'What is the largest ocean on Earth?' },
+ { id: 'bones', text: 'How many bones are in an adult human body?' },
+ { id: 'hearts', text: 'How many hearts does an octopus have?' },
+ ];
+ const solved = new Set();
+ const list = document.getElementById('questions');
+ const score = () => { document.getElementById('score').textContent = solved.size + '/' + QUESTIONS.length; };
+
+ for (const q of QUESTIONS) {
+ const li = document.createElement('li');
+ const p = document.createElement('p'); p.className = 'q'; p.textContent = q.text;
+ const form = document.createElement('form');
+ const input = document.createElement('input'); input.placeholder = 'Your answer'; input.autocomplete = 'off'; input.setAttribute('aria-label', q.text);
+ const button = document.createElement('button'); button.type = 'submit'; button.textContent = 'Check';
+ const result = document.createElement('p'); result.className = 'result'; result.setAttribute('aria-live', 'polite');
+ form.append(input, button);
+ li.append(p, form, result);
+ list.append(li);
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ if (!input.value.trim()) return input.focus();
+ button.disabled = true;
+ try {
+ const r = await OV.fn('check', { id: q.id, answer: input.value });
+ if (r.right) {
+ solved.add(q.id); score();
+ li.classList.add('right'); input.disabled = true;
+ result.className = 'result right';
+ result.textContent = 'Right! ' + (r.solved === 1 ? 'You are the first to get it.' : r.solved + ' right answers so far.');
+ return;
+ }
+ result.className = 'result wrong';
+ result.textContent = 'Not quite. Try again.';
+ button.disabled = false; input.select();
+ } catch (err) {
+ result.className = 'result wrong';
+ result.textContent = 'Could not check: ' + err.message;
+ button.disabled = false;
+ }
+ });
+ }
+ score();
+</script>
+</body>
+</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+'use strict';
+/**
+ * Space functions (server/spaces/functions.js): a space's functions/<name>.js runs on the server in a QuickJS sandbox.
+ * The runner answers JSON, HTML and full responses (only content-type and cache-control headers pass), stops a
+ * runaway loop on its CPU budget and a stuck promise on its deadline, exposes no Node, network or files, and caps the
+ * `ov` calls of one run and the calls per caller. The editor's preview runs the unsaved copy as the maker; a published
+ * space serves the published copy at /api/<name> (GET and POST, JSON and form bodies) and never serves functions/ as
+ * files; a signed-in visitor's token reaches the function as ov.user and their approved writes; the space's own store
+ * is apart from every visitor's.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./stageb/boot');
+const { createSpaceFunctions, toResponse } = require('../server/spaces/functions');
+
+const ORIGIN = 'https://openvibe.space';
+const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+
+(async () => {
+ // ── The runner alone, over a fake store ──
+ const kv = new Map();
+ const fake = {
+ listItems: async () => [],
+ addItem: async (space, person, collection, value) => ({ id: '1', value, author: { username: person.username } }),
+ kvGet: async (space, person, key) => (kv.has(`${person.subject}/${key}`) ? kv.get(`${person.subject}/${key}`) : null),
+ kvSet: async (space, person, key, value) => { kv.set(`${person.subject}/${key}`, value); return true; },
+ };
+ const fns = createSpaceFunctions({ data: fake, workers: 1 });
+ const space = { id: 'spc_T', slug: 'demo', name: 'Demo' };
+ const req = { method: 'GET', path: '/api/x', query: {}, headers: {}, body: null };
+ const run = (code, o = {}) => fns.call(space, 'x', code, req, { caller: o.caller || `c${Math.random()}`, ...o });
+
+ await check('the runner answers JSON, HTML and full responses, keeping only content-type and cache-control', async () => {
+ const json = await run('async function handle(r, ov) { const n = (await ov.store.get("n")) || 0; await ov.store.set("n", n + 1); console.log("n is", n); return { n: n + 1 }; }');
+ assert.deepStrictEqual([json.status, json.headers['content-type'], json.body, json.logs], [200, 'application/json; charset=utf-8', '{"n":1}', [['log', 'n is 0']]]);
+ assert.strictEqual(kv.get('space:spc_T/n'), 1, 'the space\'s own store');
+ assert.deepStrictEqual([(await run('function handle() { return "<h1>hi</h1>"; }')).headers['content-type']], ['text/html; charset=utf-8']);
+ const full = await run('function handle() { return { status: 201, headers: { "Content-Type": "text/csv", "Set-Cookie": "a=1", "Cache-Control": "public, max-age=60" }, body: "a,b" }; }');
+ assert.deepStrictEqual([full.status, full.headers, full.body], [201, { 'content-type': 'text/csv', 'cache-control': 'public, max-age=60' }, 'a,b']);
+ assert.strictEqual((await run('function handle() {}')).status, 204);
+ assert.deepStrictEqual(toResponse({ status: 99, body: { a: 1 } }), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' }, body: '{"a":1}' });
+ });
+
+ await check('a runaway loop stops on its CPU budget, a stuck promise on its deadline; nothing of Node is reachable', async () => {
+ let t0 = Date.now();
+ const loop = await run('function handle() { while (true) {} }');
+ assert.deepStrictEqual([loop.status, /CPU time/.test(loop.body)], [504, true]);
+ assert.ok(Date.now() - t0 < 3000, `the loop stopped in ${Date.now() - t0} ms`);
+ t0 = Date.now();
+ const stuck = await run('async function handle() { await new Promise(() => {}); }');
+ assert.deepStrictEqual([stuck.status, /ran out of time/.test(stuck.body)], [504, true]);
+ assert.ok(Date.now() - t0 < 7000);
+ const escape = await run('function handle() { return [typeof require, typeof process, typeof fetch, typeof XMLHttpRequest, typeof setTimeout, typeof WebAssembly].join(); }');
+ assert.strictEqual(escape.body, 'undefined,undefined,undefined,undefined,undefined,undefined');
+ assert.match((await run('function handle( {')).body, /did not load: SyntaxError/);
+ assert.match((await run('const x = 1;')).body, /must define handle/);
+ assert.match((await run('function f() { return f() + 1; } function handle() { return f(); }')).body, /stack overflow|recursed/);
+ assert.strictEqual((await run('function handle() { return "still fine"; }')).body, 'still fine', 'the worker survives all of it');
+ });
+
+ await check('one run makes at most 100 ov calls; a caller makes a limited number of calls a minute', async () => {
+ const many = await run('async function handle(r, ov) { for (let i = 0; i < 150; i++) await ov.store.get("k"); return "done"; }');
+ assert.deepStrictEqual([many.status, /at most 100 ov calls/.test(many.body)], [500, true]);
+ await run('function handle() { return 1; }', { caller: 'same', perCallerPerMinute: 2 });
+ await run('function handle() { return 1; }', { caller: 'same', perCallerPerMinute: 2 });
+ await assert.rejects(run('function handle() { return 1; }', { caller: 'same', perCallerPerMinute: 2 }), (e) => e.status === 429);
+ await assert.rejects(fns.call(space, 'Bad Name', 'function handle() {}', req), (e) => e.status === 404);
+ });
+ await fns.close();
+
+ // ── In Space: the preview, the published space, a signed-in visitor ──
+ const t = await boot();
+ const { csrfToken } = require('../server/auth/forms');
+ const alice = t.user('alice'), bob = t.user('bob');
+ const csrf = (u) => csrfToken(t.config, { subject: u.subject });
+ const form = (u, path, fields) => t.api('POST', path, { session: u, form: { csrf: csrf(u), ...fields }, headers: { origin: ORIGIN } });
+ const edit = (u, method, path, json) => t.api(method, path, { session: u, json, headers: { origin: ORIGIN, 'x-csrf-token': csrf(u) } });
+ let slug, host, spaceRow;
+ try {
+ await check('a space made from the trivia starter has its function; the preview runs it (and the unsaved copy) as the maker', async () => {
+ const r = await form(alice, '/spaces/new', { template: 'trivia' });
+ slug = /^\/spaces\/([^/]+)\/edit$/.exec(r.headers.location)[1];
+ host = `${slug}.openvibe.website`;
+ spaceRow = await t.ctx.spaces.getBySlug(slug);
+ assert.deepStrictEqual((await t.ctx.spaces.files(spaceRow)).map((f) => f.path).sort(), ['functions/check.js', 'index.html']);
+ const fn = (args) => edit(alice, 'POST', `/spaces/${slug}/preview`, { op: 'fn.call', args });
+ const right = (await fn({ name: 'check', method: 'POST', body: { id: 'sun', answer: ' Mercury ' } })).json().result;
+ assert.deepStrictEqual([right.status, JSON.parse(right.body), right.logs], [200, { right: true, solved: 1 }, [['log', 'sun right']]]);
+ const draft = (await fn({ name: 'check', source: 'function handle(r, ov) { return { who: ov.user.username, x: r.query.x, m: r.method }; }', query: { x: 1 } })).json().result;
+ assert.deepStrictEqual(JSON.parse(draft.body), { who: 'alice', x: '1', m: 'GET' });
+ assert.strictEqual((await fn({ name: 'missing' })).json().error.code, 'space.function_not_found');
+ assert.strictEqual((await edit(bob, 'POST', `/spaces/${slug}/preview`, { op: 'fn.call', args: { name: 'check' } })).status, 404, 'only the maker (a draft is hidden from others)');
+ });
+
+ await check('published, /api/<name> runs the published copy for GET and POST; functions/ is never served as a file', async () => {
+ assert.strictEqual((await edit(alice, 'POST', `/spaces/${slug}/publish`)).status, 200);
+ const get = await t.get(host, '/api/check');
+ assert.deepStrictEqual([get.status, get.json(), get.headers['cache-control']], [200, { questions: 5 }, 'private, no-store']);
+ assert.match(get.headers['content-security-policy'], /connect-src 'self'/, 'the app policy');
+ const post = (body, headers = { 'content-type': 'application/json' }) => t.get(host, '/api/check', { method: 'POST', body, headers });
+ assert.deepStrictEqual((await post(JSON.stringify({ id: 'moon', answer: '1969' }))).json(), { right: true, solved: 1 });
+ assert.deepStrictEqual((await post(JSON.stringify({ id: 'moon', answer: '1970' }))).json(), { right: false, solved: 1 });
+ assert.deepStrictEqual((await post('id=ocean&answer=Pacific', { 'content-type': 'application/x-www-form-urlencoded' })).json(), { right: true, solved: 1 });
+ assert.strictEqual((await post('{nope')).status, 400);
+ assert.strictEqual((await post(JSON.stringify({ x: 'a'.repeat(70 * 1024) }))).status, 413);
+ assert.strictEqual((await post(JSON.stringify({ id: 'nope' }))).status, 400, 'the function\'s own status');
+ for (const p of ['/functions/check.js', '/function%73/check.js', '/functions/', '/functions']) {
+ const f = await t.get(host, p);
+ assert.strictEqual(f.status, 404, p);
+ assert.ok(!f.text.includes('mercury'), `${p} does not leak the answers`);
+ }
+ assert.strictEqual((await t.get(host, '/api/nope')).status, 404);
+ assert.strictEqual((await t.get(host, '/api/nope', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } })).status, 404);
+ assert.strictEqual((await t.get(host, '/api/check', { method: 'PUT', body: '{}' })).status, 405);
+ assert.strictEqual((await t.get(host, '/index.html', { method: 'POST', body: '{}' })).status, 405, 'the rest stays static');
+ // A saved but unpublished change does not run until it is published.
+ await edit(alice, 'PUT', `/spaces/${slug}/files`, { files: [...(await t.ctx.spaces.files(spaceRow)).filter((f) => f.path !== 'functions/check.js'), { path: 'functions/check.js', content: 'function handle() { return "draft"; }' }] });
+ assert.deepStrictEqual((await t.get(host, '/api/check')).json(), { questions: 5 });
+ });
+
+ await check('a signed-in visitor reaches the function as ov.user and their approved writes; the space\'s store is its own', async () => {
+ await edit(alice, 'PUT', `/spaces/${slug}/settings`, { capabilities: ['profile', 'data', 'kv'] });
+ const files = (await t.ctx.spaces.files(spaceRow)).filter((f) => !f.path.startsWith('functions/'));
+ await edit(alice, 'PUT', `/spaces/${slug}/files`, { files: [...files,
+ { path: 'functions/check.js', content: 'function handle() { return { questions: 5 }; }' },
+ { path: 'functions/whoami.js', content: 'async function handle(r, ov) { const item = r.method === "POST" ? await ov.items.add("notes", { text: r.body.text }) : null; return { user: ov.user, by: item && item.author.username, secret: await ov.store.get("solved-moon") }; }' },
+ { path: 'functions/cached.js', content: 'function handle() { return { status: 200, headers: { "cache-control": "public, max-age=60" }, body: "<p>hello</p>" }; }' },
+ ] });
+ assert.strictEqual((await edit(alice, 'POST', `/spaces/${slug}/publish`)).status, 200);
+ const verifier = b64url(crypto.randomBytes(32));
+ const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
+ const r = await form(bob, `/spaces/${slug}/connect`, { challenge, state: 'st4te-abc', return: '/', decision: 'allow' });
+ const code = /#ov_code=([A-Za-z0-9_-]+)/.exec(r.headers.location)[1];
+ const token = (await t.api('POST', `/api/v1/apps/${spaceRow.id}/token`, { json: { code, code_verifier: verifier }, headers: { origin: `https://${host}` } })).json().token;
+ const anon = (await t.get(host, '/api/whoami')).json();
+ assert.deepStrictEqual(anon, { user: null, by: null, secret: 1 }, 'the function reads the space\'s store');
+ const signed = await t.get(host, '/api/whoami', { method: 'POST', body: JSON.stringify({ text: 'hi' }), headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` } });
+ assert.deepStrictEqual(signed.json(), { user: { username: 'bob', display_name: 'bob' }, by: 'bob', secret: 1 });
+ const anonPost = await t.get(host, '/api/whoami', { method: 'POST', body: JSON.stringify({ text: 'anon' }), headers: { 'content-type': 'application/json' } });
+ assert.strictEqual(anonPost.json().by, slug, 'signed out, the space writes as itself');
+ const visitorKv = await t.api('POST', `/api/v1/apps/${spaceRow.id}/run`, { json: { op: 'kv.get', args: { key: 'solved-moon' } }, headers: { origin: `https://${host}`, authorization: `Bearer ${token}` } });
+ assert.strictEqual(visitorKv.json().result, null, 'a visitor never reads the space\'s store');
+ const cached = await t.get(host, '/api/cached');
+ assert.deepStrictEqual([cached.headers['cache-control'], cached.headers['content-type'], cached.text], ['public, max-age=60', 'text/html; charset=utf-8', '<p>hello</p>']);
+ const cachedSigned = await t.get(host, '/api/cached', { headers: { authorization: `Bearer ${token}` } });
+ assert.strictEqual(cachedSigned.headers['cache-control'], 'private, no-store', 'never cached for a signed-in visitor');
+ });
+
+ await check('the trivia starter\'s function compiles', async () => {
+ const tpl = require('../server/spaces/templates').files('trivia');
+ new Function(tpl.find((f) => f.path === 'functions/check.js').content);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });