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)

OP OpenVibers committed 28 days ago · f11b5e83a3 · 20 files, +949 −22

Changes

ddiff --git a/README.md b/README.md
iindex 1e970b9..3da51cb 100644
---- a/README.md
++++ b/README.md
@@@ -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.
**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
`SPACE_PUBLIC_HOSTING=on`.
ddiff --git a/deploy/nginx/openvibe.website.conf b/deploy/nginx/openvibe.website.conf
iindex c7fb47a..62138ad 100644
---- a/deploy/nginx/openvibe.website.conf
++++ b/deploy/nginx/openvibe.website.conf
@@@ -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;
ddiff --git a/docs/spaces.md b/docs/spaces.md
iindex b7709f9..9311e15 100644
---- a/docs/spaces.md
++++ b/docs/spaces.md
@@@ -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-64
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
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`:
- 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. |
ddiff --git a/package-lock.json b/package-lock.json
iindex 068c4d5..4a97db6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 4fa8fe6..14e7a05 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/public/css/spaces.css b/public/css/spaces.css
iindex 5fe9351..9140836 100644
---- a/public/css/spaces.css
++++ b/public/css/spaces.css
@@@ -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; }
ddiff --git a/public/js/space-editor.js b/public/js/space-editor.js
iindex b442902..3e4db08 100644
---- a/public/js/space-editor.js
++++ b/public/js/space-editor.js
@@@ -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();
ddiff --git a/server/hosting-config.js b/server/hosting-config.js
iindex 892d503..b8d952a 100644
---- a/server/hosting-config.js
++++ b/server/hosting-config.js
@@@ -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'),
ddiff --git a/server/hosting.js b/server/hosting.js
iindex 75b3cad..1e4989b 100644
---- a/server/hosting.js
++++ b/server/hosting.js
@@@ -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.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') {
ddiff --git a/server/http/tenant.js b/server/http/tenant.js
iindex efbb7e3..b84765c 100644
---- a/server/http/tenant.js
++++ b/server/http/tenant.js
@@@ -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, siteConfig
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). */
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, siteConfig
res.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, siteConfig
const 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, siteConfig
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.
res.statusCode = 301;
res.setHeader('Location', `${rawPath}/${search}`);
ddiff --git a/server/spaces/data.js b/server/spaces/data.js
iindex 2020864..50c498b 100644
---- a/server/spaces/data.js
++++ b/server/spaces/data.js
@@@ -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 };
ddiff --git a/server/spaces/functions-worker.js b/server/spaces/functions-worker.js
nnew file mode 100644
iindex 0000000..a2bf924
---- /dev/null
++++ b/server/spaces/functions-worker.js
@@@ -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)}` } }),
++ );
++ }
++});
ddiff --git a/server/spaces/functions.js b/server/spaces/functions.js
nnew file mode 100644
iindex 0000000..ce88db0
---- /dev/null
++++ b/server/spaces/functions.js
@@@ -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 };
ddiff --git a/server/spaces/pages.js b/server/spaces/pages.js
iindex 39045cd..5c7b167 100644
---- a/server/spaces/pages.js
++++ b/server/spaces/pages.js
@@@ -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 }) };
}));
ddiff --git a/server/spaces/render.js b/server/spaces/render.js
iindex bbe8e0c..4a4a0f4 100644
---- a/server/spaces/render.js
++++ b/server/spaces/render.js
@@@ -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>
ddiff --git a/server/spaces/sdk.js b/server/spaces/sdk.js
iindex 2c981a1..a611a95 100644
---- a/server/spaces/sdk.js
++++ b/server/spaces/sdk.js
@@@ -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;
ddiff --git a/server/spaces/templates.js b/server/spaces/templates.js
iindex 6ff661c..e9c693d 100644
---- a/server/spaces/templates.js
++++ b/server/spaces/templates.js
@@@ -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 };
ddiff --git a/templates/spaces/trivia.check.js b/templates/spaces/trivia.check.js
nnew file mode 100644
iindex 0000000..b98c10e
---- /dev/null
++++ b/templates/spaces/trivia.check.js
@@@ -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 };
++}
ddiff --git a/templates/spaces/trivia.html b/templates/spaces/trivia.html
nnew file mode 100644
iindex 0000000..97c9738
---- /dev/null
++++ b/templates/spaces/trivia.html
@@@ -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>
ddiff --git a/test/space-functions.test.js b/test/space-functions.test.js
nnew file mode 100644
iindex 0000000..87f533e
---- /dev/null
++++ b/test/space-functions.test.js
@@@ -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); });