openvibers/openvibe.design Commits
The Design Arena: three pinned AI designers, blind votes at phone and desktop size, Elo per category, an open dataset (#4)
Changes
@@@ -23,3 +23,32 @@ OV_OAUTH_CLIENT_SECRET=# optional: defaults to true in production# COOKIE_SECURE=true++++# The Arena's PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).++# In development without DATABASE_URL an embedded PGlite database in data/pglite is used.++DATABASE_URL=++DATABASE_DIRECT_URL=++# DESIGN_PGLITE_DIR=++++# OpenVibe.AI: the generate operation, called as Design with a client-credentials token.++# Network grants scope ai.run.create (audience openvibe.ai, namespace ai.generate).++OV_AI_INTERNAL_URL=http://127.0.0.1:4700++# DESIGN_AI_AUDIENCE=openvibe.ai++# DESIGN_AI_SCOPE=ai.run.create++# DESIGN_AI_WAIT_MS=55000++# DESIGN_AI_TIMEOUT_MS=120000++# DESIGN_AI_POLL_MS=1500++# Three explicit provider:model slots; unset pins AI's generate-route offers (fewer than three falls back to its pool).++# DESIGN_ARENA_MODELS=shared:gpt-5-mini,local:qwen2.5++++# Arena budgets (per UTC day) and generation jobs.++# DESIGN_ARENA_PER_DAY=3++# DESIGN_ARENA_DAILY=20++# DESIGN_ARENA_JOB_INTERVAL_MS=1500++# The drill flag keeps the generation loop off; a test or a drill calls tick() itself.++# DESIGN_DRILL=0++++# OpenVibe.Events → this service for ADR-033 account export/deletion; unset turns the route off (503).++# DESIGN_EVENTS_SECRET=++# DESIGN_EVENTS_URL=++# DESIGN_EVENTS_ENDPOINT=@@@ -2,20 +2,22 @@> Make it yours, find the best AI designer.--**Status:** v1, the theme studio (v0.1.0, see [STATUS.json](STATUS.json)).++**Status:** v2, the theme studio and the Design Arena (v0.1.0, see [STATUS.json](STATUS.json)).**Domain:** `openvibe.design` · **Port:** 5090 · **Service id:** `design` · **Env prefix:** `DESIGN`**License:** AGPL-3.0-only (the same as every OpenVibe service).OpenVibe.Design is the Design room: the theme studio where a person browses OpenVibe's themes, tries them onpreviews, edits one with contrast checks, uses it, submits it to the gallery, and rolls back to any earlier--setting. Theme selection stays in OpenVibe.Network; this service signs the person in and proxies their calls to--the Network's theme API.++setting, and the Design Arena where one prompt goes to three AI models, two outputs are judged blind at phone and++desktop sizes, and an Elo leaderboard per category ranks the designers with the cost and time of every generation.++Theme selection stays in OpenVibe.Network; this service signs the person in and proxies their calls to the++Network's theme API.## What it is--- **Identity:** service id `design`, name OpenVibe.Design, port 5090, host 127.0.0.1. It stores no accounts, no-- database and no tokens: the person's Network session lives in httpOnly cookies and their theme lives in-- OpenVibe.Network.++- **Identity:** service id `design`, name OpenVibe.Design, port 5090, host 127.0.0.1. It stores no accounts and++ no tokens: the person's Network session lives in httpOnly cookies and their theme lives in OpenVibe.Network. The++ Arena's rounds, outputs, votes and Elo ratings live in this service's PostgreSQL database (PGlite in development).- **Sign-in:** an OAuth 2 client of OpenVibe.Network (`design`), authorization code with PKCE S256, a state checkand a server-side code exchange.- **The theme API proxy:** every theme read and write goes to OpenVibe.Network's `/api/themes`, with the person's@@@ -34,6 +36,30 @@ the Network's theme API.- **Use, share, roll back:** *Use* is `PUT /api/me/theme` (source `studio`), *Share it in the gallery* submits thetheme for review, and *History* lists the person's earlier looks with a one-tap *Restore*.++## The Design Arena++++- **A round:** a signed-in person writes one prompt in one of six categories (landing, dashboard, mobile app, game++ UI, logo, slides). It is queued; the job loop reads the offers on OpenVibe.AI's `generate` routing and pins three++ distinct ones (`route: { provider, model }`), and each output stores the HTML (sanitized), the provider and model AI++ reports, its cost, its measured time and its tokens. When AI offers fewer than three, the remaining slots go to its++ pool and the round says so (`pool_fallback: true`).++- **Budgets:** `DESIGN_ARENA_PER_DAY` (default 3) rounds per person per UTC day and `DESIGN_ARENA_DAILY` (default 20)++ for the whole site. A refusal says which budget was reached and when to come back.++- **Safety:** the model's page is text until it is rendered, and then only inside `<iframe sandbox srcdoc>` with no++ `allow-scripts` and no `allow-same-origin`. A CSP meta in the srcdoc blocks every network fetch++ (`default-src 'none'; style-src 'unsafe-inline'; img-src data:`). Scripts, `on*` handlers and `javascript:` URLs++ are stripped before storage, and an output is capped at 200 KB.++- **Voting:** `/arena/vote` serves two successful outputs in random order with the model names hidden. One vote per++ person per round; the round's creator cannot vote. A vote reveals both models and updates the category's Elo++ ratings (K = 32, a tie is 0.5, `both_bad` is a game for neither).++- **Leaderboard:** per category: model, Elo, games, wins, losses, ties, win rate, median cost and median time.++- **Open dataset:** `GET /api/v1/arena/dataset.jsonl` is one JSON object per round with its prompt, every output++ (metadata and HTML) and every vote without a voter id.++- **AI access:** Design is a first-party service and calls AI with a client-credentials token from Network:++ audience `openvibe.ai`, scope `ai.run.create` (and the `ai.generate` namespace in the grant). It pins one of that++ routing's listed offers per slot; AI refuses anything it does not offer in words, and a pin never bypasses its++ quotas or budgets. Generation is off under `DESIGN_DRILL=1`; the job loop starts with the process otherwise.++## Pages| Route | What it is |@@@ -41,6 +67,10 @@ the Network's theme API.| `/` | the theme catalogue's front door || `/studio` | the theme studio; includes `/js/studio.js` and `/css/design.css` || `/t/:slug` | one public theme (Network 404 → the 404 page) |++| `/arena` | the Arena leaderboard per category and the way to start a round |++| `/arena/new` | the start-a-round form (works without JavaScript) |++| `/arena/vote` | the next blind pair, and the reveal after a vote |++| `/arena/r/:id` | one round: every output at 390 px and 1280 px, with cost and time || `/developers` | the API, for people and agents || anything else | the 404 page |@@@ -50,26 +80,34 @@ JavaScript. A page rendered for a signed-in person is `private, no-store`; a pub## API--Everything is proxied to `config.networkUrl` (`OV_NETWORK_INTERNAL_URL`, default `http://127.0.0.1:4000`) under--`/api/themes`. Errors are RFC 9457 `application/problem+json`. Reads are public; writes need the session and this--site's own `Origin` header.++The theme surface is proxied to `config.networkUrl` (`OV_NETWORK_INTERNAL_URL`, default `http://127.0.0.1:4000`)++under `/api/themes`; the Arena's own data is under `/api/v1/arena`. Errors are RFC 9457++`application/problem+json`. Arena reads are public; writes need the session and a same-origin signal++(`Sec-Fetch-Site: same-origin`, else an `Origin` equal to the site). A missing signal is refused on a cookie write;++a Bearer write is not cookie-authenticated and is unaffected.| Route | Auth | What it does ||---|---|---|| `GET /api/themes` | none | the public catalogue; cached in memory 5 minutes || `GET /api/themes/:idOrSlug` | none | one public theme || `GET /api/me/theme` | cookie | Network `GET /api/themes/me/active` → `{ theme_id, theme, custom_variables }`; 401 when signed out |--| `PUT /api/me/theme` | cookie + Origin | Network `PUT /api/themes/me` with `{ theme_id, custom_variables, source: "studio" }` |++| `PUT /api/me/theme` | cookie + same-origin | Network `PUT /api/themes/me` with `{ theme_id, custom_variables, source: "studio" }` || `GET /api/me/theme/history` | cookie | Network `GET /api/themes/me/history` |--| `POST /api/me/theme/history/:id/restore` | cookie + Origin | Network's restore; `id` must be a positive integer, else 400 |--| `POST /api/themes/submit` | cookie + Origin | Network `POST /api/themes` with `{ name, slug, description, mode, variables, tags }` |++| `POST /api/me/theme/history/:id/restore` | cookie + same-origin | Network's restore; `id` must be a positive integer, else 400 |++| `POST /api/themes/submit` | cookie + same-origin | Network `POST /api/themes` with `{ name, slug, description, mode, variables, tags }` |++| `POST /api/v1/arena/rounds` | cookie + same-origin | queue a round `{ category, prompt }` (subject to the two daily budgets) |++| `GET /api/v1/arena/rounds/:id` | none | one round with its outputs, cost and time |++| `GET /api/v1/arena/vote` | cookie | the next blind pair for this person (models hidden) |++| `POST /api/v1/arena/vote` | cookie + same-origin | `{ round_id, left_output, right_output, winner }`; reveals both models and updates Elo |++| `GET /api/v1/arena/leaderboard?category=…` | none | Elo, games, win rate, median cost and median time |++| `GET /api/v1/arena/dataset.jsonl` | none | prompts, outputs (HTML included) and votes without voters |- A Network **4xx** answer passes through with its status and body; a Network failure (no answer, timeout, 5xx)is a `502 network.unavailable` problem.--- Every write (`PUT`, `POST`) whose `Origin` is not this site's own origin is refused with `403 design.cross_site`.++- A cookie-authenticated write (`PUT`, `POST`) without `Sec-Fetch-Site: same-origin` or this site's `Origin` is refused with `403 design.cross_site`; a Bearer write is unaffected.- Everything under `/api/me/*` carries `Cache-Control: private, no-store`.--Plumbing: `GET /api/health`, `GET /api/ready` (200 `ready` or `degraded`; Network, JWKS and the OAuth client are++Plumbing: `GET /api/health`, `GET /api/ready` (the database is required; Network, JWKS and the OAuth client areoptional checks), `GET /release.json`, `POST /release-metrics`, `/shared/*` (the pinned Frame), `/css/*` and`/js/*` (content-hashed `?v=` → immutable), `GET /robots.txt`, `GET /sitemap.xml` (the public themes),`GET /llms.txt` and `GET /metrics` (loopback only).@@@ -1,11 +1,11 @@{"repository": "OpenVibers/OpenVibe.Design","version": "0.1.0",-- "stage": "v1",++ "stage": "v2","code": true,"wave": "W20",-- "plan": "OpenVibe.Design: the Design room's theme studio. Browse OpenVibe's themes, try them on previews, edit one with contrast checks, use it, submit it to the gallery, and roll back to any earlier setting. Theme selection stays in OpenVibe.Network; this service signs the person in and proxies their calls.",-- "runtime": "node 22, express 4, openvibe-shared/shell; stateless (no database, no Valkey, no Events)",++ "plan": "OpenVibe.Design: the Design room's theme studio. Browse OpenVibe's themes, try them on previews, edit one with contrast checks, use it, submit it to the gallery, and roll back to any earlier setting. Theme selection stays in OpenVibe.Network; this service signs the person in and proxies their calls. v2 adds the Design Arena: one prompt, three AI models, blind votes and an Elo leaderboard per category, with cost and time per generation and an open dataset.",++ "runtime": "node 22, express 4, openvibe-shared/shell; PostgreSQL (or embedded PGlite in development), OpenVibe.AI generate jobs, OpenVibe.Events account data in, no Valkey","port": "5090","domain": "openvibe.design","contracts": "openvibe-contracts v0.227.0",@@@ -16,7 +16,7 @@"openvibe-sdk v0.70.0","openvibe-shared v4.33.0"],-- "summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore.",++ "summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.","works": ["Sign-in with OpenVibe.Network: OAuth 2 authorization code, PKCE S256, state check, server-side exchange, httpOnly design_at/design_rt cookies, sanitizeNext (own origins only, no control characters or backslashes)","The theme API proxy: GET /api/themes (5-minute in-memory cache), GET /api/themes/:idOrSlug, GET/PUT /api/me/theme, GET /api/me/theme/history, POST /api/me/theme/history/:id/restore, POST /api/themes/submit",@@@ -25,7 +25,14 @@"Crawl artifacts (robots.txt, sitemap.xml with the public themes, llms.txt), /api/health, /api/ready (degraded when Network does not answer), /release.json, /release-metrics, /shared/*","Helmet with the network's CSP, per-IP 120/min on /api and /auth, content-hashed /css and /js, graceful stop","Deploy files: openvibe-design.service, nginx vhost for openvibe.design on 127.0.0.1:5090",-- "Tests: sanitizeNext, the login redirect and callback, /auth/me, the proxy (cache, 401, CSRF, pass-through, down), the pages and the crawl artifacts, health and readiness"++ "Tests: sanitizeNext, the login redirect and callback, /auth/me, the proxy (cache, 401, CSRF, pass-through, down), the pages and the crawl artifacts, health and readiness",++ "The Design Arena API: POST/GET /api/v1/arena/rounds, GET /vote and POST /vote, GET /leaderboard?category=\u2026, GET /dataset.jsonl",++ "Arena generation: a job loop off under DESIGN_DRILL, AI generate with a client-credentials token (audience openvibe.ai, scope ai.run.create), three slots from AI's chat route, cost/time/tokens stored, per-person and whole-site daily budgets",++ "Output safety: scripts, on* handlers and javascript: URLs stripped, 200 KB cap, storage as text, rendering only in <iframe sandbox srcdoc> with a CSP meta that blocks every network fetch",++ "Blind voting and Elo: two outputs in random order with models hidden, creator cannot vote, one vote per person per round, K=32, tie=0.5, both_bad counts for neither",++ "Arena pages: /arena (leaderboard and start), /arena/new, /arena/vote, /arena/r/:id with 390 px and 1280 px frames",++ "ADR-033 account export/deletion for a person's rounds, outputs and votes through /internal/events",++ "Tests: the migration, the generation job, the sanitizer and iframe markup, daily limits, blind pairs, one vote, creator refusal, Elo win/tie/both_bad, leaderboard numbers, dataset privacy and account export/erase"],"notYet": ["The pages' design and public/js/studio.js: the main session writes them; today the views are plain placeholders and design.css and studio.js are one-comment files",@@@ -44,7 +51,12 @@"server-rendered pages","crawl artifacts","limits and readiness",-- "deploy files"++ "deploy files",++ "Design Arena",++ "Arena generation jobs",++ "Blind voting and Elo",++ "Open Arena dataset",++ "Account export/deletion"],"updated": "2026-10-28"}nnew file mode 100644@@@ -0,0 +1,77 @@++-- phase: expand++-- OpenVibe.Design's Arena (plan 2026-10-28 §5.2 v2): one prompt, several models, blind votes and an Elo++-- leaderboard per category. The model's page is stored as text and rendered only in a sandboxed iframe; no++-- column here is a secret. Votes keep the output ids exactly as the voter saw them, never a person's name in++-- the open dataset.++++CREATE TABLE arena_rounds (++ id text COLLATE "C" PRIMARY KEY, -- arr_ + ULID++ category text COLLATE "C" NOT NULL, -- landing | dashboard | mobile_app | game_ui | logo | slides++ prompt text NOT NULL,++ created_by text COLLATE "C" NOT NULL, -- usr_…; the person who started the round++ status text COLLATE "C" NOT NULL DEFAULT 'queued', -- queued | generating | ready | failed++ offers jsonb NOT NULL DEFAULT '[]'::jsonb, -- the {provider, model} slots this round asked AI for++ pool_fallback boolean NOT NULL DEFAULT false, -- true when fewer than three offers were listed++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT arena_rounds_category CHECK (category IN ('landing', 'dashboard', 'mobile_app', 'game_ui', 'logo', 'slides')),++ CONSTRAINT arena_rounds_status CHECK (status IN ('queued', 'generating', 'ready', 'failed'))++);++CREATE INDEX arena_rounds_status_created ON arena_rounds (status, created_at);++CREATE INDEX arena_rounds_by_creator ON arena_rounds (created_by, created_at);++CREATE INDEX arena_rounds_ready_by_category ON arena_rounds (category, created_at) WHERE status = 'ready';++++CREATE TABLE arena_outputs (++ id text COLLATE "C" PRIMARY KEY, -- aro_ + ULID++ round text COLLATE "C" NOT NULL REFERENCES arena_rounds (id) ON DELETE CASCADE,++ model text COLLATE "C" NOT NULL, -- the model AI reports, e.g. gpt-5-mini++ provider text COLLATE "C", -- the provider AI reports, e.g. shared++ html text NOT NULL DEFAULT '', -- sanitized, at most 200 KB++ cost_usd numeric(12, 6) NOT NULL DEFAULT 0, -- what the run reported, in USD++ ms integer NOT NULL DEFAULT 0, -- the generation's measured time++ tokens integer NOT NULL DEFAULT 0,++ error text,++ ui_check jsonb, -- Vision's checks, when it has run++ created_at timestamptz NOT NULL DEFAULT now()++);++CREATE INDEX arena_outputs_by_round ON arena_outputs (round);++CREATE INDEX arena_outputs_by_model ON arena_outputs (model);++++CREATE TABLE arena_votes (++ round text COLLATE "C" NOT NULL REFERENCES arena_rounds (id) ON DELETE CASCADE,++ voter_subject text COLLATE "C" NOT NULL, -- usr_…; never exported or shown++ left_output text COLLATE "C" NOT NULL REFERENCES arena_outputs (id) ON DELETE CASCADE,++ right_output text COLLATE "C" NOT NULL REFERENCES arena_outputs (id) ON DELETE CASCADE,++ winner text COLLATE "C" NOT NULL, -- left | right | tie | both_bad++ at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (round, voter_subject),++ CONSTRAINT arena_votes_winner CHECK (winner IN ('left', 'right', 'tie', 'both_bad')),++ CONSTRAINT arena_votes_distinct_outputs CHECK (left_output <> right_output)++);++CREATE INDEX arena_votes_by_round ON arena_votes (round);++CREATE INDEX arena_votes_by_voter ON arena_votes (voter_subject, at);++++CREATE TABLE arena_ratings (++ category text COLLATE "C" NOT NULL,++ model text COLLATE "C" NOT NULL,++ elo numeric(8, 2) NOT NULL DEFAULT 1000,++ games integer NOT NULL DEFAULT 0,++ wins integer NOT NULL DEFAULT 0,++ losses integer NOT NULL DEFAULT 0,++ ties integer NOT NULL DEFAULT 0,++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (category, model),++ CONSTRAINT arena_ratings_category CHECK (category IN ('landing', 'dashboard', 'mobile_app', 'game_ui', 'logo', 'slides')),++ CONSTRAINT arena_ratings_counters CHECK (games >= 0 AND wins >= 0 AND losses >= 0 AND ties >= 0)++);++CREATE INDEX arena_ratings_order ON arena_ratings (category, elo DESC, games DESC);++++-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033): a redelivered export or deletion++-- changes nothing. It holds an export_id or deletion_id, the subject and the counts sent to Network.++CREATE TABLE account_data_events (++ id text PRIMARY KEY,++ kind text NOT NULL,++ subject text NOT NULL,++ outcome jsonb,++ applied_at timestamptz NOT NULL DEFAULT now(),++ sent_at timestamptz++);@@@ -14,14 +14,25 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.227.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "pg": "^8.23.1"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.13"},"engines": {"node": ">=22.0.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.13",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",++ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",++ "devOptional": true,++ "license": "Apache-2.0"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -784,9 +795,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.227.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.227.0",-- "integrity": "sha512-5B3PUpcN2+WGq/8tEai+fpTznza8PcPTTYKkmhkx8Vpn/nRuQV1DupZwAciSzGbZufgdaNI/28b4AVLtylwrAA==",++ "version": "0.236.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",++ "integrity": "sha512-SPq+/t6ohdwW2DPqK9nZmtbeFex7q6KN/eQVc19MTQ30Df/m7A/idixZGcfs4I3Z43pUpLN8I4ylfOLlFShqsA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -854,6 +865,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/proxy-addr": {"version": "2.0.8","resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",@@@ -1081,6 +1220,15 @@"url": "https://github.com/sponsors/ljharb"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1138,6 +1286,15 @@"engines": {"node": ">= 0.8"}++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -2,7 +2,7 @@"name": "openvibe-design","version": "0.1.0","private": true,-- "description": "OpenVibe.Design \u2014 Make it yours, find the best AI designer.",++ "description": "OpenVibe.Design — Make it yours, find the best AI designer.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -20,11 +20,15 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.227.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "pg": "^8.23.1"},"engines": {"node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.13"}}@@@ -143,3 +143,109 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.ds-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }@media (prefers-reduced-motion: reduce) { .ds-orb-ring { animation: none; } .ds-swatch, .ds-previews { transition: none; } }html[data-ov-motion=reduced] .ds-orb-ring { animation: none; }++++/* ── The Design Arena (plan 2026-10-28 §5.2 v2) ──────────────────────────────────────────────────────────────++ Server-rendered placeholders for the main session's design. The generated page only ever appears inside an++ iframe, so nothing here can affect the Arena's own layout. Phone first: a pair stacks; from 1000 px it is two++ columns, and a desktop shot is scaled down rather than widening the page. */++.ar-page { padding-top: clamp(18px, 4vw, 40px); }++.ar-hero { max-width: 900px; padding-bottom: 18px; }++.ar-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }++.ar-hero h1, .ar-page-h h1 { margin: 0; font-size: clamp(1.7rem, 4.5vw, 3rem); line-height: 1.06; letter-spacing: -.035em; }++.ar-lede { margin: 10px 0 0; max-width: 780px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }++.ar-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 18px 0 0; }++.ar-fine { color: var(--text-muted, #7386a3); font-size: .84rem; line-height: 1.5; }++.ar-notice { margin: 14px 0; padding: 12px 14px; border: 1px solid var(--border, #26344d); border-radius: 12px; background: var(--bg-card, #0f1729); }++.ar-status { min-height: 1.4em; color: var(--text-secondary, #96a7c2); }++.ar-meta { color: var(--text-secondary, #96a7c2); }++.ar-boards, .ar-open { margin-top: 30px; }++.ar-lb { margin-top: 22px; }++.ar-lb-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 14px; }++.ar-lb-head h2 { margin: 0; font-size: 1.05rem; }++.ar-more { font-weight: 700; font-size: .88rem; color: var(--room-strong); text-decoration: none; }++.ar-table { width: 100%; border-collapse: collapse; margin-top: 8px; font-variant-numeric: tabular-nums; }++.ar-table th, .ar-table td { padding: 9px 8px; text-align: left; border-bottom: 1px solid var(--border, #26344d); white-space: nowrap; }++.ar-table th { font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.ar-table .ar-lb-model { font-weight: 700; }++.ar-table .ar-lb-elo { font-weight: 800; color: var(--room-strong); }++.ar-lb-empty td, .ar-empty { color: var(--text-muted, #7386a3); }++.ar-form { display: grid; gap: 14px; max-width: 760px; margin-top: 18px; }++.ar-form label { display: grid; gap: 6px; font-weight: 700; font-size: .9rem; }++.ar-form select, .ar-form textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border, #26344d); background: var(--bg-card, #0f1729); color: inherit; font: inherit; }++.ar-form textarea { resize: vertical; line-height: 1.5; }++.ar-vote-pair { display: grid; gap: 18px; margin-top: 18px; }++.ar-vote-side { min-width: 0; padding: 12px; border: 1px solid var(--border, #26344d); border-radius: 14px; background: var(--bg-card, #0f1729); }++.ar-vote-side h2 { margin: 0 0 8px; font-size: .82rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.ar-output { padding: 14px; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); min-width: 0; }++.ar-output-h { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 14px; margin-bottom: 10px; }++.ar-output-h h2 { margin: 0; font-size: 1rem; }++.ar-output-error { padding: 12px; border-radius: 10px; background: color-mix(in srgb, #b42318 18%, transparent); color: var(--text-primary, #e6edf7); }++.ar-shot-label { margin: 4px 0 0; font-size: .74rem; color: var(--text-muted, #7386a3); }++.ar-reveal { display: grid; gap: 14px; margin-top: 18px; }++@media (min-width: 760px) { .ar-reveal { grid-template-columns: 1fr 1fr; } }++.ar-reveal-side { padding: 14px; border: 1px solid var(--border, #26344d); border-radius: 14px; background: var(--bg-card, #0f1729); }++.ar-reveal-side h2 { margin: 0 0 6px; font-size: .8rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++++/* Arena design pass. A pair is compared one viewport at a time (the switch); arena.js scales each 1280 or 390 px++ frame to the width it has (--s), and without it a desktop shot is a fixed half-size and a phone 0.6. */++.ar-view { display: inline-flex; gap: 2px; margin: 18px 0 0; padding: 3px; border: 1px solid var(--border, #26344d); border-radius: 999px; background: var(--bg-card, #0f1729); }++.ar-view input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }++.ar-view label { min-height: 34px; display: inline-flex; align-items: center; padding: 0 16px; border-radius: 999px; font-weight: 800; font-size: .84rem; color: var(--text-secondary, #96a7c2); cursor: pointer; }++.ar-view input:checked + label { background: color-mix(in srgb, var(--room-strong) 20%, transparent); color: var(--text-primary, #e6edf7); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--room-strong) 45%, transparent); }++.ar-view input:focus-visible + label { outline: 2px solid var(--room-strong); outline-offset: 2px; }++.ar-page:has(#ar-view-phone:checked) .ar-shot--desktop, .ar-page:has(#ar-view-desktop:checked) .ar-shot--phone { display: none; }++++.ar-shot { margin: 0; min-width: 0; }++.ar-shot + .ar-shot { margin-top: 16px; }++.ar-shot-frame { --s: .5; position: relative; overflow: hidden; background: #fff; }++.ar-shot-frame .ar-frame { position: absolute; top: 0; left: 0; display: block; border: 0; background: #fff; transform: scale(var(--s)); transform-origin: top left; }++.ar-shot-label { margin: 6px 0 0; font-size: .72rem; letter-spacing: .04em; color: var(--text-muted, #7386a3); }++/* Desktop: a browser window, the title bar drawn in CSS */++.ar-shot--desktop::before { content: ''; display: block; height: 22px; border: 1px solid var(--border, #26344d); border-bottom: 0; border-radius: 10px 10px 0 0;++ background: radial-gradient(circle at 13px 50%, #ff5f57 3.5px, transparent 4px), radial-gradient(circle at 26px 50%, #febc2e 3.5px, transparent 4px), radial-gradient(circle at 39px 50%, #28c840 3.5px, transparent 4px), var(--bg-elevated, var(--bg-secondary, #16213a)); }++.ar-shot--desktop .ar-shot-frame { width: min(100%, 640px); aspect-ratio: 1280 / 800; border: 1px solid var(--border, #26344d); border-top: 0; border-radius: 0 0 10px 10px; }++.ar-shot--desktop::before { width: min(100%, 640px); box-sizing: border-box; }++html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::before { width: 100%; }++/* Phone: a handset, centred in its column */++.ar-shot--phone { display: grid; justify-items: center; }++.ar-shot--phone .ar-shot-frame { --s: .6; box-sizing: content-box; width: calc(390px * var(--s)); aspect-ratio: 390 / 844; border: 7px solid #0b1020; border-radius: calc(46px * var(--s) + 7px);++ box-shadow: 0 0 0 1px var(--border, #26344d), 0 22px 44px -28px rgba(0, 0, 0, .7); }++++/* Vote: two sides; the side a vote button names lights up while it is hovered or focused */++.ar-vote-pair { display: grid; gap: 14px; margin-top: 14px; }++@media (min-width: 900px) { .ar-vote-pair { grid-template-columns: 1fr 1fr; align-items: start; gap: 18px; } }++.ar-page:has(#ar-view-phone:checked) .ar-vote-pair { grid-template-columns: 1fr 1fr; gap: 10px; }++.ar-vote-side { transition: border-color .15s, box-shadow .15s; }++.ar-vote-side h2 { display: flex; align-items: center; gap: 8px; }++#ar-vote-left h2::before, #ar-vote-right h2::before { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px; background: color-mix(in srgb, var(--room-strong) 18%, transparent); color: var(--room-strong); font-size: .78rem; }++#ar-vote-left h2::before { content: 'L'; }++#ar-vote-right h2::before { content: 'R'; }++.ar-page:has(.ar-vote-btn[data-winner=left]:is(:hover, :focus-visible)) #ar-vote-left,++.ar-page:has(.ar-vote-btn[data-winner=right]:is(:hover, :focus-visible)) #ar-vote-right,++.ar-page:has(.ar-vote-btn[data-winner=tie]:is(:hover, :focus-visible)) .ar-vote-side { border-color: var(--room-strong); box-shadow: 0 0 0 1px var(--room-strong); }++.ar-page:has(.ar-vote-btn[data-winner=both_bad]:is(:hover, :focus-visible)) .ar-vote-side { border-color: color-mix(in srgb, #ef4444 60%, var(--border, #26344d)); }++/* The verdict stays in reach while the pair scrolls past (above the phone app bar) */++.ar-vote-actions { position: sticky; bottom: var(--ovnav-appbar-h, 0px); z-index: 3; display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin: 16px 0 0; padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0px));++ border-top: 1px solid var(--border, #26344d); background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 88%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }++@media (max-width: 759px) { .ar-vote-actions { display: grid; grid-template-columns: 1fr 1fr; } .ar-vote-actions .ovk-btn { justify-content: center; width: 100%; } }++++/* Round: the outputs side by side where they fit */++.ar-outputs { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); align-items: start; }++.ar-output-h h2 { overflow-wrap: anywhere; }++++/* Leaderboards: one card per category, numbers right-aligned, the leader in the room colour */++.ar-lb { padding: 14px 16px 4px; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); overflow-x: auto; }++.ar-boards > .ar-lb-head h2 { font-size: clamp(1.15rem, 2.4vw, 1.45rem); }++.ar-table tr:last-child td { border-bottom: 0; }++.ar-table th:nth-child(n+3), .ar-table td:nth-child(n+3) { text-align: right; }++.ar-table .ar-lb-rank { width: 2em; font-weight: 800; color: var(--text-muted, #7386a3); }++.ar-table .ar-lb-row:first-child .ar-lb-rank, .ar-table .ar-lb-row:first-child .ar-lb-model { color: var(--room-strong); }++.ar-table .ar-lb-row:hover td { background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); }++.ar-boards > p.ar-fine { margin-top: 12px; }++.ar-open { padding: 18px; border: 1px dashed var(--border, #26344d); border-radius: 16px; }++.ar-open h2 { margin: 0; }++++/* Forms */++.ar-form select:focus-visible, .ar-form textarea:focus-visible { outline: 2px solid var(--room-strong); outline-offset: 1px; }++.ar-form textarea::placeholder { color: var(--text-muted, #7386a3); }++@media (prefers-reduced-motion: reduce) { .ar-vote-side { transition: none; } }nnew file mode 100644@@@ -0,0 +1,111 @@++/**++ * The Arena's small in-browser conveniences. The pages work without this file: /arena/new is a real form that++ * posts to the page, /arena/vote is a real form that posts the winner, and every iframe is already sandboxed.++ *++ * With it, the vote and create forms stay on the page (fetch + JSON), a refusal is shown in words, and a round++ * that is still generating refreshes itself. Nothing from a model is ever inserted into this document.++ */++(function () {++ 'use strict';++ const $ = (id) => document.getElementById(id);++ const status = (el, text) => { if (el) el.textContent = text; };++++ async function postJson(url, body) {++ const res = await fetch(url, {++ method: 'POST',++ headers: { 'content-type': 'application/json', accept: 'application/json' },++ body: JSON.stringify(body),++ credentials: 'same-origin',++ });++ const json = await res.json().catch(() => null);++ if (!res.ok) {++ const detail = json && (json.detail || json.title || json.code);++ throw new Error(detail || `The request failed (${res.status}).`);++ }++ return json;++ }++++ const vote = $('ar-vote-form');++ if (vote) {++ vote.addEventListener('submit', async (event) => {++ const button = event.submitter;++ if (!button || !button.dataset.winner) return;++ event.preventDefault();++ const out = $('ar-vote-status');++ status(out, 'Recording your vote…');++ try {++ const data = await postJson('/api/v1/arena/vote', {++ round_id: $('ar-vote-round').value,++ left_output: $('ar-vote-left-id').value,++ right_output: $('ar-vote-right-id').value,++ winner: button.dataset.winner,++ });++ const left = data.revealed.left;++ const right = data.revealed.right;++ const reveal = document.createElement('div');++ reveal.id = 'ar-reveal';++ reveal.className = 'ar-reveal';++ for (const [side, o] of [['Left', left], ['Right', right]]) {++ const box = document.createElement('div');++ box.className = 'ar-reveal-side';++ const h = document.createElement('h2');++ h.textContent = side;++ const p = document.createElement('p');++ const b = document.createElement('b');++ b.textContent = o.model;++ p.append(b, document.createTextNode(o.provider ? ` · ${o.provider}` : ''));++ const meta = document.createElement('p');++ meta.className = 'ar-fine';++ meta.textContent = `$${Number(o.cost_usd || 0).toFixed(4)} · ${o.ms} ms · ${o.tokens} tokens`;++ box.append(h, p, meta);++ reveal.append(box);++ }++ vote.replaceWith(reveal);++ status(out, `Vote recorded: ${data.vote.winner === 'both_bad' ? 'both bad' : data.vote.winner}. The models are revealed above.`);++ } catch (err) {++ status(out, err.message || 'The vote could not be recorded. Try again.');++ }++ });++ }++++ const create = $('ar-new-form');++ if (create) {++ create.addEventListener('submit', async (event) => {++ if (create.getAttribute('data-json') === '0') return;++ event.preventDefault();++ const out = $('ar-new-status');++ status(out, 'Starting the round…');++ try {++ const data = await postJson('/api/v1/arena/rounds', {++ category: $('ar-category').value,++ prompt: $('ar-prompt').value,++ });++ window.location.assign(`/arena/r/${encodeURIComponent(data.round.id)}`);++ } catch (err) {++ status(out, err.message || 'The round could not be started. Try again.');++ }++ });++ }++++ const round = $('ar-round');++ if (round) {++ const state = ($('ar-round-meta') || {}).textContent || '';++ if (/Status:\s*(queued|generating)/i.test(state)) setTimeout(() => window.location.reload(), 4000);++ }++++ // Fit every generated frame to the column it sits in: a desktop frame fills the width, a phone stays a handset.++ var shots = document.querySelectorAll('.ar-shot');++ if (shots.length && 'ResizeObserver' in window) {++ document.documentElement.classList.add('ar-fit');++ var ro = new ResizeObserver(function (entries) {++ entries.forEach(function (entry) {++ var shot = entry.target, frame = shot.querySelector('.ar-shot-frame'), iframe = shot.querySelector('iframe');++ var w = entry.contentRect.width, base = Number(iframe && iframe.getAttribute('width')) || 1280;++ if (!frame || !w) return;++ var s = shot.classList.contains('ar-shot--phone') ? Math.min(0.72, (w - 14) / base) : w / base;++ frame.style.setProperty('--s', String(Math.max(0.12, s)));++ });++ });++ shots.forEach(function (shot) { ro.observe(shot); });++ }++})();@@@ -1,17 +1,19 @@'use strict';/**-- * OpenVibe.Design — Express app factory; server/index.js listens, tests build their own instance with a mock-- * Network.++ * OpenVibe.Design — Express app factory; server/index.js listens, tests build their own instance with a temporary++ * database and mock Network/AI.*-- * /, /studio, /t/:slug, /developers the pages (http/pages.js)-- * /api/themes/*, /api/me/theme* the theme API proxy to OpenVibe.Network (http/api.js)-- * /auth/* Network SSO with PKCE (auth/sso.js)-- * /api/health, /api/ready, /release.json liveness, truthful readiness, release metadata-- * /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)++ * /, /studio, /t/:slug, /arena/*, /developers the pages (http/pages.js)++ * /api/v1/arena/* the Arena API (http/arena-api.js)++ * /api/themes/*, /api/me/theme* the theme API proxy to OpenVibe.Network (http/api.js)++ * /auth/* Network SSO with PKCE (auth/sso.js)++ * /internal/events ADR-033 account export/deletion (identity/account-data.js)++ * /api/health, /api/ready, /release.json liveness, truthful readiness, release metadata++ * /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)*-- * Stateless: no database, no Valkey, no Events. The only dependency is OpenVibe.Network, for sign-in and the theme-- * API; the pages and the public catalogue degrade honestly without it.++ * The Arena's rounds are generated by a job loop (server/index.js starts it; DESIGN_DRILL=1 keeps it off). The++ * request that starts a round only queues it.*/const path = require('path');const express = require('express');@@@ -23,35 +25,53 @@ const cache = require('openvibe-shared/cache-policy');const releaseLib = require('openvibe-shared/release');const metricsLib = require('openvibe-shared/metrics');const serve = require('openvibe-shared/serve');++const { createNetworkSender } = require('openvibe-sdk/account-data');const configLib = require('./config');++const { openStore } = require('./db');const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createThemeCatalog } = require('./themes');const { createApi } = require('./http/api');++const { createArenaApi } = require('./http/arena-api');const { createPageRoutes } = require('./http/pages');const { createDiscoveryRoutes } = require('./http/discovery');const { createServiceReadiness } = require('./observability');++const { createArenaStore } = require('./arena/store');++const { createArenaAi } = require('./arena/ai');++const { createArenaJobs } = require('./arena/jobs');++const accountDataLib = require('./identity/account-data');const { PUBLIC_DIR, assetVersion } = require('./assets');const views = require('./views');const VERSION = require('../package.json').version;/**-- * opts: config, catalog, fetchImpl, log, now, release-- * `catalog` is the public theme client; a test passes an object with the same methods, or a fetchImpl that talks to-- * an in-process Network.++ * opts: config, store, catalog, arena, arenaAi, accountSend, fetchImpl, log, now, release++ * A test passes a migrated store and can inject the Arena, the AI client and the account sender.*/--function createApp(opts = {}) {++async function createApp(opts = {}) {const config = opts.config || configLib.load();const log = opts.log || console;const fetchImpl = opts.fetchImpl || globalThis.fetch;const now = opts.now || (() => Date.now());++ const s = opts.store || await openStore(config, { now, log });const keys = createKeyStore({ config, fetchImpl, log });const sso = createSso({ config, keys, fetchImpl, log, now });const catalog = opts.catalog || createThemeCatalog({ config, fetchImpl, log, now });-- const ctx = { config, keys, sso, catalog, log };++ const arena = opts.arena || createArenaStore({ s, config, log });++ const ai = opts.arenaAi || opts.ai || createArenaAi({ config, fetchImpl, log });++ const jobs = createArenaJobs({ s, arena, ai, config, log });++ const accountData = accountDataLib.create({ db: s.db, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });++++ const ctx = {++ config, s, store: s, keys, sso, catalog, arena, ai, jobs, accountData, accountSend, log,++ random: opts.random || Math.random,++ };const release = opts.release || releaseLib.createRelease({ service: 'design', root: path.join(__dirname, '..') });views.setRelease(release.release);@@@ -68,7 +88,7 @@ function createApp(opts = {}) {// ── Machine endpoints ───────────────────────────────────app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-design', version: VERSION }));-- const readiness = createServiceReadiness({ config, catalog, keys, release: release.release });++ const readiness = createServiceReadiness({ s, config, catalog, keys, release: release.release });app.get('/api/ready', readiness.handler);app.use(contracts.http.middleware());@@@ -84,6 +104,7 @@ function createApp(opts = {}) {imgSrc: ["'self'", 'data:', 'https:'],// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],++ // 'self' covers the Arena's about:srcdoc iframes; generated pages cannot navigate the top window.frameSrc: ["'self'", 'https://openvibe.network'],frameAncestors: ["'none'"],objectSrc: ["'none'"],@@@ -115,6 +136,10 @@ function createApp(opts = {}) {app.use(sso.middleware());app.use('/auth', sso.routes());++ // ── Account export/deletion from OpenVibe.Events (loopback only) ──++ // Raw body first: the SDK verifies the delivery signature over the exact bytes.++ app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend }));++// ── Static assets (content-hashed ?v= → immutable) ──────app.use('/shared', serve.handler());const setAssetHeaders = (res, filePath) => {@@@ -125,8 +150,9 @@ function createApp(opts = {}) {app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false, setHeaders: setAssetHeaders }));app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));-- // ── API: the theme proxy ────────────────────────────────++ // ── API: the Arena, then the theme proxy ────────────────app.use('/api', express.json({ limit: '64kb' }));++ app.use('/api/v1/arena', createArenaApi(ctx));app.use('/api', createApi(ctx));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {detail: 'No such API route.',@@@ -141,7 +167,7 @@ function createApp(opts = {}) {// eslint-disable-next-line no-unused-varsapp.use((err, req, res, _next) => {-- // Never log request bodies (a theme's variables can be large, a person's request can be personal).++ // Never log request bodies (a prompt or a generated page can be large or personal).log.error('[OpenVibe.Design]', err && err.message ? err.message.slice(0, 300) : err);if (res.headersSent) return;res.set('Cache-Control', 'no-store');nnew file mode 100644@@@ -0,0 +1,301 @@++'use strict';++++/**++ * The Arena's only model gateway: OpenVibe.AI's `generate` operation, called as the first-party service `design`++ * with a client-credentials token (audience openvibe.ai, scope ai.run.create). Network grants the token, and the++ * `ai.*` namespace lets it run `ai.generate`.++ *++ * AI's routing is the authority. Design reads the chat route's offered providers/models (GET /api/v1/routes) and++ * asks for three slots; each run reports the provider and model that actually answered, and those are what the++ * leaderboard ranks. `DESIGN_ARENA_MODELS` can name explicit `provider:model` slots, but the reported provenance++ * always wins, so a fallback or a pool's choice is never mislabelled.++ */++const { serviceAuth } = require('openvibe-contracts');++const { sanitizeHtml } = require('./sanitize');++++class ArenaAiError extends Error {++ constructor(status, code, detail) {++ super(detail || code);++ this.name = 'ArenaAiError';++ this.status = status;++ this.code = code;++ this.detail = detail || code;++ }++}++++const CATEGORY_BRIEF = {++ landing: 'A marketing landing page for a product or place: a hero, a clear proof section, and one call to action.',++ dashboard: 'A dense analytics dashboard: navigation, KPI cards, a chart area, a table and useful empty states.',++ mobile_app: 'A phone-first app screen or flow: one clear task, thumb-sized controls, safe areas and a bottom bar if it helps.',++ game_ui: 'A game interface: HUD, inventory or lobby that is readable at a glance and feels like a game.',++ logo: 'A logo and small brand sheet: the mark, a wordmark, a colour palette and a few lockups.',++ slides: 'A short slide deck: a title slide and two or three content slides with a clear visual hierarchy.',++};++++function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console }) {++ const ai = config.ai;++ const tokenClient = config.oauth.clientSecret++ ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: ai.audience,++ scope: ai.scope,++ fetchImpl,++ })++ : null;++ const enabled = Boolean(ai.url && tokenClient);++++ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++ async function call(method, path, body, { retried = false } = {}) {++ if (!enabled) throw new ArenaAiError(503, 'ai.not_configured', 'OpenVibe.AI is not configured for the Arena.');++ let headers;++ try {++ headers = { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokenClient.authHeaders()) };++ } catch (err) {++ throw new ArenaAiError(502, 'ai.token_failed', 'OpenVibe.Network did not issue an AI token for Design.');++ }++ let res;++ try {++ res = await fetchImpl(`${ai.url.replace(/\/+$/, '')}${path}`, {++ method,++ headers,++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(ai.timeoutMs),++ });++ } catch (err) {++ throw new ArenaAiError(502, 'ai.unreachable', err && err.name === 'TimeoutError'++ ? 'OpenVibe.AI did not answer in time.'++ : 'OpenVibe.AI could not be reached.');++ }++ if (res.status === 401 && !retried) {++ tokenClient.invalidate();++ return call(method, path, body, { retried: true });++ }++ const json = await res.json().catch(() => null);++ if (!res.ok) {++ const code = (json && (json.code || json.error)) || `ai.http_${res.status}`;++ const detail = (json && (json.detail || json.message)) || `OpenVibe.AI refused the run (${res.status}).`;++ throw new ArenaAiError(res.status >= 500 ? 502 : res.status, String(code).slice(0, 80), String(detail).slice(0, 500));++ }++ return json;++ }++++ /**++ * The offers the routing lists for `generate` (GET /api/v1/routes?operation=generate). AI's route++ * response is the source: an `offers` array of { provider, model }. A route that predates offers++ * still answers with primary/fallbacks/pinned, which are read as before. The default.chat route is++ * the one ai.generate uses, so its offers are preferred; distinct providers come first, then a++ * second model of one provider.++ */++ async function offeredModels() {++ let body;++ try { body = await call('GET', '/api/v1/routes?operation=generate'); } catch (err) {++ log.warn('[OpenVibe.Design] AI routes unavailable:', (err && err.message) || err);++ return [];++ }++ let routes = Array.isArray(body) ? body : (body && Array.isArray(body.routes) ? body.routes : []);++ if (!routes.length && body && body.routes && typeof body.routes === 'object' && !Array.isArray(body.routes)) {++ routes = Object.entries(body.routes).map(([key, r]) => ({ key, ...(r || {}) }));++ }++ if (!routes.length && body && body.key) routes = [body];++ if (!routes.length && body && Array.isArray(body.models)) {++ routes = body.models.map((m) => ({ key: 'default.chat', capability: 'chat', primary: { provider: m.provider || m.provider_key, model: m.model || m.model_key } }));++ }++ const chat = routes.filter((r) => r && (r.capability === 'chat' || /chat|generate/i.test(String(r.key || ''))));++ const ordered = [++ ...chat.filter((r) => r.key === 'default.chat'),++ ...chat.filter((r) => r.key !== 'default.chat'),++ ];++ const out = [];++ const seen = new Set();++ const add = (provider, model) => {++ const p = String(provider || '').trim();++ const m = model == null ? null : String(model).trim();++ if (!p) return;++ const key = `${p}|${m || ''}`;++ if (seen.has(key)) return;++ seen.add(key);++ out.push({ provider: p, model: m || null });++ };++ // AI's offers are authoritative when the response carries them. The default.chat route is++ // ai.generate's route; when it has no offers, no other route's offer is a valid pin here.++ const hasOffers = ordered.some((r) => Array.isArray(r.offers));++ if (hasOffers) {++ const preferred = ordered.find((r) => r.key === 'default.chat' && Array.isArray(r.offers));++ const source = preferred ? [preferred] : ordered.filter((r) => Array.isArray(r.offers));++ for (const r of source) for (const o of r.offers) add(o && o.provider, o && o.model);++ } else {++ // An older AI lists definitions only. These are read as offers for compatibility; a pin AI++ // does not list is refused by its 422, which the job records on the output.++ for (const r of ordered) {++ if (r.primary) add(r.primary.provider, r.primary.model);++ const pinned = Array.isArray(r.pinned) ? r.pinned : [];++ for (const p of pinned) add(p && p.provider, p && p.model);++ const fallbacks = Array.isArray(r.fallbacks) ? r.fallbacks : [];++ for (const f of fallbacks) add(f && f.provider, f && f.model);++ if (r.constraints && r.constraints.model && r.primary) add(r.primary.provider, r.constraints.model);++ }++ }++ return rankOffers(out);++ }++++ /** Distinct offers, one per provider first, then further models of a provider already seen. */++ function rankOffers(list) {++ const out = [];++ const seen = new Set();++ const providers = new Set();++ const rest = [];++ for (const c of list) {++ const p = String((c && c.provider) || '').trim();++ if (!p) continue;++ const m = c.model == null ? null : String(c.model).trim();++ const key = `${p}|${m || ''}`;++ if (seen.has(key)) continue;++ seen.add(key);++ const offer = { provider: p, model: m || null };++ if (!providers.has(p)) { providers.add(p); out.push(offer); } else rest.push(offer);++ }++ return [...out, ...rest];++ }++++ function configuredModels() {++ return (ai.models || []).map((s) => {++ const i = s.indexOf(':');++ return i > 0 ? { provider: s.slice(0, i), model: s.slice(i + 1) || null } : { provider: s, model: null };++ });++ }++++ /**++ * Three distinct offers to ask AI for. When the routing offers fewer than three, the remainder is++ * an unpinned slot answered by AI's pool, and the round records that it fell back (`pool_fallback`).++ */++ async function slotPlan() {++ let list = configuredModels();++ if (!list.length) list = await offeredModels();++ const ranked = rankOffers(list);++ const slots = ranked.slice(0, 3);++ const poolFallback = slots.length < 3;++ while (slots.length < 3) slots.push({ provider: 'ai', model: null });++ return { slots, pool_fallback: poolFallback, offered: ranked.length };++ }++++ async function slots() { return (await slotPlan()).slots; }++++ function systemPrompt(category) {++ return [++ 'You are a senior product designer and front-end engineer competing in the OpenVibe Design Arena.',++ 'Build the page the person describes as one complete, self-contained HTML document.',++ `Category: ${category}. ${CATEGORY_BRIEF[category] || ''}`,++ 'Rules: return only the HTML document, starting with <!doctype html>. Use inline CSS in a <style> tag or style attributes.',++ 'No JavaScript, no event-handler attributes, no external stylesheets, fonts, scripts or images. Images may be inline SVG or data: URIs.',++ 'Make it responsive enough to be judged at 390 px and 1280 px wide. Use real, plausible content, not lorem ipsum. No explanations outside the HTML.',++ ].join('\n');++ }++++ function textOf(output) {++ if (typeof output === 'string') return output;++ if (!output || typeof output !== 'object') return '';++ for (const k of ['text', 'html', 'content', 'body']) if (typeof output[k] === 'string') return output[k];++ if (output.message && typeof output.message.content === 'string') return output.message.content;++ return '';++ }++++ function msOf(run, fallback) {++ for (const v of [run && run.ms, run && run.latency_ms, run && run.duration_ms, run && run.usage && run.usage.ms, run && run.usage && run.usage.latency_ms, run && run.usage && run.usage.duration_ms]) {++ if (Number.isFinite(Number(v)) && Number(v) >= 0) return Math.round(Number(v));++ }++ const start = run && (run.started_at || run.created_at);++ const end = run && run.finished_at;++ if (start && end) {++ const d = Date.parse(end) - Date.parse(start);++ if (Number.isFinite(d) && d >= 0) return Math.round(d);++ }++ return Math.max(0, Math.round(fallback));++ }++++ async function waitFor(run, startedAt) {++ let r = run;++ const deadline = Date.now() + ai.timeoutMs;++ while (r && ['queued', 'running'].includes(r.status)) {++ if (!r.id) throw new ArenaAiError(502, 'ai.bad_answer', 'OpenVibe.AI answered with a running run that has no id.');++ if (Date.now() > deadline) throw new ArenaAiError(504, 'ai.timeout', `The AI run ${r.id} did not finish in time.`);++ await sleep(ai.pollMs);++ const body = await call('GET', `/api/v1/runs/${encodeURIComponent(r.id)}`);++ r = body && body.run ? body.run : body;++ }++ if (!r) throw new ArenaAiError(502, 'ai.bad_answer', 'OpenVibe.AI answered without a run.');++ const done = ['succeeded', 'cached', 'completed', 'ok'].includes(r.status)++ || (r.status == null && r.output != null && !r.error);++ if (!done) {++ const e = r.error || {};++ throw new ArenaAiError(502, 'ai.run_failed', `The AI run ${r.id || '?'} ${r.status || 'did not finish'}${e.code ? ` (${e.code}: ${e.detail || ''})` : ''}.`);++ }++ return { run: r, ms: msOf(r, Date.now() - startedAt) };++ }++++ async function generate({ prompt, category, slot, roundId, index }) {++ if (!enabled) throw new ArenaAiError(503, 'ai.not_configured', 'OpenVibe.AI is not configured for the Arena.');++ // One offer from GET /api/v1/routes: AI runs that provider/model alone, or refuses it in words.++ // A pool slot (fewer than three offered) sends no route and lets the routing choose.++ const pinned = slot && slot.provider && slot.provider !== 'ai' ? { provider: slot.provider, model: slot.model || null } : null;++ const body = {++ prompt: `${prompt}\n\nCategory: ${category}\nReturn one complete HTML document.`,++ system: systemPrompt(category),++ max_tokens: 8000,++ temperature: 0.7,++ options: { cache: false },++ ...(pinned ? { route: pinned } : {}),++ target: { service: 'design', type: 'arena_output', id: `${roundId}_${index}`.slice(0, 128) },++ };++ const startedAt = Date.now();++ const wait = Math.min(ai.waitMs, 60_000);++ let created;++ try {++ created = await call('POST', `/api/v1/generate?wait=${wait}`, body);++ } catch (err) {++ // A deployment that exposes the run API but not the convenience operation: the same run, by workflow.++ if (!err || err.status !== 404) throw err;++ created = await call('POST', `/api/v1/runs?wait=${wait}`, {++ workflow: 'ai.generate',++ input: { prompt: body.prompt, system: body.system, max_tokens: body.max_tokens, temperature: body.temperature },++ options: body.options,++ ...(body.route ? { route: body.route } : {}),++ target: body.target,++ });++ }++ const first = created && created.run ? created.run : created;++ const { run, ms } = await waitFor(first, startedAt);++ const text = textOf(run.output);++ if (!text.trim()) throw new ArenaAiError(502, 'ai.empty', `The AI run ${run.id} returned no HTML.`);++ let html = text.trim().replace(/^\s*```(?:html)?\s*\n?/i, '').replace(/\n?```\s*$/, '');++ const doc = /<!doctype\s+html[\s\S]*/i.exec(html) || /<html[\s\S]*/i.exec(html);++ if (doc) html = doc[0];++ const usage = run.usage || {};++ const prov = run.provenance || {};++ const tokens = usage.tokens && typeof usage.tokens === 'object'++ ? (Number(usage.tokens.input) || 0) + (Number(usage.tokens.output) || 0)++ : Number.isFinite(Number(usage.tokens)) ? Number(usage.tokens)++ : (Number(usage.tokens_in) || 0) + (Number(usage.tokens_out) || 0)++ || (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0)++ || (Number(run.tokens) || 0);++ return {++ html: sanitizeHtml(html, { maxBytes: ai.maxHtmlBytes }),++ cost_usd: Number(usage.cost_usd != null ? usage.cost_usd++ : usage.cost != null ? usage.cost++ : run.cost_usd != null ? run.cost_usd++ : run.cost != null ? run.cost : 0) || 0,++ ms,++ tokens: Math.max(0, Math.round(tokens)),++ model: String(prov.model || run.model || (run.output && run.output.model) || (slot && slot.model) || 'unknown').slice(0, 120),++ provider: String(prov.provider || run.provider || (slot && slot.provider) || 'ai').slice(0, 80),++ ui_check: run.ui_check || (run.output && run.output.ui_check) || null,++ run_id: run.id || null,++ };++ }++++ return { enabled, slots, slotPlan, offeredModels, rankOffers, generate, systemPrompt, CATEGORY_BRIEF };++}++++module.exports = { createArenaAi, ArenaAiError, CATEGORY_BRIEF };nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++++/**++ * The Arena's generation worker. A round is created as `queued` by the request; this loop claims it, asks AI for++ * three outputs, stores each (sanitized) with its reported provider, model, cost, time and tokens, then marks the++ * round `ready` (at least two usable outputs) or `failed`.++ *++ * The loop starts in server/index.js and never under DESIGN_DRILL=1. Tests call tick() explicitly.++ */++const ERROR_MAX = 500;++++function createArenaJobs({ s, arena, ai, config, log = console }) {++ let timer = null;++ let running = false;++ let stopped = false;++++ async function claim() {++ return await s.tx(async () => {++ const r = await s.db.maybe("SELECT * FROM arena_rounds WHERE status = 'queued' ORDER BY created_at, id LIMIT 1 FOR UPDATE");++ if (!r) return null;++ await s.db.exec("UPDATE arena_rounds SET status = 'generating' WHERE id = $1", [r.id]);++ return { ...r, status: 'generating' };++ });++ }++++ async function generateRound(round) {++ let plan;++ try {++ plan = typeof ai.slotPlan === 'function' ? await ai.slotPlan() : { slots: await ai.slots(), pool_fallback: false };++ } catch (err) {++ log.warn('[OpenVibe.Design] AI slots unavailable:', (err && err.message) || err);++ plan = { slots: [{ provider: 'ai', model: null }], pool_fallback: true };++ }++ const slots = (Array.isArray(plan.slots) ? plan.slots : []).slice(0, 3);++ while (slots.length < 3) slots.push({ provider: 'ai', model: null });++ // Say on the round what was asked for, and whether (and why) the pool filled a slot.++ await s.db.exec('UPDATE arena_rounds SET offers = $1::jsonb, pool_fallback = $2 WHERE id = $3',++ [JSON.stringify(slots), Boolean(plan.pool_fallback), round.id]);++ let ok = 0;++ for (let i = 0; i < 3; i++) {++ const slot = slots[i] || { provider: 'ai', model: null };++ try {++ const out = await ai.generate({ prompt: round.prompt, category: round.category, slot, roundId: round.id, index: i });++ await s.db.exec(`INSERT INTO arena_outputs (id, "round", model, provider, html, cost_usd, ms, tokens, error, ui_check, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, $10::timestamptz)`,++ [`aro_${require('openvibe-contracts').ids.ulid(s.now())}`, round.id, out.model, out.provider, out.html,++ out.cost_usd, out.ms, out.tokens, out.ui_check == null ? null : JSON.stringify(out.ui_check), s.iso()]);++ ok++;++ } catch (err) {++ const detail = String((err && (err.detail || err.message)) || err).slice(0, ERROR_MAX);++ log.warn(`[OpenVibe.Design] Arena round ${round.id} output ${i} failed:`, detail);++ await s.db.exec(`INSERT INTO arena_outputs (id, "round", model, provider, html, cost_usd, ms, tokens, error, ui_check, created_at)++ VALUES ($1, $2, $3, $4, '', 0, 0, 0, $5, NULL, $6::timestamptz)`,++ [`aro_${require('openvibe-contracts').ids.ulid(s.now())}`, round.id,++ String((slot && slot.model) || 'unknown').slice(0, 120), String((slot && slot.provider) || 'ai').slice(0, 80), detail, s.iso()]);++ }++ }++ await s.db.exec('UPDATE arena_rounds SET status = $1 WHERE id = $2', [ok >= 2 ? 'ready' : 'failed', round.id]);++ return ok;++ }++++ async function tick() {++ if (running || stopped) return null;++ running = true;++ try {++ const round = await claim();++ if (!round) return null;++ try {++ await generateRound(round);++ return await arena.getRound(round.id);++ } catch (err) {++ log.warn('[OpenVibe.Design] Arena round failed:', (err && err.message) || err);++ await s.db.exec("UPDATE arena_rounds SET status = 'failed' WHERE id = $1", [round.id]).catch(() => {});++ throw err;++ }++ } finally {++ running = false;++ }++ }++++ function start() {++ if (timer || stopped || config.drill) return null;++ const run = () => tick().catch((err) => log.warn('[OpenVibe.Design] Arena job:', (err && err.message) || err));++ timer = setInterval(run, config.arena.intervalMs);++ if (timer.unref) timer.unref();++ run();++ return timer;++ }++++ function stop() {++ stopped = true;++ if (timer) clearInterval(timer);++ timer = null;++ }++++ return { tick, start, stop, claim, generateRound, get running() { return running; } };++}++++module.exports = { createArenaJobs };nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++++/**++ * The Arena's output safety. A model writes a page; Design stores it as text and never puts it in its own++ * document. Two layers:++ *++ * 1. sanitizeHtml(): strip <script>, on* attributes, javascript: URLs and a few navigation tags before storing,++ * and cap the text at 200 KB.++ * 2. iframeHtml(): the only place an output is rendered is `<iframe sandbox srcdoc="…">` with no allow-scripts++ * and no allow-same-origin, and the srcdoc document starts with a CSP meta that blocks every network fetch:++ * `default-src 'none'; style-src 'unsafe-inline'; img-src data:`.++ *++ * Nothing here trusts the model. The escaped srcdoc is built with attrEscape(), so the output can never close the++ * attribute or the iframe.++ */++const { esc } = require('openvibe-shared/kit');++++const MAX_BYTES = 200 * 1024;++const CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";++++/** An attribute value for a double-quoted srcdoc: apostrophes may stay literal (CSP's 'none' stays readable). */++const attrEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/** What a browser would read from a URL attribute, enough to catch javascript: split by entities or whitespace. */++function urlLooksLikeScript(raw) {++ const decoded = String(raw)++ .replace(/&#x([0-9a-f]+);?/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))++ .replace(/&#(\d+);?/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))++ .replace(/:?/gi, ':').replace(/&tab;?/gi, '\t').replace(/&newline;?/gi, '\n');++ return /javascript\s*:/i.test(decoded.replace(/[\s\u0000-\u001f]+/g, ''));++}++++++/** The end of a tag, skipping a `>` inside a quoted attribute value (the HTML parser does the same). */++function tagEnd(s, start) {++ let quote = null;++ for (let i = start + 1; i < s.length; i++) {++ const c = s[i];++ if (quote) { if (c === quote) quote = null; }++ else if (c === '"' || c === "'") quote = c;++ else if (c === '>') return i;++ }++ return -1;++}++++/** Strip event handlers and javascript: URLs from one tag; the tag's own angle brackets are kept. */++function sanitizeTag(tag) {++ let t = tag;++ t = t.replace(/([\s/])on[a-z0-9_:.-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '$1');++ t = t.replace(/\s(on[a-z0-9_:.-]*)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');++ t = t.replace(/(\b(?:href|src|action|formaction|xlink:href|poster|background|data)\s*=\s*)((?:"[^"]*"|'[^']*'|[^\s>]+))/gi,++ (m, name, value) => (urlLooksLikeScript(value) ? `${name}"#"` : m));++ return t;++}++++/** Walk tags with the parser's quote rule instead of `[^>]*`, so a `>` inside a value cannot hide a handler. */++function sanitizeTags(s) {++ let out = '';++ let i = 0;++ while (i < s.length) {++ const lt = s.indexOf('<', i);++ if (lt === -1) { out += s.slice(i); break; }++ out += s.slice(i, lt);++ const next = s[lt + 1] || '';++ if (!/[a-z/]/i.test(next)) { out += '<'; i = lt + 1; continue; }++ const end = tagEnd(s, lt);++ if (end === -1) { out += s.slice(lt); break; }++ out += sanitizeTag(s.slice(lt, end + 1));++ i = end + 1;++ }++ return out;++}++++function truncateBytes(s, maxBytes) {++ if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;++ // Slice bytes, then drop a half-written UTF-8 character at the end.++ const buf = Buffer.from(s, 'utf8').subarray(0, maxBytes);++ let out = buf.toString('utf8');++ if (out.endsWith('\uFFFD')) out = out.slice(0, -1);++ return out;++}++++function sanitizeHtml(input, { maxBytes = MAX_BYTES } = {}) {++ let s = input == null ? '' : String(input);++ s = s.replace(/\u0000/g, '');++ s = truncateBytes(s, maxBytes);++++ // Comments can hide a script from a naive parser; remove them before anything else.++ s = s.replace(/<!--[\s\S]*?-->/g, '');++++ // <script>…</script>, including attributes and whitespace; repeat for nested or overlapping attempts.++ for (let i = 0; i < 4; i++) {++ const before = s;++ s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '');++ s = s.replace(/<script\b[^>]*\/\s*>/gi, '');++ if (s === before) break;++ }++ // An unclosed script would swallow the rest of the page: drop its opening tag (and any stray closing one).++ s = s.replace(/<\/?script\b[^>]*>/gi, '');++ // Anything that still looks like a script tag (a malformed `<script/x>`) is escaped, never a tag.++ s = s.replace(/<(\/?)(script)\b/gi, '<$1$2');++++ // Navigation / embedding tags that a generated page never needs.++ s = s.replace(/<base\b[^>]*>/gi, '');++ s = s.replace(/<meta\b[^>]*http-equiv\s*=\s*(?:"refresh"|'refresh'|refresh)[^>]*>/gi, '');++ s = s.replace(/<\/?(?:iframe|object|embed|applet)\b[^>]*>/gi, '');++++ // Attributes: on* handlers and javascript: URLs, with quoted `>` handled as the browser handles it.++ s = sanitizeTags(s);++++ // Belt and braces: no javascript: URL survives in any form (including whitespace/HTML entities between letters).++ s = s.replace(/j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:/gi, 'blocked:');++ if (/<script/i.test(s)) s = s.replace(/<(\/?)(script)/gi, '<$1$2');++++ return truncateBytes(s, maxBytes);++}++++/** The document inside the iframe: CSP first, then the model's sanitized page. */++function frameDocument(html) {++ return `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`++ + `<meta http-equiv="Content-Security-Policy" content="${CSP}">`++ + `<style>html,body{margin:0;min-height:100%;background:#fff;}img{max-width:100%;height:auto;}</style>`++ + sanitizeHtml(html);++}++++/**++ * The only renderer for a model output at a viewport width. `sandbox=""` is the strictest sandbox: no scripts,++ * no same-origin, no forms, no popups, no top navigation. The CSP meta is already inside the escaped srcdoc.++ */++function iframeHtml({ html = '', width = 390, height = 844, title = 'A generated design', id = null, className = '' } = {}) {++ const w = Math.max(1, Math.round(Number(width) || 390));++ const h = Math.max(1, Math.round(Number(height) || 844));++ // Keep the attribute list explicit rather than clever: a generated page must never influence it.++ return `<iframe${id ? ` id="${esc(id)}"` : ''} class="ar-frame${className ? ` ${esc(className)}` : ''}"`++ + ` title="${esc(title)}" width="${w}" height="${h}" data-width="${w}"`++ + ` sandbox="" loading="lazy" referrerpolicy="no-referrer"`++ + ` srcdoc="${attrEscape(frameDocument(html))}"></iframe>`;++}++++module.exports = { sanitizeHtml, frameDocument, iframeHtml, truncateBytes, MAX_BYTES, CSP, attrEscape, urlLooksLikeScript };nnew file mode 100644@@@ -0,0 +1,277 @@++'use strict';++++/**++ * The Arena's data access: rounds, outputs, votes, ratings, the open dataset and the day's budgets. Every++ * person-facing write is a transaction; a vote and the rating changes it causes commit together, so a crash can++ * never leave a vote without its Elo update. Votes are keyed by (round, voter_subject): one per person per round.++ */++const { ids } = require('openvibe-contracts');++++const CATEGORIES = Object.freeze(['landing', 'dashboard', 'mobile_app', 'game_ui', 'logo', 'slides']);++const WINNERS = Object.freeze(['left', 'right', 'tie', 'both_bad']);++const K = 32;++const BASE_ELO = 1000;++++class ArenaError extends Error {++ constructor(status, code, detail, extra = {}) {++ super(detail);++ this.name = 'ArenaError';++ this.status = status;++ this.code = code;++ this.detail = detail;++ this.extra = extra;++ }++}++++const num = (v, d = 0) => (v == null || v === '' || Number.isNaN(Number(v)) ? d : Number(v));++const roundOf = (r) => (r ? {++ id: r.id,++ category: r.category,++ prompt: r.prompt,++ status: r.status,++ // The offers the round asked AI for ({provider, model}); a pool slot is provider 'ai' with no model.++ offers: Array.isArray(r.offers) ? r.offers : [],++ // True only when the routing offered fewer than three distinct offers, so the rest came from the pool.++ pool_fallback: Boolean(r.pool_fallback),++ created_at: r.created_at,++} : null);++const outputOf = (o) => ({++ id: o.id,++ round: o.round,++ model: o.model,++ provider: o.provider,++ html: o.html,++ cost_usd: num(o.cost_usd),++ ms: num(o.ms),++ tokens: num(o.tokens),++ error: o.error || null,++ ui_check: o.ui_check == null ? null : o.ui_check,++ created_at: o.created_at,++});++const voteOf = (v) => ({ round: v.round, left_output: v.left_output, right_output: v.right_output, winner: v.winner, at: v.at });++++/** Fisher–Yates, with an injectable random source so a test can pin the order. */++function shuffle(list, random = Math.random) {++ const a = list.slice();++ for (let i = a.length - 1; i > 0; i--) {++ const j = Math.floor(random() * (i + 1));++ [a[i], a[j]] = [a[j], a[i]];++ }++ return a;++}++++function createArenaStore({ s, config, log = console }) {++ const db = s.db;++ const perDay = config.arena.perDay;++ const daily = config.arena.daily;++++ const dayStart = (at = s.now()) => {++ const d = new Date(at);++ d.setUTCHours(0, 0, 0, 0);++ return d.toISOString();++ };++++ async function counts(subject) {++ const start = dayStart();++ const used = num(await db.value('SELECT COUNT(*)::int AS n FROM arena_rounds WHERE created_by = $1 AND created_at >= $2::timestamptz', [subject, start]));++ const siteUsed = num(await db.value('SELECT COUNT(*)::int AS n FROM arena_rounds WHERE created_at >= $1::timestamptz', [start]));++ return {++ used,++ remaining: Math.max(0, perDay - used),++ per_day: perDay,++ site_used: siteUsed,++ site_daily: daily,++ site_remaining: Math.max(0, daily - siteUsed),++ };++ }++++ async function createRound({ category, prompt, subject }) {++ if (!CATEGORIES.includes(category)) throw new ArenaError(422, 'arena.invalid_category', `Pick a category: ${CATEGORIES.join(', ')}.`);++ const text = String(prompt == null ? '' : prompt).trim();++ if (!text) throw new ArenaError(422, 'arena.prompt_required', 'Describe the page you want the models to design.');++ if (text.length > config.arena.maxPrompt) throw new ArenaError(422, 'arena.prompt_too_long', `The prompt must be at most ${config.arena.maxPrompt} characters.`);++ const at = s.now();++ const start = dayStart(at);++ const id = `arr_${ids.ulid(at)}`;++ const row = await db.maybe(++ `INSERT INTO arena_rounds (id, category, prompt, created_by, status, created_at)++ SELECT $1, $2, $3, $4, 'queued', $5::timestamptz++ WHERE (SELECT COUNT(*) FROM arena_rounds WHERE created_by = $4 AND created_at >= $6::timestamptz) < $7++ AND (SELECT COUNT(*) FROM arena_rounds WHERE created_at >= $6::timestamptz) < $8++ RETURNING *`,++ [id, category, text, subject, s.iso(), start, perDay, daily],++ );++ if (!row) {++ const c = await counts(subject);++ if (c.used >= perDay) {++ throw new ArenaError(429, 'arena.limit_person',++ `You can start ${perDay} Arena round${perDay === 1 ? '' : 's'} a day. You have started ${c.used} today; try again tomorrow.`);++ }++ throw new ArenaError(429, 'arena.limit_site',++ `The Arena has reached its ${daily} round${daily === 1 ? '' : 's'} for today. Try again tomorrow.`);++ }++ return await getRound(id);++ }++++ async function getRound(id) {++ const r = await db.maybe('SELECT * FROM arena_rounds WHERE id = $1', [id]);++ if (!r) return null;++ const outputs = (await db.many('SELECT * FROM arena_outputs WHERE "round" = $1 ORDER BY created_at, id', [id])).map(outputOf);++ const votes = num(await db.value('SELECT COUNT(*)::int AS n FROM arena_votes WHERE "round" = $1', [id]));++ return { ...roundOf(r), outputs, votes };++ }++++ /** A ready round this person has not voted on and did not start, with its two successful outputs shuffled. */++ async function nextPair(subject, { random = Math.random } = {}) {++ const r = await db.maybe(++ `SELECT r.* FROM arena_rounds r++ WHERE r.status = 'ready' AND r.created_by <> $1++ AND (SELECT COUNT(*) FROM arena_outputs o WHERE o."round" = r.id AND o.error IS NULL AND o.html <> '') >= 2++ AND NOT EXISTS (SELECT 1 FROM arena_votes v WHERE v."round" = r.id AND v.voter_subject = $1)++ ORDER BY random() LIMIT 1`,++ [subject],++ );++ if (!r) return null;++ const outputs = (await db.many('SELECT * FROM arena_outputs WHERE "round" = $1 AND error IS NULL AND html <> \'\' ORDER BY id', [r.id])).map(outputOf);++ if (outputs.length < 2) return null;++ const [left, right] = shuffle(outputs, random).slice(0, 2);++ // Blind: the model, provider, cost and time are not part of this answer. The HTML is (in the iframe).++ return {++ round: { id: r.id, category: r.category, prompt: r.prompt },++ left: { id: left.id, html: left.html },++ right: { id: right.id, html: right.html },++ };++ }++++ async function applyElo(t, category, left, right, winner, atIso) {++ if (winner === 'both_bad') return; // a game for neither++ if (left.model === right.model) return; // two outputs of one model are not a match between designers++ const models = [left.model, right.model];++ for (const model of models) {++ await t.exec(`INSERT INTO arena_ratings (category, model, elo, games, wins, losses, ties, updated_at)++ VALUES ($1, $2, $3, 0, 0, 0, 0, $4::timestamptz)++ ON CONFLICT (category, model) DO NOTHING`, [category, model, BASE_ELO, atIso]);++ }++ const rows = await t.many('SELECT * FROM arena_ratings WHERE category = $1 AND model = ANY($2::text[]) ORDER BY model FOR UPDATE', [category, models]);++ const byModel = new Map(rows.map((r) => [r.model, r]));++ const a = byModel.get(left.model);++ const b = byModel.get(right.model);++ if (!a || !b) return;++ const ra = num(a.elo, BASE_ELO);++ const rb = num(b.elo, BASE_ELO);++ const ea = 1 / (1 + (10 ** ((rb - ra) / 400)));++ const eb = 1 - ea;++ const sa = winner === 'left' ? 1 : (winner === 'right' ? 0 : 0.5);++ const sb = 1 - sa;++ const nextA = Math.round((ra + K * (sa - ea)) * 100) / 100;++ const nextB = Math.round((rb + K * (sb - eb)) * 100) / 100;++ const update = async (row, elo, score, oppScore) => {++ await t.exec(`UPDATE arena_ratings SET elo = $1, games = games + 1,++ wins = wins + $2, losses = losses + $3, ties = ties + $4, updated_at = $5::timestamptz++ WHERE category = $6 AND model = $7`,++ [elo, score > oppScore ? 1 : 0, score < oppScore ? 1 : 0, score === oppScore ? 1 : 0, atIso, category, row.model]);++ };++ await update(a, nextA, sa, sb);++ await update(b, nextB, sb, sa);++ }++++ async function vote({ roundId, subject, leftOutput, rightOutput, winner }) {++ if (!WINNERS.includes(winner)) throw new ArenaError(422, 'arena.invalid_winner', 'The winner must be left, right, tie or both_bad.');++ if (!leftOutput || !rightOutput || leftOutput === rightOutput) throw new ArenaError(422, 'arena.invalid_pair', 'A vote needs two different outputs.');++ try {++ const result = await s.tx(async () => {++ const r = await db.maybe('SELECT * FROM arena_rounds WHERE id = $1 FOR UPDATE', [roundId]);++ if (!r) throw new ArenaError(404, 'arena.round_not_found', 'No such Arena round.');++ if (r.created_by === subject) throw new ArenaError(403, 'arena.creator_cannot_vote', 'You cannot vote on a round you started.');++ if (r.status !== 'ready') throw new ArenaError(409, 'arena.round_not_ready', 'This round is not ready to vote on yet.');++ const outputs = (await db.many('SELECT * FROM arena_outputs WHERE "round" = $1 AND id = ANY($2::text[]) AND error IS NULL AND html <> \'\'', [roundId, [leftOutput, rightOutput]])).map(outputOf);++ if (outputs.length !== 2) throw new ArenaError(422, 'arena.invalid_pair', 'Those two outputs are not both part of this round.');++ const left = outputs.find((o) => o.id === leftOutput);++ const right = outputs.find((o) => o.id === rightOutput);++ const prior = await db.maybe('SELECT 1 AS n FROM arena_votes WHERE "round" = $1 AND voter_subject = $2', [roundId, subject]);++ if (prior) throw new ArenaError(409, 'arena.already_voted', 'You have already voted on this round.');++ const at = s.iso();++ await db.exec('INSERT INTO arena_votes ("round", voter_subject, left_output, right_output, winner, at) VALUES ($1, $2, $3, $4, $5, $6::timestamptz)',++ [roundId, subject, leftOutput, rightOutput, winner, at]);++ await applyElo(db, r.category, left, right, winner, at);++ return { round: roundOf(r), winner, at, left, right };++ });++ return result;++ } catch (err) {++ if (err && err.code === '23505') throw new ArenaError(409, 'arena.already_voted', 'You have already voted on this round.');++ throw err;++ }++ }++++ async function leaderboard(category = null) {++ const rows = await db.many(++ `WITH models AS (++ SELECT category, model FROM arena_ratings WHERE ($1::text IS NULL OR category = $1)++ UNION++ SELECT r.category, o.model FROM arena_outputs o JOIN arena_rounds r ON r.id = o."round"++ WHERE r.status = 'ready' AND o.error IS NULL AND ($1::text IS NULL OR r.category = $1)++ ), medians AS (++ SELECT r.category, o.model,++ percentile_cont(0.5) WITHIN GROUP (ORDER BY o.cost_usd) AS median_cost,++ percentile_cont(0.5) WITHIN GROUP (ORDER BY o.ms) AS median_ms++ FROM arena_outputs o JOIN arena_rounds r ON r.id = o."round"++ WHERE r.status = 'ready' AND o.error IS NULL AND ($1::text IS NULL OR r.category = $1)++ GROUP BY r.category, o.model++ )++ SELECT m.category, m.model, COALESCE(rt.elo, $2) AS elo,++ COALESCE(rt.games, 0) AS games, COALESCE(rt.wins, 0) AS wins,++ COALESCE(rt.losses, 0) AS losses, COALESCE(rt.ties, 0) AS ties,++ med.median_cost, med.median_ms++ FROM models m++ LEFT JOIN arena_ratings rt ON rt.category = m.category AND rt.model = m.model++ LEFT JOIN medians med ON med.category = m.category AND med.model = m.model++ ORDER BY m.category, elo DESC, games DESC, m.model`,++ [category, BASE_ELO],++ );++ const all = rows.map((r) => {++ const games = num(r.games);++ const wins = num(r.wins);++ const ties = num(r.ties);++ return {++ category: r.category,++ model: r.model,++ elo: Math.round(num(r.elo, BASE_ELO) * 100) / 100,++ games,++ wins,++ losses: num(r.losses),++ ties,++ // A tie is half a win, as in Elo's expected score.++ win_rate: games ? Math.round(((wins + ties * 0.5) / games) * 10000) / 10000 : 0,++ median_cost_usd: r.median_cost == null ? null : Math.round(num(r.median_cost) * 1e6) / 1e6,++ median_cost: r.median_cost == null ? null : Math.round(num(r.median_cost) * 1e6) / 1e6,++ median_ms: r.median_ms == null ? null : Math.round(num(r.median_ms)),++ };++ });++ if (category) return all;++ return CATEGORIES.map((c) => ({ category: c, leaderboard: all.filter((r) => r.category === c) }));++ }++++ /** The open dataset: prompts, every output (HTML included) and every vote without its voter. */++ async function dataset() {++ const rounds = await db.many('SELECT id, category, prompt, status, created_at FROM arena_rounds ORDER BY created_at, id');++ const outputs = (await db.many('SELECT * FROM arena_outputs ORDER BY created_at, id')).map(outputOf);++ const votes = (await db.many('SELECT "round", left_output, right_output, winner, at FROM arena_votes ORDER BY at, "round"')).map(voteOf);++ const byRound = new Map(rounds.map((r) => [r.id, { ...r, outputs: [], votes: [] }]));++ for (const o of outputs) { const r = byRound.get(o.round); if (r) r.outputs.push(o); }++ for (const v of votes) { const r = byRound.get(v.round); if (r) r.votes.push(v); }++ return [...byRound.values()];++ }++++ async function datasetJsonl() {++ const lines = (await dataset()).map((r) => JSON.stringify(r));++ return lines.length ? `${lines.join('\n')}\n` : '';++ }++++ /** A current day's budget summary for the pages and the create answer. */++ return {++ CATEGORIES, WINNERS, K, BASE_ELO,++ counts, createRound, getRound, nextPair, vote, leaderboard, dataset, datasetJsonl, shuffle,++ };++}++++module.exports = { createArenaStore, ArenaError, CATEGORIES, WINNERS, K, BASE_ELO, shuffle };@@@ -153,10 +153,21 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat}}-- /** req.viewer for every page: the signed-in person, refreshed when their token expired. */++ /**++ * req.viewer for every page: a Bearer session token when the caller sends one (an API client, not a++ * browser form), else the httpOnly cookie, refreshed when its token expired.++ */function middleware() {return async (req, res, next) => {req.viewer = { kind: 'anonymous' };++ const header = String(req.get('authorization') || '');++ const bearerToken = /^Bearer\s+\S/i.test(header) ? header.replace(/^Bearer\s+/i, '').trim() : null;++ if (bearerToken) {++ await keys.ensure();++ const v = await verifySession(bearerToken);++ if (v.ok) req.viewer = viewerFromClaims(v.claims, bearerToken);++ return next();++ }const token = req.cookies && req.cookies[ACCESS_COOKIE];if (!token) return next();await keys.ensure();@@@ -22,6 +22,7 @@ function load(env = process.env) {// token ever talks to it directly.const networkPublicUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');const networkInternalUrl = trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000');++ const aiUrl = trim(env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700');return {service: 'design',@@@ -32,6 +33,7 @@ function load(env = process.env) {isProduction,baseUrl,trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ drill: env.DESIGN_DRILL === '1' || env.DESIGN_DRILL === 'true',// The theme API proxy (server/http/api.js): every call goes to the Network's /api/themes. `networkUrl`// is the internal origin the proxy and the token exchange use; `networkPublicUrl` is the browser's.@@@ -42,6 +44,42 @@ function load(env = process.env) {themeCacheMs: Math.max(0, int(env.DESIGN_THEME_CACHE_MS, 5 * 60_000)),timeoutMs: Math.max(200, int(env.DESIGN_UPSTREAM_TIMEOUT_MS, 8000)),++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In++ // development without DATABASE_URL an embedded PGlite database in data/pglite is used.++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.DESIGN_PGLITE_DIR || env.DESIGN_DATABASE_DIR || env.PGLITE_DIR || '' },++++ // OpenVibe.AI: the generate operation. Design is a first-party service and asks for a client-credentials++ // token (audience openvibe.ai, scope ai.run.create; Network grants the ai.* namespace). Generation is++ // picked up by the job loop, never by the request that creates a round.++ ai: {++ url: aiUrl,++ audience: env.DESIGN_AI_AUDIENCE || 'openvibe.ai',++ scope: env.DESIGN_AI_SCOPE || 'ai.run.create',++ waitMs: Math.max(1000, int(env.DESIGN_AI_WAIT_MS, 55_000)),++ timeoutMs: Math.max(1000, int(env.DESIGN_AI_TIMEOUT_MS, 120_000)),++ pollMs: Math.max(100, int(env.DESIGN_AI_POLL_MS, 1500)),++ // Optional explicit slots, `provider:model,provider:model,provider:model`. Without it Design reads the++ // models the chat/generate route offers and asks the router for three of them.++ models: String(env.DESIGN_ARENA_MODELS || '').split(',').map((x) => x.trim()).filter(Boolean),++ maxHtmlBytes: 200 * 1024,++ },++++ // Arena budgets and jobs. Both limits are per UTC day; the drill flag keeps the worker from starting.++ arena: {++ perDay: Math.max(1, int(env.DESIGN_ARENA_PER_DAY, 3)),++ daily: Math.max(1, int(env.DESIGN_ARENA_DAILY, 20)),++ intervalMs: Math.max(250, int(env.DESIGN_ARENA_JOB_INTERVAL_MS, 1500)),++ pollMs: Math.max(250, int(env.DESIGN_ARENA_POLL_MS, 20_000)),++ maxPrompt: Math.max(20, int(env.DESIGN_ARENA_MAX_PROMPT, 4000)),++ },++++ // OpenVibe.Events → this service for ADR-033 account export/deletion. Off when the secret is unset.++ events: {++ secrets: String(env.DESIGN_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.DESIGN_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.DESIGN_EVENTS_ENDPOINT || '',++ },++// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.oauth: {clientId: env.OV_OAUTH_CLIENT_ID || 'design',nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++++/**++ * OpenVibe.Design's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.++ * The Arena's rounds, outputs, votes and ratings live here, and so does the SDK's account_data_events++ * idempotency table (ADR-033). Nothing written here is a secret.++ */++const fs = require('fs');++const path = require('path');++const { createDb } = require('openvibe-sdk/db');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');++++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh)');++ const dir = config.db.pgliteDir || DEV_PGLITE;++ log.warn(`[OpenVibe.Design] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'design', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;++ }++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: config.db.directUrl, service: 'design-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'design', registry, log });++}++++/** The store every arena module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */++function createStore(db, { now = () => Date.now() } = {}) {++ return {++ db,++ now,++ iso: () => new Date(now()).toISOString(),++ newId: (prefix) => `${prefix}_${require('openvibe-contracts').ids.ulid(now())}`,++ tx: async (fn) => await db.tx(() => fn()),++ close: () => db.close(),++ };++}++++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++module.exports = { openDb, openStore, createStore, MIGRATIONS };@@@ -20,10 +20,7 @@const { http } = require('openvibe-contracts');const { asyncRouter } = require('./router');const { ACCESS_COOKIE } = require('../auth/sso');----function ownOrigin(config) {-- try { return new URL(config.baseUrl).origin; } catch { return null; }--}++const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */function passThrough(res, r) {@@@ -51,23 +48,12 @@ function forward(res, req, up) {function createApi(ctx) {const { config, catalog, log } = ctx;const r = asyncRouter();-- const origin = ownOrigin(config);---- /** Writes (PUT/POST): only this site's own origin may ask for them. */-- function requireOrigin(req, res, next) {-- const sent = String(req.get('origin') || '');-- if (!origin || sent !== origin) {-- return http.sendProblem(res, 403, 'design.cross_site', {-- detail: 'A change to a theme must come from openvibe.design itself.',-- ctx: req.ov,-- });-- }-- return next();-- }++ // A cookie write needs a same-origin signal; a Bearer write is not cookie-authenticated and is left alone.++ const requireOrigin = requireSameOrigin(config, { detail: 'A change to a theme must come from openvibe.design itself.' });-- /** The person's Network access token, from the httpOnly cookie only. */++ /** The person's Network access token: the Bearer they sent, else the httpOnly cookie. */function requireToken(req, res, next) {-- const token = req.cookies && req.cookies[ACCESS_COOKIE];++ const token = bearer(req) || (req.cookies && req.cookies[ACCESS_COOKIE]);if (!token) {return http.sendProblem(res, 401, 'token.required', {detail: 'Sign in at openvibe.design to use your theme.',nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++++/**++ * The Arena API, /api/v1/arena/*:++ *++ * GET /categories the six categories++ * POST /rounds start a round (signed in, same-origin, daily budgets)++ * GET /rounds/:id one round with its outputs++ * GET /vote the next blind pair for this person++ * POST /vote record a vote, reveal the two models and update Elo++ * GET /leaderboard[?category=…] Elo, games, win rate, median cost and ms++ * GET /dataset.jsonl every prompt, output and vote, without voter ids++ *++ * A round is queued here; the job loop generates it. The request never waits for a model.++ */++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('../http/router');++const { ArenaError, CATEGORIES } = require('../arena/store');++const { ownOrigin, requireSameOrigin } = require('./same-origin');++++function subjectOf(req) {++ return req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? req.viewer.subject : null;++}++++function createArenaApi(ctx) {++ const { arena, config, log } = ctx;++ const r = asyncRouter();++++ const problem = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++++ // A cookie-authenticated write must carry Sec-Fetch-Site: same-origin or an Origin equal to this++ // site; a missing signal is refused. A Bearer write is not cookie-authenticated and is unaffected.++ const requireOrigin = requireSameOrigin(config, { detail: 'An Arena change must come from openvibe.design itself.' });++++ function requireSubject(req, res, next) {++ if (!subjectOf(req)) return problem(res, req, 401, 'token.required', 'Sign in at openvibe.design to use the Arena.');++ return next();++ }++++ const failed = (res, req, err) => {++ if (err instanceof ArenaError) return problem(res, req, err.status, err.code, err.detail);++ log.warn('[OpenVibe.Design] Arena API failed:', (err && err.message) || err);++ return problem(res, req, 500, 'internal.error', 'The Arena could not answer. Try again in a moment.');++ };++++ r.get('/categories', (_req, res) => res.json({ categories: CATEGORIES }));++++ async function leaderboard(req, res) {++ const category = req.query.category == null || req.query.category === '' ? null : String(req.query.category);++ if (category && !CATEGORIES.includes(category)) {++ return problem(res, req, 400, 'arena.invalid_category', `Pick a category: ${CATEGORIES.join(', ')}.`);++ }++ try {++ const list = await arena.leaderboard(category);++ res.set('Cache-Control', 'public, max-age=30');++ if (category) return res.json({ category, leaderboard: list });++ return res.json({ categories: list, leaderboard: list.flatMap((c) => c.leaderboard) });++ } catch (err) { return failed(res, req, err); }++ }++ r.get('/leaderboard', leaderboard);++ r.get('/leaderboards', leaderboard);++++ async function dataset(req, res) {++ try {++ const text = await arena.datasetJsonl();++ res.set('Cache-Control', 'public, max-age=60');++ res.set('Content-Disposition', 'attachment; filename="arena-dataset.jsonl"');++ return res.type('application/x-ndjson').send(text);++ } catch (err) { return failed(res, req, err); }++ }++ r.get('/dataset.jsonl', dataset);++ r.get('/dataset', dataset);++++ r.post('/rounds', requireOrigin, requireSubject, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ try {++ const round = await arena.createRound({ category: String(b.category || ''), prompt: b.prompt, subject: subjectOf(req) });++ res.set('Cache-Control', 'private, no-store');++ res.set('Location', `/api/v1/arena/rounds/${round.id}`);++ return res.status(201).json({ round });++ } catch (err) { return failed(res, req, err); }++ });++++ async function readRound(req, res) {++ try {++ const round = await arena.getRound(String(req.params.id || ''));++ if (!round) return problem(res, req, 404, 'arena.round_not_found', 'No such Arena round.');++ res.set('Cache-Control', 'public, max-age=30');++ return res.json({ round });++ } catch (err) { return failed(res, req, err); }++ }++ r.get('/rounds/:id', readRound);++ r.get('/round/:id', readRound);++++ async function nextPair(req, res) {++ try {++ const pair = await arena.nextPair(subjectOf(req), { random: ctx.random || Math.random });++ res.set('Cache-Control', 'private, no-store');++ if (!pair) return res.json({ pair: null, detail: 'There is no round waiting for your vote. Start one, or come back when another is ready.' });++ return res.json(pair);++ } catch (err) { return failed(res, req, err); }++ }++ r.get('/vote', requireSubject, nextPair);++ r.get('/vote/next', requireSubject, nextPair);++ r.get('/next', requireSubject, nextPair);++++ async function castVote(req, res) {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ try {++ const out = await arena.vote({++ roundId: String(b.round_id || b.round || ''),++ subject: subjectOf(req),++ leftOutput: String(b.left_output || b.left || ''),++ rightOutput: String(b.right_output || b.right || ''),++ winner: String(b.winner || ''),++ });++ res.set('Cache-Control', 'private, no-store');++ return res.status(201).json({++ vote: { round: out.round.id, left_output: out.left.id, right_output: out.right.id, winner: out.winner, at: out.at },++ revealed: { left: out.left, right: out.right },++ });++ } catch (err) { return failed(res, req, err); }++ }++ r.post('/vote', requireOrigin, requireSubject, castVote);++ r.post('/votes', requireOrigin, requireSubject, castVote);++++ return r;++}++++module.exports = { createArenaApi, ownOrigin, subjectOf };@@@ -29,6 +29,7 @@ function publicPages(themes = []) {const static_ = [{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },{ path: '/studio', changefreq: 'weekly', priority: 0.9, title: 'The theme studio', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },++ { path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },];const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({path: `/t/${encodeURIComponent(String(t.slug))}`,@@@ -67,11 +68,13 @@ function createDiscoveryRoutes(ctx) {{ title: 'Start here', links: [{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },++ { title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },] },{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },{ title: 'Machine-readable', links: [{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },++ { title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },{ title: 'Sitemap', url: abs('/sitemap.xml') },{ title: 'Release metadata (JSON)', url: abs('/release.json') },] },@@@ -1,19 +1,25 @@'use strict';/**-- * The public pages:-- * / the theme catalogue's front door-- * /studio the theme studio (the shell the main session's public/js/studio.js fills)-- * /t/:slug one public theme-- * /developers the API, for people and agents++ * The pages:++ * / the theme catalogue's front door++ * /studio the theme studio++ * /t/:slug one public theme++ * /arena the Arena leaderboard and its front door++ * /arena/new start a round (the form also posts here, so it works without JavaScript)++ * /arena/vote the next blind pair, and the reveal after a vote++ * /arena/r/:id one round: every output at 390 px and 1280 px, with cost and time++ * /developers the API*-- * Every page is server-rendered and complete without JavaScript. A page for a signed-in person is private and-- * never cached; a public page gets the short 120-second window. `me` comes from the session the SSO middleware-- * already resolved — a guest never causes a Network call to find out they are a guest.++ * Every page is server-rendered. The Arena's model HTML is only ever inside `<iframe sandbox srcdoc>`; the pages++ * themselves never contain it. `me` comes from the session the SSO middleware already resolved.*/++const express = require('express');const views = require('../views');const { asyncRouter } = require('./router');const { send } = require('../render/layout');++const { ArenaError } = require('../arena/store');++const { writeAllowed } = require('./same-origin');function meOf(viewer) {if (!viewer || viewer.kind !== 'user') return { signedIn: false, user: null };@@@ -30,15 +36,21 @@ function meOf(viewer) {};}++const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);++function createPageRoutes(ctx) {-- const { catalog, log } = ctx;++ const { config, catalog, arena, log } = ctx;const r = asyncRouter();-- // The catalogue never throws in a page: a Network failure degrades to the page without its list.const safe = (fn) => Promise.resolve().then(fn).catch((err) => {log.warn('[OpenVibe.Design] page catalogue failed:', (err && err.message) || err);return null;});const page = (res, status, html, viewer) => send(res, status, html, { viewer });++ const categories = arena.CATEGORIES;++ const aria = async (viewer) => {++ const subject = viewer && viewer.kind === 'user' ? viewer.subject : null;++ return subject ? await arena.counts(subject).catch(() => null) : null;++ };r.get('/', async (req, res) => {const list = await safe(() => catalog.list());@@@ -67,6 +79,83 @@ function createPageRoutes(ctx) {return page(res, 200, views.theme({ theme: up.data.theme }), req.viewer);});++ // ── Arena ───────────────────────────────────────────────++ r.get('/arena', async (req, res) => {++ const me = meOf(req.viewer);++ let boards;++ try { boards = await arena.leaderboard(null); } catch (err) {++ log.warn('[OpenVibe.Design] leaderboard failed:', (err && err.message) || err);++ boards = categories.map((category) => ({ category, leaderboard: [] }));++ }++ return page(res, 200, views.arenaHome({ categories: boards, me, limits: await aria(req.viewer) }), req.viewer);++ });++++ r.get('/arena/new', async (req, res) => {++ return page(res, 200, views.arenaNew({ me: meOf(req.viewer), limits: await aria(req.viewer), categories }), req.viewer);++ });++++ r.post('/arena/new', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {++ const me = meOf(req.viewer);++ if (!subjectOf(req)) return res.redirect(303, '/auth/login?next=%2Farena%2Fnew');++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('A round must be started from openvibe.design itself.');++ const b = req.body || {};++ try {++ const round = await arena.createRound({ category: String(b.category || ''), prompt: b.prompt, subject: subjectOf(req) });++ return res.redirect(303, `/arena/r/${encodeURIComponent(round.id)}`);++ } catch (err) {++ const notice = err instanceof ArenaError ? err.detail : 'The round could not be started. Try again in a moment.';++ if (!(err instanceof ArenaError)) log.warn('[OpenVibe.Design] starting a round failed:', (err && err.message) || err);++ return page(res, err instanceof ArenaError ? err.status : 500,++ views.arenaNew({ me, limits: await aria(req.viewer), categories, notice }), req.viewer);++ }++ });++++ r.get('/arena/vote', async (req, res) => {++ const me = meOf(req.viewer);++ let pair = null;++ let notice = null;++ if (subjectOf(req)) {++ try { pair = await arena.nextPair(subjectOf(req), { random: ctx.random || Math.random }); } catch (err) {++ log.warn('[OpenVibe.Design] picking a pair failed:', (err && err.message) || err);++ notice = 'The next pair could not be read. Try again in a moment.';++ }++ }++ return page(res, 200, views.arenaVote({ pair, me, notice }), req.viewer);++ });++++ r.post('/arena/vote', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {++ const me = meOf(req.viewer);++ if (!subjectOf(req)) return res.redirect(303, '/auth/login?next=%2Farena%2Fvote');++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('A vote must come from openvibe.design itself.');++ const b = req.body || {};++ try {++ const out = await arena.vote({++ roundId: String(b.round_id || ''),++ subject: subjectOf(req),++ leftOutput: String(b.left_output || ''),++ rightOutput: String(b.right_output || ''),++ winner: String(b.winner || ''),++ });++ return page(res, 201, views.arenaVote({ me, result: {++ vote: { round: out.round.id, winner: out.winner, at: out.at },++ revealed: { left: out.left, right: out.right },++ } }), req.viewer);++ } catch (err) {++ const notice = err instanceof ArenaError ? err.detail : 'The vote could not be recorded. Try again in a moment.';++ if (!(err instanceof ArenaError)) log.warn('[OpenVibe.Design] vote failed:', (err && err.message) || err);++ return page(res, err instanceof ArenaError ? err.status : 500, views.arenaVote({ me, notice }), req.viewer);++ }++ });++++ r.get('/arena/r/:id', async (req, res) => {++ let round = null;++ try { round = await arena.getRound(String(req.params.id || '')); } catch (err) {++ log.warn('[OpenVibe.Design] round page failed:', (err && err.message) || err);++ }++ if (!round) return page(res, 404, views.notFound(), req.viewer);++ return page(res, 200, views.arenaRound({ round, me: meOf(req.viewer) }), req.viewer);++ });++r.get('/developers', (req, res) => page(res, 200, views.developers(), req.viewer));r.use((req, res) => page(res, 404, views.notFound(), req.viewer));nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++++/**++ * Same-origin writes (CSRF). A write authenticated by the httpOnly session cookie must come from this++ * site: `Sec-Fetch-Site: same-origin`, or an `Origin` equal to BASE_URL's origin. A missing header is++ * refused on a cookie write. A write carrying a Bearer token is not cookie-authenticated and is left++ * alone (a browser never attaches Authorization cross-site by itself); an anonymous request passes++ * through to the route's own 401.++ */++const { http } = require('openvibe-contracts');++const { ACCESS_COOKIE } = require('../auth/sso');++++function ownOrigin(config) {++ try { return new URL(config.baseUrl).origin; } catch { return null; }++}++++function bearer(req) {++ const h = String(req.get('authorization') || '');++ return /^Bearer\s+\S/i.test(h) ? h.replace(/^Bearer\s+/i, '').trim() : null;++}++++function cookieAuthenticated(req) {++ return Boolean(req.cookies && req.cookies[ACCESS_COOKIE]);++}++++/** `Sec-Fetch-Site: same-origin`, else an Origin equal to this site's; both absent is not same-origin. */++function sameOrigin(req, config) {++ const site = String(req.get('sec-fetch-site') || '').trim().toLowerCase();++ if (site === 'same-origin') return true;++ const sent = String(req.get('origin') || '');++ const own = ownOrigin(config);++ return Boolean(sent) && Boolean(own) && sent === own;++}++++/** A cookie write needs a same-origin signal; Bearer and anonymous writes are not cookie writes. */++function writeAllowed(req, config) {++ if (bearer(req)) return true;++ if (!cookieAuthenticated(req)) return true;++ return sameOrigin(req, config);++}++++function requireSameOrigin(config, { detail = 'A change must come from this site itself.', code = 'design.cross_site' } = {}) {++ return (req, res, next) => {++ if (writeAllowed(req, config)) return next();++ return http.sendProblem(res, 403, code, { detail, ctx: req.ov });++ };++}++++module.exports = { ownOrigin, bearer, cookieAuthenticated, sameOrigin, writeAllowed, requireSameOrigin };nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++++/**++ * Account export and deletion → Design's Arena (ADR-033; openvibe-sdk/account-data). The rows a person owns are++ * the rounds they started and the votes they cast. A round's outputs are exported with it; deleting the round++ * cascades its outputs and every vote on it (including other people's votes on that round, which is the right++ * outcome: the round is gone). Ratings are aggregate and name no person, so they stay.++ *++ * A vote's voter_subject is never exported or shown in the open dataset.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const TABLES = [++ { table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },++ { table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },++];++++async function extraExport(db, subject) {++ const rows = await db.many(++ `SELECT o.* FROM arena_outputs o++ JOIN arena_rounds r ON r.id = o."round"++ WHERE r.created_by = $1++ ORDER BY o.created_at DESC LIMIT 5000`,++ [subject],++ );++ return rows.length ? [{ name: 'arena_outputs.json', content: rows }] : [];++}++++function create({ db, log = console } = {}) {++ return createAccountData({++ db,++ service: 'design',++ tables: TABLES,++ extraExport,++ note: 'Arena votes are exported without another voter\'s identity; outputs travel with the round that made them.',++ log,++ });++}++++module.exports = { create, TABLES, TOPICS, extraExport };@@@ -2,14 +2,15 @@/*** OpenVibe.Design — process entry. `node server/index.js` listens on PORT (5090) behind nginx (deploy/).-- * Stateless: the HTTP server and the JWKS refresher are what stop.++ * The Arena's job loop starts here, never under DESIGN_DRILL; the HTTP server, the loop, the JWKS refresher and++ * the database are what stop.*/const { createApp } = require('./app');const { gracefulStop } = require('openvibe-sdk/service');/**-- * The process stop (openvibe-sdk/service): stop taking connections, drain the requests in flight, stop the JWKS-- * refresher, exit. Exported so a test can inject `exit` and `signals: false`.++ * The process stop (openvibe-sdk/service): stop taking connections, drain the requests in flight, stop the Arena++ * loop, the JWKS refresher and the database, then exit. Exported so a test can inject `exit` and `signals: false`.*/function createLifecycle({ server, ctx, exit, signals } = {}) {return gracefulStop({@@@ -19,7 +20,11 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {exit,signals,deadlineMs: 10_000,-- close: [() => ctx.keys.client.stop()],++ close: [++ () => ctx.jobs.stop(),++ () => ctx.keys.client.stop(),++ () => ctx.s.close(),++ ],});}@@@ -27,9 +32,11 @@ async function start() {const { app, ctx } = await createApp();const { config } = ctx;const server = app.listen(config.port, config.host, () => {-- console.log(`[OpenVibe.Design] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (network ${config.networkUrl})`);++ console.log(`[OpenVibe.Design] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (network ${config.networkUrl}, ai ${config.ai.url})`);// Load the Network's signing key now, not on the first sign-in, so /api/ready reports sign-in as it is.ctx.keys.ensure().catch(() => {});++ // Generate queued Arena rounds. DESIGN_DRILL=1 keeps the loop off (a test or a drill drives tick()).++ ctx.jobs.start();});server.keepAliveTimeout = 65_000;createLifecycle({ server, ctx });@@@ -1,22 +1,31 @@'use strict';/**-- * Truthful readiness for GET /api/ready (openvibe-shared/ready). There is no database: the service is stateless.++ * Truthful readiness for GET /api/ready (openvibe-shared/ready). The database is required now that the Arena++ * keeps rounds, outputs and votes; without it the Arena cannot answer and a restart should be visible.*++ * db required PostgreSQL/PGlite answers* network optional OpenVibe.Network answers its theme catalogue* network_jwks optional the Network signing key is loaded; without it nobody can sign in-- * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in needs it)++ * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in and AI tokens need it)*-- * No check is required: if Network does not answer, the process still serves the pages and the crawl artifacts, so-- * the answer is 200 with status "degraded" — never 503 (the same rule OpenVibe.Domains uses for its two upstreams).++ * If only an optional check fails the answer is 200 with status "degraded"; a required check failing is 503.*/const { createReadiness } = require('openvibe-shared/ready');--function createServiceReadiness({ config, catalog, keys = null, release = null }) {++function createServiceReadiness({ s, config, catalog, keys = null, release = null }) {return createReadiness({service: 'design',release,checks: [++ {++ name: 'db',++ required: true,++ check: async () => {++ const r = await s.db.ready();++ return r.ok ? { ok: true, detail: r.detail } : r.error;++ },++ },{name: 'network',required: false,@@@ -24,6 +24,7 @@ const esc = kit.esc;const NAV = [{ label: 'Home', href: '/', icon: 'fa-house' },{ label: 'Studio', href: '/studio', icon: 'fa-palette' },++ { label: 'Arena', href: '/arena', icon: 'fa-crown' },{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },];@@@ -42,7 +43,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',service: 'design',apiBase: NETWORK,links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && String(active).startsWith(`${l.href}/`)) })),-- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Studio', href: '/studio', icon: 'fa-palette' }],++ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Studio', href: '/studio', icon: 'fa-palette' }, { label: 'Arena', href: '/arena', icon: 'fa-crown' }],history: { type: 'page', title: title || NAME },silentLogin: `${BASE}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',@@@ -15,6 +15,7 @@ const layoutLib = require('../render/layout');const kit = require('openvibe-shared/kit');const sc = require('openvibe-shared/showcase');const rooms = require('openvibe-shared/rooms');++const { iframeHtml } = require('../arena/sanitize');const { layout, esc, NAME } = layoutLib;const ROOM = rooms.get('design');@@@ -153,6 +154,146 @@ function developers() {return layout({ title: 'The Design API', path: '/developers', body: `<div class="ds-wrap">${body}</div>`, active: '/developers' });}++const ARENA_CATEGORY = {++ landing: 'Landing page',++ dashboard: 'Dashboard',++ mobile_app: 'Mobile app',++ game_ui: 'Game UI',++ logo: 'Logo',++ slides: 'Slides',++};++++const arenaCost = (v) => `$${Number(v || 0).toFixed(4)}`;++const arenaMs = (v) => (Number(v || 0) >= 1000 ? `${(Number(v) / 1000).toFixed(1)} s` : `${Math.round(Number(v) || 0)} ms`);++const arenaPct = (v) => `${(Number(v || 0) * 100).toFixed(1)}%`;++++/** One output at one viewport width, inside the sandboxed, CSP-capped iframe. */++function arenaShot(o, width) {++ const phone = Number(width) === 390;++ return `<div class="ar-shot ${phone ? 'ar-shot--phone' : 'ar-shot--desktop'}" data-width="${phone ? 390 : 1280}">++<div class="ar-shot-frame">${iframeHtml({++ html: o.html || '',++ width: phone ? 390 : 1280,++ height: phone ? 844 : 800,++ title: `${o.model ? `${o.model} ` : ''}at ${phone ? 390 : 1280} pixels`,++ id: `ar-frame-${String(o.id || 'x').replace(/[^a-z0-9_-]/gi, '')}-${phone ? 390 : 1280}`,++ className: phone ? 'ar-frame--phone' : 'ar-frame--desktop',++ })}</div>++<p class="ar-shot-label">${phone ? 'Phone · 390 px' : 'Desktop · 1280 px'}</p></div>`;++}++++// Which viewport the pair (or the round's outputs) shows. The radios belong to no form, so a vote never sends them.++function arenaViewSwitch() {++ return `<div class="ar-view" role="radiogroup" aria-label="Viewport">++<input type="radio" id="ar-view-desktop" name="ar_view" value="desktop" form="ar-view-none" checked><label for="ar-view-desktop">Desktop</label>++<input type="radio" id="ar-view-phone" name="ar_view" value="phone" form="ar-view-none"><label for="ar-view-phone">Phone</label></div>`;++}++++function arenaLeaderboard(category, rows) {++ const body = rows.length ? rows.map((r, i) => `<tr class="ar-lb-row" data-model="${esc(r.model)}">++<td class="ar-lb-rank">${i + 1}</td>++<td class="ar-lb-model">${esc(r.model)}</td>++<td class="ar-lb-elo">${Number(r.elo).toFixed(0)}</td>++<td class="ar-lb-games">${r.games}</td>++<td class="ar-lb-winrate">${arenaPct(r.win_rate)}</td>++<td class="ar-lb-cost">${r.median_cost_usd == null ? '—' : arenaCost(r.median_cost_usd)}</td>++<td class="ar-lb-ms">${r.median_ms == null ? '—' : arenaMs(r.median_ms)}</td></tr>`).join('') : '<tr class="ar-lb-empty"><td colspan="7">No votes yet. Be the first to judge this category.</td></tr>';++ return `<section class="ar-lb" id="ar-lb-${esc(category)}" aria-labelledby="ar-lb-h-${esc(category)}">++<div class="ar-lb-head"><h2 id="ar-lb-h-${esc(category)}">${esc(ARENA_CATEGORY[category] || category)}</h2><a class="ar-more" href="/arena/vote">Vote on this category</a></div>++<table class="ar-table"><thead><tr><th scope="col">#</th><th scope="col">Model</th><th scope="col">Elo</th><th scope="col">Games</th><th scope="col">Win rate</th><th scope="col">Median cost</th><th scope="col">Median time</th></tr></thead>++<tbody>${body}</tbody></table></section>`;++}++++function arenaHome({ categories = [], me = {}, limits = null } = {}) {++ const signedIn = Boolean(me.signedIn);++ const boards = categories.map((c) => arenaLeaderboard(c.category, c.leaderboard || [])).join('');++ const rows = categories.flatMap((c) => c.leaderboard || []);++ const body = `<div class="ar-page" id="ar-home">++<header class="ar-hero"><p class="ar-kicker">Design Arena</p><h1>Which model designs it best?</h1>++<p class="ar-lede">One prompt goes to three models. They each build the same page; you see two at phone and desktop sizes, vote blind, and an Elo leaderboard per category ranks the designers, with the cost and time of every generation.</p>++<div class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/new">Start a round</a> <a class="ovk-btn ovk-btn--quiet" href="/arena/vote">Vote blind</a></div>++<p class="ar-fine" id="ar-limits">${signedIn++ ? (limits ? `You have ${limits.remaining} of ${limits.per_day} rounds left today. The Arena has ${limits.site_remaining} of ${limits.site_daily} left.` : 'Signed in: you can start a round or vote.')++ : 'Sign in to start a round or vote. The leaderboard and the dataset are open to everyone.'}</p></header>++<section class="ar-boards" id="ar-leaderboard" aria-labelledby="ar-boards-h"><div class="ar-lb-head"><h2 id="ar-boards-h">The leaderboard</h2><span class="ar-fine">${rows.length} model${rows.length === 1 ? '' : 's'} ranked · K = 32</span></div>++${boards || '<p class="ar-empty">No rounds have been judged yet. Start the first one.</p>'}++<p class="ar-fine">A win rate counts a tie as half a win. <code>both_bad</code> is a game for neither model. The cost and time are the median over the outputs in this category.</p></section>++<section class="ar-open" id="ar-open"><h2>Everything is open</h2><p class="ar-lede">The prompts, every generated page and every vote are downloadable. No voter is named.</p>++<p><a class="ovk-btn ovk-btn--quiet" href="/api/v1/arena/dataset.jsonl">Download dataset.jsonl</a></p></section>++</div>`;++ return layout({ title: 'The Design Arena', description: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.', path: '/arena', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });++}++++function arenaNew({ me = {}, limits = null, categories = [], notice = null } = {}) {++ const signedIn = Boolean(me.signedIn);++ const options = (categories.length ? categories : Object.keys(ARENA_CATEGORY)).map((c) => `<option value="${esc(c)}">${esc(ARENA_CATEGORY[c] || c)}</option>`).join('');++ const body = `<div class="ar-page" id="ar-new">++<header class="ar-page-h"><p class="ar-kicker">Start a round</p><h1>Describe the page</h1>++<p class="ar-lede">Three models will build it from this prompt. You cannot vote on a round you started, so the vote stays blind.</p></header>++${notice ? `<p class="ar-notice" id="ar-new-notice" role="alert">${esc(notice)}</p>` : ''}++${signedIn ? '' : `<p class="ar-notice" id="ar-signin">${kit.button({ href: '/auth/login?next=%2Farena%2Fnew', label: 'Sign in to start a round' })}</p>`}++<form class="ar-form" id="ar-new-form" method="post" action="/arena/new">++<label>Category <select id="ar-category" name="category" ${signedIn ? '' : 'disabled'}>${options}</select></label>++<label>Prompt <textarea id="ar-prompt" name="prompt" rows="8" maxlength="4000" required ${signedIn ? '' : 'disabled'} placeholder="A landing page for a tiny coffee roastery in Seattle: warm, editorial, one product shot and a subscribe form."></textarea></label>++<div class="ar-actions"><button type="submit" class="ovk-btn ovk-btn--primary" ${signedIn ? '' : 'disabled'}>Start the round</button><a class="ovk-btn ovk-btn--quiet" href="/arena">Back to the Arena</a></div>++<p class="ar-status" id="ar-new-status" role="status"></p>++<p class="ar-fine" id="ar-new-limits">${limits ? `${limits.remaining} of ${limits.per_day} rounds left for you today; ${limits.site_remaining} of ${limits.site_daily} for the whole site.` : 'A signed-in person may start a few rounds a day; the whole site has a daily cap too.'}</p>++</form></div>`;++ return layout({ title: 'Start an Arena round', description: 'Describe a page once; three AI designers build it and the Arena ranks them.', path: '/arena/new', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });++}++++function arenaVote({ pair = null, me = {}, result = null, notice = null } = {}) {++ const signedIn = Boolean(me.signedIn);++ let body;++ if (!signedIn) {++ body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind</p><h1>Sign in to judge</h1><p class="ar-lede">The two designs are shown without their models. Sign in to record one vote per round; you cannot vote on a round you started.</p></header><p>${kit.button({ href: '/auth/login?next=%2Farena%2Fvote', label: 'Sign in to vote' })}</p></div>`;++ } else if (result) {++ const left = result.revealed.left;++ const right = result.revealed.right;++ body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote recorded</p><h1>The models are revealed</h1><p class="ar-lede">You chose <b>${esc(result.vote.winner === 'both_bad' ? 'both bad' : result.vote.winner)}</b>. The Elo leaderboard has been updated.</p></header>++<div class="ar-reveal" id="ar-reveal"><div class="ar-reveal-side" id="ar-reveal-left"><h2>Left</h2><p><b>${esc(left.model)}</b>${left.provider ? ` · ${esc(left.provider)}` : ''}</p><p class="ar-fine">${arenaCost(left.cost_usd)} · ${arenaMs(left.ms)} · ${left.tokens} tokens</p></div>++<div class="ar-reveal-side" id="ar-reveal-right"><h2>Right</h2><p><b>${esc(right.model)}</b>${right.provider ? ` · ${esc(right.provider)}` : ''}</p><p class="ar-fine">${arenaCost(right.cost_usd)} · ${arenaMs(right.ms)} · ${right.tokens} tokens</p></div></div>++<p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/vote">Vote on another</a> <a class="ovk-btn ovk-btn--quiet" href="/arena/r/${encodeURIComponent(result.vote.round)}">See the round</a></p></div>`;++ } else if (!pair) {++ body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind</p><h1>Nothing waiting</h1><p class="ar-lede">${esc(notice || 'There is no ready round you have not voted on. Start one, or come back when another is ready.')}</p></header><p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/new">Start a round</a> <a class="ovk-btn ovk-btn--quiet" href="/arena">The leaderboard</a></p></div>`;++ } else {++ body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind · ${esc(ARENA_CATEGORY[pair.round.category] || pair.round.category)}</p><h1>Which design is better?</h1><p class="ar-lede">${esc(pair.round.prompt)}</p><p class="ar-fine">The models are hidden until you vote. Compare at phone and desktop sizes.</p></header>++<form id="ar-vote-form" class="ar-vote" method="post" action="/arena/vote">++<input type="hidden" id="ar-vote-round" name="round_id" value="${esc(pair.round.id)}">++<input type="hidden" id="ar-vote-left-id" name="left_output" value="${esc(pair.left.id)}">++<input type="hidden" id="ar-vote-right-id" name="right_output" value="${esc(pair.right.id)}">++${arenaViewSwitch()}++<div class="ar-vote-pair" id="ar-vote-pair">++<section class="ar-vote-side" id="ar-vote-left" data-side="left"><h2>Left</h2>${arenaShot(pair.left, 390)}${arenaShot(pair.left, 1280)}</section>++<section class="ar-vote-side" id="ar-vote-right" data-side="right"><h2>Right</h2>${arenaShot(pair.right, 390)}${arenaShot(pair.right, 1280)}</section></div>++<div class="ar-vote-actions" id="ar-vote-actions"><button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="left" data-winner="left">Left is better</button>++<button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="right" data-winner="right">Right is better</button>++<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="tie" data-winner="tie">It is a tie</button>++<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="both_bad" data-winner="both_bad">Both are bad</button></div>++<p class="ar-status" id="ar-vote-status" role="status"></p></form></div>`;++ }++ return layout({ title: 'Vote blind · The Design Arena', description: 'Two generated designs, no model names: vote for the better one.', path: '/arena/vote', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });++}++++function arenaRound({ round, me = {} } = {}) {++ const outputs = round.outputs || [];++ const ok = outputs.filter((o) => !o.error);++ const totalCost = ok.reduce((a, o) => a + Number(o.cost_usd || 0), 0);++ const totalMs = ok.reduce((a, o) => a + Number(o.ms || 0), 0);++ const cards = outputs.map((o, i) => `<li class="ar-output" id="ar-output-${esc(o.id)}" data-model="${esc(o.model)}">++<div class="ar-output-h"><h2>${esc(o.model)}</h2><p class="ar-fine">${esc(o.provider || 'unknown provider')} · ${arenaCost(o.cost_usd)} · ${arenaMs(o.ms)} · ${o.tokens} tokens</p></div>++${o.error ? `<p class="ar-output-error" role="status">This model did not finish: ${esc(o.error)}</p>` : `<div class="ar-output-shots">${arenaShot(o, 390)}${arenaShot(o, 1280)}</div>`}</li>`).join('');++ const body = `<div class="ar-page" id="ar-round" data-round="${esc(round.id)}">++<header class="ar-page-h"><p class="ar-kicker">${esc(ARENA_CATEGORY[round.category] || round.category)} · Arena round</p><h1>${esc(String(round.prompt).slice(0, 240))}</h1>++<p class="ar-meta" id="ar-round-meta">Status: <b>${esc(round.status)}</b> · ${outputs.length} model${outputs.length === 1 ? '' : 's'} · ${arenaCost(totalCost)} · ${arenaMs(totalMs)} in generation · ${round.votes || 0} vote${Number(round.votes) === 1 ? '' : 's'}</p>++<p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/vote">Vote blind</a> <a class="ovk-btn ovk-btn--quiet" href="/arena">The leaderboard</a> <a class="ovk-btn ovk-btn--quiet" href="/api/v1/arena/rounds/${encodeURIComponent(round.id)}">This round as JSON</a></p></header>++${round.status === 'queued' || round.status === 'generating' ? '<p class="ar-notice" id="ar-round-wait">The models are working. This page refreshes itself.</p>' : ''}++${round.pool_fallback ? '<p class="ar-notice" id="ar-round-pool">Fewer than three distinct offers were available for this round, so the rest came from the routing pool.</p>' : ''}++${outputs.some((o) => !o.error) ? arenaViewSwitch() : ''}++<ul class="ar-outputs" id="ar-outputs">${cards || '<li class="ar-empty">No output yet.</li>'}</ul></div>`;++ return layout({ title: `${ARENA_CATEGORY[round.category] || round.category}: an Arena round`, description: `Three AI designs for: ${round.prompt.slice(0, 140)}`, path: `/arena/r/${encodeURIComponent(round.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'], robots: 'noindex, follow' });++}++function notFound() {const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/studio">Open the studio</a> or <a href="/">browse the themes</a>.</p></header>`;return layout({ title: 'Not found', robots: 'noindex, follow', body: `<div class="ds-wrap">${body}</div>`, active: '/' });@@@ -160,6 +301,7 @@ function notFound() {module.exports = {home, studio, theme, developers, notFound,++ arenaHome, arenaNew, arenaVote, arenaRound, ARENA_CATEGORY, arenaShot,layout,setRelease: layoutLib.setRelease,configure: layoutLib.configure,nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++/**++ * ADR-033: the Arena's part of an account export and deletion, through /internal/events with the SDK's signed++ * delivery and a stand-in Network. A's rounds and outputs and A's votes are exported; B's identity is not; deleting++ * A removes their rounds (cascading every vote on them) and their votes, and a redelivery changes nothing.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++++const A = 'usr_01JZ0000000000000000000AAA';++const B = 'usr_01JZ0000000000000000000BBB';++const EXP = 'exp_01JZ0000000000000000000EXP';++const DEL = 'del_01JZ0000000000000000000DEX';++const SECRET = `fixture_${'arena'.repeat(8)}`;++const WRONG = `fixture_${'wrong'.repeat(8)}`;++const ORIGIN = 'https://openvibe.design';++++const exportEvent = { event_id: 'evt_01JZ0000000000000000000E01', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };++const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D01', event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };++const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });++++(async () => {++ const calls = [];++ const sender = async (path, body) => { calls.push({ path, body }); return { ok: true, status: 201, json: async () => ({}) }; };++ const t = await boot({ env: { DESIGN_EVENTS_SECRET: SECRET }, accountSend: sender });++ const ana = t.network.addUser('ana');++ const bo = t.network.addUser('bo');++ try {++ // A starts a round (then it is given A's fixed account subject), and B votes on it.++ const aRound = (await t.get('/api/v1/arena/rounds', { as: ana, json: { category: 'landing', prompt: 'A round to export' }, origin: ORIGIN })).json().round;++ await t.tick();++ await t.ctx.s.db.exec('UPDATE arena_rounds SET created_by = $1 WHERE id = $2', [A, aRound.id]);++ const aOutputs = (await t.ctx.arena.getRound(aRound.id)).outputs;++ const pair = await t.ctx.arena.nextPair(bo.subject);++ const vote = await t.get('/api/v1/arena/vote', { as: bo, json: { round_id: pair.round.id, left_output: pair.left.id, right_output: pair.right.id, winner: 'left' }, origin: ORIGIN });++ assert.strictEqual(vote.status, 201, vote.text);++++ // B starts a round and A's vote is stored on it directly (A is a fixture account, not a Network token here).++ const bRound = (await t.get('/api/v1/arena/rounds', { as: bo, json: { category: 'dashboard', prompt: 'B round stays' }, origin: ORIGIN })).json().round;++ await t.tick();++ const bOutputs = (await t.ctx.arena.getRound(bRound.id)).outputs;++ await t.ctx.s.db.exec(`INSERT INTO arena_votes ("round", voter_subject, left_output, right_output, winner) VALUES ($1, $2, $3, $4, 'tie')`,++ [bRound.id, A, bOutputs[0].id, bOutputs[1].id]);++++ await check('an export carries A\'s rounds, their outputs and A\'s votes, never B\'s identity', async () => {++ const raw = bodyOf(exportEvent);++ const res = await t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'exported');++ const part = calls.find((c) => c.path === `/internal/account-exports/${EXP}/parts`);++ assert.ok(part, 'the part was pushed to Network');++ assert.strictEqual(part.body.subject, A);++ const names = part.body.files.map((f) => f.name).sort();++ assert.deepStrictEqual(names, ['arena_outputs.json', 'arena_rounds.json', 'arena_votes.json']);++ const all = JSON.stringify(part.body);++ assert.ok(all.includes('A round to export'));++ const outputsFile = part.body.files.find((f) => f.name === 'arena_outputs.json');++ assert.ok(outputsFile.content.some((o) => o.html === aOutputs[0].html), 'the output HTML travels with the round');++ assert.ok(all.includes(bRound.id), 'A\'s vote on B\'s round is exported');++ assert.ok(!all.includes(B), 'another voter is never named');++ assert.ok(!all.includes(bo.subject), 'another account is never named');++ });++++ await check('a deletion erases A\'s rounds (with their outputs and votes) and A\'s votes; B\'s round stays', async () => {++ const raw = bodyOf(deleteEvent);++ const res = await t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'erased');++ const db = t.ctx.s.db;++ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_rounds WHERE id = $1', [aRound.id])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_outputs WHERE "round" = $1', [aRound.id])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_votes WHERE voter_subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_votes WHERE "round" = $1', [aRound.id])), 0, 'B\'s vote on A\'s round went with the round');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_rounds WHERE id = $1', [bRound.id])), 1, 'B\'s round stays');++ const confirmation = calls.find((c) => c.path === `/internal/account-deletions/${DEL}/confirmations`);++ assert.ok(confirmation, 'the confirmation was sent');++ assert.ok(confirmation.body.erased.arena_rounds >= 1);++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));++ });++++ await check('a redelivery changes nothing; the internal route refuses a bad signature and a forwarded request', async () => {++ const before = Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM arena_rounds'));++ const raw = bodyOf(deleteEvent);++ const again = await t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json().outcome, 'unchanged');++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM arena_rounds')), before);++ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'content-type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });++ assert.strictEqual(bad.status, 401);++ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(forwarded.status, 403);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++/**++ * migration 0001_arena.sql: the five tables the Arena and ADR-033 need, their columns, checks, defaults and++ * the one-vote-per-person primary key.++ */++const assert = require('assert');++const { testDb } = require('./helpers/db');++const { check, done } = require('./helpers/boot');++++(async () => {++ const t = await testDb();++ const db = t.db;++ try {++ await check('the Arena tables and their columns exist', async () => {++ const cols = async (table) => (await db.many('SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 ORDER BY ordinal_position', [table])).map((r) => r.column_name);++ assert.deepStrictEqual(await cols('arena_rounds'), ['id', 'category', 'prompt', 'created_by', 'status', 'offers', 'pool_fallback', 'created_at']);++ assert.deepStrictEqual(await cols('arena_outputs'), ['id', 'round', 'model', 'provider', 'html', 'cost_usd', 'ms', 'tokens', 'error', 'ui_check', 'created_at']);++ assert.deepStrictEqual(await cols('arena_votes'), ['round', 'voter_subject', 'left_output', 'right_output', 'winner', 'at']);++ assert.deepStrictEqual(await cols('arena_ratings'), ['category', 'model', 'elo', 'games', 'wins', 'losses', 'ties', 'updated_at']);++ assert.deepStrictEqual(await cols('account_data_events'), ['id', 'kind', 'subject', 'outcome', 'applied_at', 'sent_at']);++ });++++ await check('rounds default to queued and ratings to Elo 1000', async () => {++ await db.exec("INSERT INTO arena_rounds (id, category, prompt, created_by) VALUES ('arr_A', 'landing', 'p', 'usr_A')");++ const r = await db.one("SELECT status, created_at FROM arena_rounds WHERE id = 'arr_A'");++ assert.strictEqual(r.status, 'queued');++ assert.ok(!Number.isNaN(Date.parse(r.created_at)));++ await db.exec("INSERT INTO arena_ratings (category, model) VALUES ('landing', 'model-a')");++ const rt = await db.one("SELECT elo, games, wins, losses, ties FROM arena_ratings WHERE category = 'landing' AND model = 'model-a'");++ assert.strictEqual(Number(rt.elo), 1000);++ assert.deepStrictEqual([rt.games, rt.wins, rt.losses, rt.ties], [0, 0, 0, 0]);++ });++++ await check('categories and statuses are checked', async () => {++ await assert.rejects(() => db.exec("INSERT INTO arena_rounds (id, category, prompt, created_by) VALUES ('arr_B', 'poster', 'p', 'usr_A')"), /check|constraint/i);++ await assert.rejects(() => db.exec("INSERT INTO arena_rounds (id, category, prompt, created_by, status) VALUES ('arr_C', 'landing', 'p', 'usr_A', 'cooking')"), /check|constraint/i);++ });++++ await check('one vote per person per round is a primary-key refusal', async () => {++ await db.exec("INSERT INTO arena_outputs (id, \"round\", model, provider, html) VALUES ('aro_L', 'arr_A', 'model-a', 'p', '<h1>L</h1>'), ('aro_R', 'arr_A', 'model-b', 'p', '<h1>R</h1>')");++ await db.exec("INSERT INTO arena_votes (\"round\", voter_subject, left_output, right_output, winner) VALUES ('arr_A', 'usr_V', 'aro_L', 'aro_R', 'left')");++ await assert.rejects(() => db.exec("INSERT INTO arena_votes (\"round\", voter_subject, left_output, right_output, winner) VALUES ('arr_A', 'usr_V', 'aro_R', 'aro_L', 'right')"), (err) => err.code === '23505');++ });++++ await check('deleting a round cascades its outputs and votes', async () => {++ await db.exec("DELETE FROM arena_rounds WHERE id = 'arr_A'");++ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_outputs')), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_votes')), 0);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++/**++ * The output safety rules: the sanitizer strips scripts, event handlers and javascript: URLs and caps at 200 KB;++ * the only renderer is a strict sandboxed iframe whose srcdoc carries a CSP that blocks every network fetch.++ */++const assert = require('assert');++const { sanitizeHtml, frameDocument, iframeHtml, MAX_BYTES } = require('../server/arena/sanitize');++const { check, done } = require('./helpers/boot');++++(async () => {++ await check('scripts, event handlers and javascript: URLs never reach storage', async () => {++ const cases = [++ '<h1>ok</h1><script>alert(1)</script>',++ '<SCRIPT SRC="https://evil.example/x.js"></SCRIPT><p>after</p>',++ '<script>one</script><script>two</script>',++ '<p>before<script src=x>unclosed',++ '<img src="x" onerror="alert(1)">',++ '<svg onload=alert(1)><circle/></svg>',++ '<div onclick = "x" OnMouseOver="y">safe</div>',++ '<img alt="x>y" onerror="alert(1)">',++ '<a href="javascript:alert(1)">x</a>',++ '<a href="JaVaScRiPt:alert(1)">x</a>',++ '<a href="java\nscript:alert(1)">x</a>',++ '<a href="javascript:alert(1)">x</a>',++ '<a href="java	script:alert(1)">x</a>',++ '<a href="java	script:alert(1)">x</a>',++ ];++ for (const input of cases) {++ const out = sanitizeHtml(input);++ assert.ok(!/<script/i.test(out), `script remains in ${JSON.stringify(out)}`);++ assert.ok(!/\son[a-z]+\s*=/i.test(out), `handler remains in ${JSON.stringify(out)}`);++ assert.ok(!/javascript\s*:/i.test(out), `javascript: remains in ${JSON.stringify(out)}`);++ }++ assert.match(sanitizeHtml('<h1>keep me</h1><script>bad()</script>'), /keep me/);++ });++++ await check('a page larger than 200 KB is cut to the cap', async () => {++ const huge = `<style>${'body{color:red}'.repeat(20_000)}</style><p>${'a'.repeat(300 * 1024)}</p>`;++ const out = sanitizeHtml(huge);++ assert.ok(Buffer.byteLength(out, 'utf8') <= MAX_BYTES, `${Buffer.byteLength(out, 'utf8')} bytes`);++ assert.strictEqual(MAX_BYTES, 200 * 1024);++ });++++ await check('the frame document carries the blocking CSP before any model content', async () => {++ const doc = frameDocument('<h1>x</h1><script>bad()</script>');++ assert.ok(doc.startsWith('<meta charset="utf-8">'));++ assert.match(doc, /<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">/);++ assert.ok(!/<script/i.test(doc));++ });++++ await check('iframe markup is sandboxed with no scripts and no same-origin, and cannot break out', async () => {++ const html = iframeHtml({ html: '<h1>hello</h1>', width: 390, height: 844, title: 'phone' });++ assert.match(html, /<iframe /);++ assert.match(html, /sandbox=""/);++ assert.doesNotMatch(html, /allow-scripts/);++ assert.doesNotMatch(html, /allow-same-origin/);++ assert.match(html, /width="390"/);++ assert.match(html, /srcdoc="/);++ const evil = iframeHtml({ html: '</iframe><script>window.top.location="https://evil.example"</script>', width: 1280 });++ assert.strictEqual((evil.match(/<iframe /g) || []).length, 1);++ assert.ok(!evil.includes('</iframe><script>'));++ assert.match(evil, /width="1280"/);++ assert.match(evil, /default-src 'none'/);++ });++++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,342 @@++'use strict';++/**++ * The Arena end to end: a round generates three outputs through the job, the sanitizer is applied before storage,++ * the budgets refuse in words, the pair is blind, one vote per person, the creator cannot vote, Elo handles a win,++ * a tie and both_bad, the leaderboard reports games/win rate/medians, and the dataset names no voter.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const ORIGIN = 'https://openvibe.design';++++(async () => {++ const t = await boot({ random: () => 0, env: { DESIGN_ARENA_PER_DAY: '8', DESIGN_ARENA_DAILY: '40' } });++ const ana = t.network.addUser('ana');++ const bo = t.network.addUser('bo');++ const cy = t.network.addUser('cy');++++ const create = (user, category = 'landing', prompt = 'A landing page for a tiny coffee roastery in Seattle', extra = {}) => t.get('/api/v1/arena/rounds', {++ as: user, json: { category, prompt }, origin: ORIGIN, ...extra,++ });++ const getRound = async (id) => (await t.get(`/api/v1/arena/rounds/${id}`)).json().round;++ // Extra rounds would compete with A for the next blind pair; the checks that make them clean up after themselves.++ const dropRounds = async (...ids) => { for (const id of ids.filter(Boolean)) await t.ctx.s.db.exec('DELETE FROM arena_rounds WHERE id = $1', [id]); };++++ let A;++ await check('a round is queued by the request, and no AI call happens in the request', async () => {++ const before = t.ai.requests.length;++ const r = await create(ana);++ assert.strictEqual(r.status, 201, r.text);++ A = r.json().round;++ assert.strictEqual(A.status, 'queued');++ assert.deepStrictEqual(A.outputs, []);++ assert.strictEqual(t.ai.requests.length, before, 'the job, not the request, calls AI');++ });++++ await check('the job generates three outputs through AI with its reported model, provider, cost, time and tokens', async () => {++ const ready = await t.tick();++ assert.ok(ready, 'a queued round was claimed');++ assert.strictEqual(ready.id, A.id);++ assert.strictEqual(ready.status, 'ready');++ assert.strictEqual(ready.outputs.length, 3);++ const models = ready.outputs.map((o) => o.model);++ const providers = ready.outputs.map((o) => o.provider);++ assert.deepStrictEqual(models, ['alpha-1', 'beta-2', 'gamma-3']);++ assert.deepStrictEqual(providers, ['acme', 'beta', 'gamma']);++ for (const o of ready.outputs) {++ assert.ok(o.id.startsWith('aro_'));++ assert.strictEqual(o.error, null);++ assert.match(o.html, /^<!doctype html>/i);++ assert.strictEqual(o.cost_usd, 0.0012);++ assert.strictEqual(o.ms, 321);++ assert.strictEqual(o.tokens, 600);++ }++ assert.strictEqual(ready.pool_fallback, false, 'three offers were listed, so no pool fallback');++ assert.deepStrictEqual(ready.offers, [++ { provider: 'acme', model: 'alpha-1' },++ { provider: 'beta', model: 'beta-2' },++ { provider: 'gamma', model: 'gamma-3' },++ ]);++ const posts = t.ai.requests.filter((r) => r.method === 'POST' && r.path === '/api/v1/generate');++ assert.strictEqual(posts.length, 3);++ for (const [i, p] of posts.entries()) {++ assert.ok(p.body.options.cache === false);++ assert.strictEqual(p.body.options.model, undefined, 'the pin is a route, not an options.model hint');++ assert.deepStrictEqual(p.body.route, ready.offers[i], 'each slot asks for one distinct offer');++ assert.match(p.body.prompt, /coffee roastery/);++ assert.strictEqual(p.body.target.service, 'design');++ }++ });++++ await check('Design asked Network for a client-credentials token for openvibe.ai with ai.run.create', async () => {++ const token = t.network.state.tokenRequests.find((f) => f.grant_type === 'client_credentials');++ assert.ok(token, 'a client-credentials grant was made');++ assert.strictEqual(token.client_id, 'design');++ assert.strictEqual(token.audience, 'openvibe.ai');++ assert.strictEqual(token.scope, 'ai.run.create');++ assert.ok(t.ai.state.tokenClaims);++ assert.ok(t.ai.state.tokenClaims.aud.includes('openvibe.ai'));++ assert.ok(t.ai.state.tokenClaims.cap.includes('ai.run.create'));++ assert.ok(t.ai.state.tokenClaims.ns.includes('ai.*'));++ });++++ await check('the sanitizer runs before storage, including the 200 KB cap', async () => {++ t.ai.state.htmlFor = () => '<h1>safe</h1><script>alert(1)</script><img src="x" onerror="alert(2)"><a href="javascript:alert(3)">x</a>';++ const r = await create(ana, 'logo', 'A mark with a script');++ const scripted = r.json().round.id;++ await t.tick();++ const round = await getRound(scripted);++ for (const o of round.outputs) {++ assert.match(o.html, /safe/);++ assert.ok(!/<script/i.test(o.html));++ assert.ok(!/\sonerror\s*=/i.test(o.html));++ assert.ok(!/javascript\s*:/i.test(o.html));++ }++ t.ai.state.htmlFor = () => `<p>${'x'.repeat(300 * 1024)}</p>`;++ const big = await create(ana, 'logo', 'A big page');++ const bigId = big.json().round.id;++ await t.tick();++ const bigRound = await getRound(bigId);++ for (const o of bigRound.outputs) assert.ok(Buffer.byteLength(o.html) <= 200 * 1024, `${Buffer.byteLength(o.html)} bytes`);++ t.ai.state.htmlFor = null;++ await dropRounds(scripted, bigId);++ });++++ await check('bad input and a cross-site Origin are refused in words', async () => {++ const signedOut = await t.get('/api/v1/arena/rounds', { json: { category: 'landing', prompt: 'x' } });++ assert.strictEqual(signedOut.status, 401);++ const badCat = await create(ana, 'poster');++ assert.strictEqual(badCat.status, 422);++ assert.match(badCat.json().detail, /category/i);++ const empty = await create(ana, 'landing', ' ');++ assert.strictEqual(empty.status, 422);++ assert.match(empty.json().detail, /describe/i);++ const cross = await create(ana, 'landing', 'x', { origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ assert.strictEqual(cross.json().code, 'design.cross_site');++ });++++ await check('a cookie write needs same-origin: missing headers are refused, Sec-Fetch-Site or Origin passes', async () => {++ const missing = await create(ana, 'landing', 'no header', { origin: null });++ assert.strictEqual(missing.status, 403);++ assert.strictEqual(missing.json().code, 'design.cross_site');++ assert.match(missing.json().detail, /openvibe\.design itself/);++ const byFetchSite = await create(ana, 'landing', 'fetch metadata', { origin: null, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(byFetchSite.status, 201, byFetchSite.text);++ const crossSite = await create(ana, 'landing', 'cross site', { origin: null, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(crossSite.status, 403);++ // A Bearer write is not cookie-authenticated: no Origin is needed.++ const bearer = await t.get('/api/v1/arena/rounds', {++ method: 'POST', json: { category: 'landing', prompt: 'a bearer write' },++ headers: { authorization: `Bearer ${t.network.userToken(ana)}` },++ });++ assert.strictEqual(bearer.status, 201, bearer.text);++ await dropRounds(byFetchSite.json().round.id, bearer.json().round.id);++ });++++ await check('the no-JS forms enforce the same policy', async () => {++ const form = { 'content-type': 'application/x-www-form-urlencoded' };++ const body = 'category=landing&prompt=a+form+round';++ const missing = await t.get('/arena/new', { as: ana, method: 'POST', body, headers: form });++ assert.strictEqual(missing.status, 403);++ const same = await t.get('/arena/new', { as: ana, method: 'POST', body, headers: { ...form, 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(same.status, 303, same.text);++ assert.match(same.headers.get('location'), /^\/arena\/r\/arr_/);++ const cross = await t.get('/arena/new', { as: ana, method: 'POST', body, headers: { ...form, origin: 'https://evil.example' } });++ assert.strictEqual(cross.status, 403);++ await dropRounds(String(same.headers.get('location')).split('/').pop());++ });++++ let winner;++ let loser;++ await check('the pair is blind: no model, provider, cost or time before the vote', async () => {++ const pair = await t.get('/api/v1/arena/vote', { as: bo });++ assert.strictEqual(pair.status, 200, pair.text);++ const p = pair.json();++ assert.strictEqual(p.round.id, A.id);++ assert.deepStrictEqual(Object.keys(p.left).sort(), ['html', 'id']);++ assert.deepStrictEqual(Object.keys(p.right).sort(), ['html', 'id']);++ assert.ok(!JSON.stringify(p).includes('alpha-1'));++ assert.ok(!JSON.stringify(p).includes('beta-2'));++ const creator = await t.get('/api/v1/arena/vote', { as: ana });++ assert.strictEqual(creator.json().pair, null, 'the creator cannot be offered their own round');++ t.state = p;++ });++++ await check('the creator cannot vote on their own round', async () => {++ const p = t.state;++ const r = await t.get('/api/v1/arena/vote', { as: ana, json: { round_id: A.id, left_output: p.left.id, right_output: p.right.id, winner: 'left' }, origin: ORIGIN });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'arena.creator_cannot_vote');++ });++++ await check('bo votes once; the models are revealed and the vote is recorded', async () => {++ const p = t.state;++ const r = await t.get('/api/v1/arena/vote', { as: bo, json: { round_id: A.id, left_output: p.left.id, right_output: p.right.id, winner: 'left' }, origin: ORIGIN });++ assert.strictEqual(r.status, 201, r.text);++ const out = r.json();++ assert.strictEqual(out.vote.winner, 'left');++ assert.ok(out.revealed.left.model);++ assert.ok(out.revealed.right.model);++ const leftModel = out.revealed.left.model;++ const rightModel = out.revealed.right.model;++ assert.notStrictEqual(leftModel, rightModel);++ winner = leftModel;++ loser = rightModel;++ const again = await t.get('/api/v1/arena/vote', { as: bo, json: { round_id: A.id, left_output: p.left.id, right_output: p.right.id, winner: 'right' }, origin: ORIGIN });++ assert.strictEqual(again.status, 409);++ assert.strictEqual(again.json().code, 'arena.already_voted');++ });++++ await check('the leaderboard updates Elo K=32, games and win rate, with median cost and time', async () => {++ const lb = (await t.get('/api/v1/arena/leaderboard?category=landing')).json().leaderboard;++ const w = lb.find((r) => r.model === winner);++ const l = lb.find((r) => r.model === loser);++ assert.ok(w && l);++ assert.strictEqual(w.elo, 1016);++ assert.strictEqual(l.elo, 984);++ assert.strictEqual(w.games, 1);++ assert.strictEqual(w.wins, 1);++ assert.strictEqual(w.losses, 0);++ assert.strictEqual(w.win_rate, 1);++ assert.strictEqual(l.games, 1);++ assert.strictEqual(l.wins, 0);++ assert.strictEqual(l.losses, 1);++ assert.strictEqual(l.win_rate, 0);++ assert.strictEqual(w.median_cost_usd, 0.0012);++ assert.strictEqual(w.median_ms, 321);++ });++++ await check('a tie is half a win for each; both_bad is a game for neither', async () => {++ const b = (await create(bo, 'dashboard', 'An analytics dashboard')).json().round;++ // Earlier same-origin tests queued rounds too: drain until this one is generated.++ let bRound;++ for (let i = 0; i < 12; i++) {++ bRound = await getRound(b.id);++ if (bRound.status === 'ready' || bRound.status === 'failed') break;++ await t.tick();++ }++ assert.strictEqual(bRound.status, 'ready');++ const tie = await t.ctx.arena.vote({ roundId: b.id, subject: cy.subject, leftOutput: bRound.outputs[0].id, rightOutput: bRound.outputs[1].id, winner: 'tie' });++ assert.strictEqual(tie.winner, 'tie');++ const lb = (await t.get('/api/v1/arena/leaderboard?category=dashboard')).json().leaderboard;++ const tied = lb.filter((r) => [bRound.outputs[0].model, bRound.outputs[1].model].includes(r.model));++ for (const row of tied) {++ assert.strictEqual(row.games, 1);++ assert.strictEqual(row.ties, 1);++ assert.strictEqual(row.wins, 0);++ assert.strictEqual(row.win_rate, 0.5);++ assert.strictEqual(row.elo, 1000, 'equal ratings tie to the same Elo');++ }++++ const c = (await create(cy, 'game_ui', 'A game HUD')).json().round;++ await t.tick();++ const cRound = await getRound(c.id);++ const before = new Map(lb.map((r) => [r.model, r.games]));++ const both = await t.ctx.arena.vote({ roundId: c.id, subject: ana.subject, leftOutput: cRound.outputs[0].id, rightOutput: cRound.outputs[1].id, winner: 'both_bad' });++ assert.strictEqual(both.winner, 'both_bad');++ const after = (await t.get('/api/v1/arena/leaderboard?category=game_ui')).json().leaderboard;++ for (const row of after) {++ assert.strictEqual(row.games, 0, 'both_bad counted a game for neither');++ assert.strictEqual(row.elo, 1000);++ assert.strictEqual(row.ties, 0);++ }++ assert.ok(before.size >= 0);++ });++++ await check('the dataset has prompts, output HTML and votes, and no voter ids', async () => {++ const ds = await t.get('/api/v1/arena/dataset.jsonl');++ assert.strictEqual(ds.status, 200);++ assert.match(ds.headers.get('content-type'), /ndjson|jsonl/);++ const lines = ds.text.trim().split('\n').map((l) => JSON.parse(l));++ assert.ok(lines.length >= 3);++ const a = lines.find((r) => r.id === A.id);++ assert.strictEqual(a.prompt, 'A landing page for a tiny coffee roastery in Seattle');++ assert.strictEqual(a.outputs.length, 3);++ assert.ok(a.outputs.every((o) => typeof o.html === 'string' && o.html.length));++ assert.strictEqual(a.votes.length, 1);++ assert.strictEqual(a.votes[0].winner, 'left');++ assert.ok(!ds.text.includes(ana.subject));++ assert.ok(!ds.text.includes(bo.subject));++ assert.ok(!ds.text.includes(cy.subject));++ assert.ok(!/voter_subject|"voter"/.test(ds.text));++ });++++ await check('the pages render the leaderboard, the form and the frames with the CSP and both widths', async () => {++ const home = await t.get('/arena');++ assert.strictEqual(home.status, 200);++ assert.match(home.text, /id="ar-leaderboard"/);++ assert.match(home.text, /id="ar-lb-landing"/);++ assert.match(home.text, new RegExp(winner.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));++ const round = await t.get(`/arena/r/${A.id}`);++ assert.strictEqual(round.status, 200);++ assert.match(round.text, /sandbox=""/);++ assert.match(round.text, /default-src 'none'/);++ assert.match(round.text, /width="390"/);++ assert.match(round.text, /width="1280"/);++ assert.match(round.text, /Phone · 390 px/);++ assert.match(round.text, /Desktop · 1280 px/);++ assert.doesNotMatch(round.text, /allow-scripts/);++ assert.doesNotMatch(round.text, /allow-same-origin/);++ const form = await t.get('/arena/new', { as: ana });++ assert.match(form.text, /id="ar-new-form"/);++ assert.match(form.text, /id="ar-category"/);++ assert.match(form.text, /id="ar-prompt"/);++ const anonVote = await t.get('/arena/vote');++ assert.match(anonVote.text, /Sign in to vote/);++ const gone = await t.get('/arena/r/nope');++ assert.strictEqual(gone.status, 404);++ });++++ await check('only when fewer than three offers are listed does a round fall back to the pool, and say so', async () => {++ const two = await boot({ aiOptions: { models: [++ { provider: 'acme', model: 'alpha-1' },++ { provider: 'beta', model: 'beta-2' },++ ] } });++ try {++ const u = two.network.addUser('few');++ const r = await two.get('/api/v1/arena/rounds', { as: u, json: { category: 'landing', prompt: 'Two offers only' }, origin: ORIGIN });++ assert.strictEqual(r.status, 201, r.text);++ const done = await two.tick();++ assert.strictEqual(done.status, 'ready');++ assert.strictEqual(done.pool_fallback, true);++ assert.deepStrictEqual(done.offers, [++ { provider: 'acme', model: 'alpha-1' },++ { provider: 'beta', model: 'beta-2' },++ { provider: 'ai', model: null },++ ]);++ const posts = two.ai.requests.filter((x) => x.method === 'POST' && x.path === '/api/v1/generate');++ assert.strictEqual(posts.length, 3);++ assert.deepStrictEqual(posts[0].body.route, { provider: 'acme', model: 'alpha-1' });++ assert.deepStrictEqual(posts[1].body.route, { provider: 'beta', model: 'beta-2' });++ assert.strictEqual(posts[2].body.route, undefined, 'the third slot is the routing pool');++ const page = await two.get(`/arena/r/${done.id}`);++ assert.match(page.text, /id="ar-round-pool"/);++ assert.match(page.text, /Fewer than three distinct offers/);++ } finally { await two.close(); }++ });++++ await check('the create page refuses over the person budget in words and keeps a 429', async () => {++ const small = await boot({ env: { DESIGN_ARENA_PER_DAY: '2', DESIGN_ARENA_DAILY: '3' } });++ try {++ const u = small.network.addUser('limit');++ const v = small.network.addUser('other');++ assert.strictEqual((await small.get('/api/v1/arena/rounds', { as: u, json: { category: 'landing', prompt: 'one' }, origin: ORIGIN })).status, 201);++ assert.strictEqual((await small.get('/api/v1/arena/rounds', { as: u, json: { category: 'landing', prompt: 'two' }, origin: ORIGIN })).status, 201);++ const refused = await small.get('/api/v1/arena/rounds', { as: u, json: { category: 'landing', prompt: 'three' }, origin: ORIGIN });++ assert.strictEqual(refused.status, 429);++ assert.strictEqual(refused.json().code, 'arena.limit_person');++ assert.match(refused.json().detail, /You can start 2 Arena rounds a day/);++ const page = await small.get('/arena/new', { as: u });++ assert.match(page.text, /0 of 2 rounds left/);++ assert.strictEqual((await small.get('/api/v1/arena/rounds', { as: v, json: { category: 'landing', prompt: 'v one' }, origin: ORIGIN })).status, 201);++ const site = await small.get('/api/v1/arena/rounds', { as: v, json: { category: 'landing', prompt: 'v two' }, origin: ORIGIN });++ assert.strictEqual(site.status, 429);++ assert.strictEqual(site.json().code, 'arena.limit_site');++ assert.match(site.json().detail, /reached its 3 rounds for today/);++ } finally { await small.close(); }++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++++/**++ * An in-process stand-in for OpenVibe.AI: the chat route's offers and the `generate` operation, in the shapes++ * server/api/runs.js returns. It verifies the client-credentials token Design got from the Network stub++ * (audience openvibe.ai, capability ai.run.create), records every request, and can fail, delay or answer++ * asynchronously. Tests read state.requests and state.tokenClaims.++ */++const http = require('http');++const crypto = require('crypto');++++const DEFAULT_MODELS = [++ { provider: 'acme', model: 'alpha-1' },++ { provider: 'beta', model: 'beta-2' },++ { provider: 'gamma', model: 'gamma-3' },++];++++function payload(token) {++ const part = String(token || '').split('.')[1];++ if (!part) return null;++ try { return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); } catch { return null; }++}++++function listen(handler) {++ return new Promise((resolve) => {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj, headers = {}) => {++ res.writeHead(status, { 'Content-Type': 'application/json', ...headers });++ res.end(JSON.stringify(obj));++ };++ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { code: 'stub.error', detail: String(err && err.message) }));++ });++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({++ server,++ url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ }));++ });++}++++async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.0012, ms = 321, tokensIn = 120, tokensOut = 480, failModels = [], asyncMode = false, routesStatus = 200 } = {}) {++ const st = {++ models: models.map((m) => ({ ...m })),++ requests: [], routeRequests: 0, lastAuth: null, tokenClaims: null,++ costUsd, ms, tokensIn, tokensOut,++ failModels: new Set(failModels), asyncMode, routesStatus,++ delayMs: 0, runCount: 0, runs: new Map(), htmlFor: null,++ };++++ function routeFor(body) {++ const asked = body && body.route;++ if (!asked || typeof asked !== 'object') return null;++ return st.models.find((x) => x.provider === asked.provider && (asked.model == null || x.model === asked.model)) || false;++ }++++ function modelFor(body) {++ const hit = routeFor(body);++ if (hit) return hit;++ const i = st.runCount % st.models.length;++ return st.models[i] || { provider: 'stub', model: 'stub-1' };++ }++++ const srv = await listen(async (req, raw, json) => {++ const url = new URL(req.url, 'http://x');++ const text = raw.toString('utf8');++ let body = {};++ try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }++ const auth = String(req.headers.authorization || '');++ st.lastAuth = auth || null;++ const claims = auth.startsWith('Bearer ') ? payload(auth.slice(7)) : null;++ if (claims) st.tokenClaims = claims;++ if (!claims || !(claims.aud || []).includes('openvibe.ai') || !(claims.cap || []).includes('ai.run.create')) {++ return json(401, { code: 'token.invalid', detail: 'a Design client-credentials token with ai.run.create is required' });++ }++ if (st.delayMs) await new Promise((r) => setTimeout(r, st.delayMs));++++ if (url.pathname === '/api/v1/routes' && req.method === 'GET') {++ st.routeRequests++;++ if (st.routesStatus !== 200) return json(st.routesStatus, { code: 'route.unavailable', detail: 'routes are down' });++ const [first, ...rest] = st.models;++ return json(200, {++ routes: [{++ key: 'default.chat', version: 3, status: 'active', capability: 'chat',++ primary: { provider: first.provider, model: first.model },++ fallbacks: rest.map((m) => ({ provider: m.provider, model: m.model })),++ pinned: [], constraints: {}, alias_of: null,++ // AI's routing lists the offers for the operation; Design pins one per slot.++ offers: st.models.map((m) => ({ provider: m.provider, model: m.model })),++ }],++ });++ }++++ if (url.pathname === '/api/v1/generate' && req.method === 'POST') {++ st.requests.push({ method: 'POST', path: url.pathname, query: Object.fromEntries(url.searchParams), body, claims });++ if (body.route && !routeFor(body)) {++ return json(422, { code: 'input.invalid', detail: `route ${body.route.provider}/${body.route.model} is not offered` });++ }++ const pick = modelFor(body);++ st.runCount++;++ const id = `run_${crypto.randomBytes(8).toString('hex')}`;++ if (st.failModels.has(pick.model)) {++ return json(502, { code: 'provider.unavailable', detail: `${pick.model} is down` });++ }++ const make = st.htmlFor || htmlFor;++ const html = make++ ? make(body, pick, st)++ : `<!doctype html><html><head><meta charset="utf-8"><title>A generated design</title></head><body><h1>A page that works</h1><p>${String(body.prompt || '').split('\n')[0].slice(0, 200)}</p><button>Start</button></body></html>`;++ const started = new Date(Date.now() - st.ms).toISOString();++ const finished = new Date().toISOString();++ const run = {++ id, status: 'succeeded', workflow: { key: 'ai.generate', version: 1 },++ provenance: { origin: 'ai', workflow: 'ai.generate', provider: pick.provider, model: pick.model, run_id: id },++ output: { text: html },++ usage: { tokens_in: st.tokensIn, tokens_out: st.tokensOut, cost_usd: st.costUsd, attempts: 1 },++ started_at: started, finished_at: finished, created_at: started,++ };++ st.runs.set(id, run);++ if (st.asyncMode) return json(202, { run: { ...run, status: 'running', output: null, usage: {} } }, { Location: `/api/v1/runs/${id}` });++ return json(201, { run });++ }++++ const one = /^\/api\/v1\/runs\/([^/]+)$/.exec(url.pathname);++ if (one && req.method === 'GET') {++ st.requests.push({ method: 'GET', path: url.pathname, claims });++ const run = st.runs.get(decodeURIComponent(one[1]));++ if (!run) return json(404, { code: 'run.not_found' });++ return json(200, { run });++ }++ return json(404, { code: 'not_found', detail: 'not found' });++ });++++ return {++ url: srv.url,++ state: st,++ models: st.models,++ requests: st.requests,++ close: srv.close,++ };++}++++module.exports = { startAi, DEFAULT_MODELS };@@@ -1,23 +1,33 @@'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way.++process.env.DESIGN_RATE_LIMIT_PER_MIN = process.env.DESIGN_RATE_LIMIT_PER_MIN || '100000';/**-- * Boots OpenVibe.Design on a random port against an in-process Network (test/helpers/network.js), and returns a-- * small HTTP client. Every test file gets its own process, Network and app.++ * Boots OpenVibe.Design on a temp database with a mock Network and AI, captures log lines, and returns a small++ * HTTP client. Every test file gets its own process, database, Network, AI and app.** const t = await boot();-- * t.get(path, { as: user, cookie, json, headers, method })++ * t.get(path, { as: user, json, headers, method, origin })* t.signIn(user) → cookie header value (design_at = a Network user token, as after /auth/callback)-- * t.logs(), t.close()++ * await t.tick() → drive the Arena job loop once++ * t.logs(), t.dbDump()*/const http = require('http');const { startNetwork } = require('./network');++const { startAi } = require('./ai');async function boot(opts = {}) {const network = await startNetwork(opts.network || {});++ const ai = opts.ai || await startAi(opts.aiOptions || {});const env = {NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.design', TRUST_PROXY: '1',OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url, OV_NETWORK_ISSUER: network.url,OV_OAUTH_CLIENT_ID: 'design', OV_OAUTH_CLIENT_SECRET: 'design-secret', COOKIE_SECURE: 'false',++ OV_AI_INTERNAL_URL: ai.url,DESIGN_RATE_LIMIT_PER_MIN: '100000',++ DESIGN_DRILL: '1',++ DESIGN_AI_POLL_MS: '10',++ DESIGN_AI_WAIT_MS: '5000',++ DESIGN_ARENA_JOB_INTERVAL_MS: '100000',...(opts.env || {}),};for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];@@@ -32,8 +42,11 @@ async function boot(opts = {}) {const configLib = require('../../server/config');const { createApp } = require('../../server/app');++ const { createStore } = require('../../server/db');const config = configLib.load(env);-- const built = createApp({ config, log, now: opts.now });++ const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });++ const store = opts.store || createStore(testdb.db, { now: opts.now });++ const built = await createApp({ config, store, arenaAi: opts.arenaAi, log, now: opts.now, accountSend: opts.accountSend, random: opts.random });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -46,23 +59,37 @@ async function boot(opts = {}) {if (o.cookie) headers.cookie = o.cookie;let body = o.body;if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }-- const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });-- const text = Buffer.from(await res.arrayBuffer()).toString('utf8');++ const method = o.method || (body ? 'POST' : 'GET');++ if (o.origin) headers.origin = o.origin;++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });++ const buf = Buffer.from(await res.arrayBuffer());++ const text = buf.toString('utf8');return {status: res.status,headers: res.headers,text,++ buffer: buf,json() { return JSON.parse(text); },};}++ async function dbDump() {++ const db = built.ctx.s.db;++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');++ }++return {-- base, network, config, ctx: built.ctx, get, signIn,++ base, network, ai, config, ctx: built.ctx, store: built.ctx.s, get, signIn, dbDump,++ tick: () => built.ctx.jobs.tick(),logs: () => logLines.join('\n'),async close() {++ built.ctx.jobs.stop();await new Promise((r) => server.close(r));built.ctx.keys.client.stop();++ if (testdb) await testdb.close();await network.close();++ if (!opts.ai) await ai.close();},};}nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * DESIGN_TEST_STORE=pg the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++const testDb = ({ store = process.env.DESIGN_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'design', max });++++module.exports = { testDb, pgAvailable };@@@ -104,6 +104,16 @@ async function startNetwork({ themes = DEFAULT_THEMES } = {}) {st.refresh.set(rt, u);return json(200, { access_token: userToken(u), refresh_token: rt, token_type: 'Bearer' });}++ if (form.grant_type === 'client_credentials') {++ // A first-party service token: audience openvibe.ai, capability ai.run.create, namespace ai.*.++ const scope = String(form.scope || '');++ const now = Math.floor(Date.now() / 1000);++ const access = signJwt({++ iss: issuer, sub: 'svc:design', actor_type: 'service', aud: [form.audience || 'openvibe.ai'],++ cap: scope.split(/\s+/).filter(Boolean), ns: ['ai.*'], iat: now, exp: now + 300,++ }, privatePem);++ return json(200, { access_token: access, token_type: 'Bearer', expires_in: 300, scope });++ }return json(400, { error: 'unsupported_grant_type' });}@@@ -81,6 +81,19 @@ const ORIGIN = 'https://openvibe.design';assert.strictEqual(missing.status, 403, 'no Origin is not this site');});++ await check('a cookie write may say same-origin with Sec-Fetch-Site; a Bearer write needs no Origin', async () => {++ const site = await t.get('/api/me/theme', {++ ...signedIn, method: 'PUT', json: { theme_id: 'paper' }, headers: { 'sec-fetch-site': 'same-origin' },++ });++ assert.strictEqual(site.status, 200, site.text);++ const bearer = await t.get('/api/me/theme', {++ method: 'PUT', json: { theme_id: 'aurora' }, headers: { authorization: `Bearer ${token}` },++ });++ assert.strictEqual(bearer.status, 200, bearer.text);++ const sent = writes('/api/themes/me').pop();++ assert.strictEqual(sent.authorization, `Bearer ${token}`, 'the Bearer token is forwarded, not the cookie');++ });++await check('a Network 422 answer passes through with its status and body', async () => {const r = await t.get('/api/themes/submit', {...signedIn,
@@ -23,3 +23,32 @@ OV_OAUTH_CLIENT_SECRET=
@@ -23,3 +23,32 @@ OV_OAUTH_CLIENT_SECRET=
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# COOKIE_SECURE=true
+
+# The Arena's PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
+# In development without DATABASE_URL an embedded PGlite database in data/pglite is used.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+# DESIGN_PGLITE_DIR=
+
+# OpenVibe.AI: the generate operation, called as Design with a client-credentials token.
+# Network grants scope ai.run.create (audience openvibe.ai, namespace ai.generate).
+OV_AI_INTERNAL_URL=http://127.0.0.1:4700
+# DESIGN_AI_AUDIENCE=openvibe.ai
+# DESIGN_AI_SCOPE=ai.run.create
+# DESIGN_AI_WAIT_MS=55000
+# DESIGN_AI_TIMEOUT_MS=120000
+# DESIGN_AI_POLL_MS=1500
+# Three explicit provider:model slots; unset pins AI's generate-route offers (fewer than three falls back to its pool).
+# DESIGN_ARENA_MODELS=shared:gpt-5-mini,local:qwen2.5
+
+# Arena budgets (per UTC day) and generation jobs.
+# DESIGN_ARENA_PER_DAY=3
+# DESIGN_ARENA_DAILY=20
+# DESIGN_ARENA_JOB_INTERVAL_MS=1500
+# The drill flag keeps the generation loop off; a test or a drill calls tick() itself.
+# DESIGN_DRILL=0
+
+# OpenVibe.Events → this service for ADR-033 account export/deletion; unset turns the route off (503).
+# DESIGN_EVENTS_SECRET=
+# DESIGN_EVENTS_URL=
+# DESIGN_EVENTS_ENDPOINT=
@@ -2,20 +2,22 @@
@@ -2,20 +2,22 @@
> Make it yours, find the best AI designer.
> Make it yours, find the best AI designer.
-**Status:** v1, the theme studio (v0.1.0, see [STATUS.json](STATUS.json)).
+**Status:** v2, the theme studio and the Design Arena (v0.1.0, see [STATUS.json](STATUS.json)).
**Domain:** `openvibe.design` · **Port:** 5090 · **Service id:** `design` · **Env prefix:** `DESIGN`
**Domain:** `openvibe.design` · **Port:** 5090 · **Service id:** `design` · **Env prefix:** `DESIGN`
**License:** AGPL-3.0-only (the same as every OpenVibe service).
**License:** AGPL-3.0-only (the same as every OpenVibe service).
OpenVibe.Design is the Design room: the theme studio where a person browses OpenVibe's themes, tries them on
OpenVibe.Design is the Design room: the theme studio where a person browses OpenVibe's themes, tries them on
previews, edits one with contrast checks, uses it, submits it to the gallery, and rolls back to any earlier
previews, edits one with contrast checks, uses it, submits it to the gallery, and rolls back to any earlier
-setting. Theme selection stays in OpenVibe.Network; this service signs the person in and proxies their calls to
-the Network's theme API.
+setting, and the Design Arena where one prompt goes to three AI models, two outputs are judged blind at phone and
+desktop sizes, and an Elo leaderboard per category ranks the designers with the cost and time of every generation.
+Theme selection stays in OpenVibe.Network; this service signs the person in and proxies their calls to the
+Network's theme API.
## What it is
## What it is
-- **Identity:** service id `design`, name OpenVibe.Design, port 5090, host 127.0.0.1. It stores no accounts, no
- database and no tokens: the person's Network session lives in httpOnly cookies and their theme lives in
- OpenVibe.Network.
+- **Identity:** service id `design`, name OpenVibe.Design, port 5090, host 127.0.0.1. It stores no accounts and
+ no tokens: the person's Network session lives in httpOnly cookies and their theme lives in OpenVibe.Network. The
+ Arena's rounds, outputs, votes and Elo ratings live in this service's PostgreSQL database (PGlite in development).
- **Sign-in:** an OAuth 2 client of OpenVibe.Network (`design`), authorization code with PKCE S256, a state check
- **Sign-in:** an OAuth 2 client of OpenVibe.Network (`design`), authorization code with PKCE S256, a state check
and a server-side code exchange.
and a server-side code exchange.
- **The theme API proxy:** every theme read and write goes to OpenVibe.Network's `/api/themes`, with the person's
- **The theme API proxy:** every theme read and write goes to OpenVibe.Network's `/api/themes`, with the person's
@@ -34,6 +36,30 @@ the Network's theme API.
@@ -34,6 +36,30 @@ the Network's theme API.
- **Use, share, roll back:** *Use* is `PUT /api/me/theme` (source `studio`), *Share it in the gallery* submits the
- **Use, share, roll back:** *Use* is `PUT /api/me/theme` (source `studio`), *Share it in the gallery* submits the
theme for review, and *History* lists the person's earlier looks with a one-tap *Restore*.
theme for review, and *History* lists the person's earlier looks with a one-tap *Restore*.
+## The Design Arena
+
+- **A round:** a signed-in person writes one prompt in one of six categories (landing, dashboard, mobile app, game
+ UI, logo, slides). It is queued; the job loop reads the offers on OpenVibe.AI's `generate` routing and pins three
+ distinct ones (`route: { provider, model }`), and each output stores the HTML (sanitized), the provider and model AI
+ reports, its cost, its measured time and its tokens. When AI offers fewer than three, the remaining slots go to its
+ pool and the round says so (`pool_fallback: true`).
+- **Budgets:** `DESIGN_ARENA_PER_DAY` (default 3) rounds per person per UTC day and `DESIGN_ARENA_DAILY` (default 20)
+ for the whole site. A refusal says which budget was reached and when to come back.
+- **Safety:** the model's page is text until it is rendered, and then only inside `<iframe sandbox srcdoc>` with no
+ `allow-scripts` and no `allow-same-origin`. A CSP meta in the srcdoc blocks every network fetch
+ (`default-src 'none'; style-src 'unsafe-inline'; img-src data:`). Scripts, `on*` handlers and `javascript:` URLs
+ are stripped before storage, and an output is capped at 200 KB.
+- **Voting:** `/arena/vote` serves two successful outputs in random order with the model names hidden. One vote per
+ person per round; the round's creator cannot vote. A vote reveals both models and updates the category's Elo
+ ratings (K = 32, a tie is 0.5, `both_bad` is a game for neither).
+- **Leaderboard:** per category: model, Elo, games, wins, losses, ties, win rate, median cost and median time.
+- **Open dataset:** `GET /api/v1/arena/dataset.jsonl` is one JSON object per round with its prompt, every output
+ (metadata and HTML) and every vote without a voter id.
+- **AI access:** Design is a first-party service and calls AI with a client-credentials token from Network:
+ audience `openvibe.ai`, scope `ai.run.create` (and the `ai.generate` namespace in the grant). It pins one of that
+ routing's listed offers per slot; AI refuses anything it does not offer in words, and a pin never bypasses its
+ quotas or budgets. Generation is off under `DESIGN_DRILL=1`; the job loop starts with the process otherwise.
+
## Pages
## Pages
| Route | What it is |
| Route | What it is |
@@ -41,6 +67,10 @@ the Network's theme API.
@@ -41,6 +67,10 @@ the Network's theme API.
| `/` | the theme catalogue's front door |
| `/` | the theme catalogue's front door |
| `/studio` | the theme studio; includes `/js/studio.js` and `/css/design.css` |
| `/studio` | the theme studio; includes `/js/studio.js` and `/css/design.css` |
| `/t/:slug` | one public theme (Network 404 → the 404 page) |
| `/t/:slug` | one public theme (Network 404 → the 404 page) |
+| `/arena` | the Arena leaderboard per category and the way to start a round |
+| `/arena/new` | the start-a-round form (works without JavaScript) |
+| `/arena/vote` | the next blind pair, and the reveal after a vote |
+| `/arena/r/:id` | one round: every output at 390 px and 1280 px, with cost and time |
| `/developers` | the API, for people and agents |
| `/developers` | the API, for people and agents |
| anything else | the 404 page |
| anything else | the 404 page |
@@ -50,26 +80,34 @@ JavaScript. A page rendered for a signed-in person is `private, no-store`; a pub
@@ -50,26 +80,34 @@ JavaScript. A page rendered for a signed-in person is `private, no-store`; a pub
## API
## API
-Everything is proxied to `config.networkUrl` (`OV_NETWORK_INTERNAL_URL`, default `http://127.0.0.1:4000`) under
-`/api/themes`. Errors are RFC 9457 `application/problem+json`. Reads are public; writes need the session and this
-site's own `Origin` header.
+The theme surface is proxied to `config.networkUrl` (`OV_NETWORK_INTERNAL_URL`, default `http://127.0.0.1:4000`)
+under `/api/themes`; the Arena's own data is under `/api/v1/arena`. Errors are RFC 9457
+`application/problem+json`. Arena reads are public; writes need the session and a same-origin signal
+(`Sec-Fetch-Site: same-origin`, else an `Origin` equal to the site). A missing signal is refused on a cookie write;
+a Bearer write is not cookie-authenticated and is unaffected.
| Route | Auth | What it does |
| Route | Auth | What it does |
|---|---|---|
|---|---|---|
| `GET /api/themes` | none | the public catalogue; cached in memory 5 minutes |
| `GET /api/themes` | none | the public catalogue; cached in memory 5 minutes |
| `GET /api/themes/:idOrSlug` | none | one public theme |
| `GET /api/themes/:idOrSlug` | none | one public theme |
| `GET /api/me/theme` | cookie | Network `GET /api/themes/me/active` → `{ theme_id, theme, custom_variables }`; 401 when signed out |
| `GET /api/me/theme` | cookie | Network `GET /api/themes/me/active` → `{ theme_id, theme, custom_variables }`; 401 when signed out |
-| `PUT /api/me/theme` | cookie + Origin | Network `PUT /api/themes/me` with `{ theme_id, custom_variables, source: "studio" }` |
+| `PUT /api/me/theme` | cookie + same-origin | Network `PUT /api/themes/me` with `{ theme_id, custom_variables, source: "studio" }` |
| `GET /api/me/theme/history` | cookie | Network `GET /api/themes/me/history` |
| `GET /api/me/theme/history` | cookie | Network `GET /api/themes/me/history` |
-| `POST /api/me/theme/history/:id/restore` | cookie + Origin | Network's restore; `id` must be a positive integer, else 400 |
-| `POST /api/themes/submit` | cookie + Origin | Network `POST /api/themes` with `{ name, slug, description, mode, variables, tags }` |
+| `POST /api/me/theme/history/:id/restore` | cookie + same-origin | Network's restore; `id` must be a positive integer, else 400 |
+| `POST /api/themes/submit` | cookie + same-origin | Network `POST /api/themes` with `{ name, slug, description, mode, variables, tags }` |
+| `POST /api/v1/arena/rounds` | cookie + same-origin | queue a round `{ category, prompt }` (subject to the two daily budgets) |
+| `GET /api/v1/arena/rounds/:id` | none | one round with its outputs, cost and time |
+| `GET /api/v1/arena/vote` | cookie | the next blind pair for this person (models hidden) |
+| `POST /api/v1/arena/vote` | cookie + same-origin | `{ round_id, left_output, right_output, winner }`; reveals both models and updates Elo |
+| `GET /api/v1/arena/leaderboard?category=…` | none | Elo, games, win rate, median cost and median time |
+| `GET /api/v1/arena/dataset.jsonl` | none | prompts, outputs (HTML included) and votes without voters |
- A Network **4xx** answer passes through with its status and body; a Network failure (no answer, timeout, 5xx)
- A Network **4xx** answer passes through with its status and body; a Network failure (no answer, timeout, 5xx)
is a `502 network.unavailable` problem.
is a `502 network.unavailable` problem.
-- Every write (`PUT`, `POST`) whose `Origin` is not this site's own origin is refused with `403 design.cross_site`.
+- A cookie-authenticated write (`PUT`, `POST`) without `Sec-Fetch-Site: same-origin` or this site's `Origin` is refused with `403 design.cross_site`; a Bearer write is unaffected.
- Everything under `/api/me/*` carries `Cache-Control: private, no-store`.
- Everything under `/api/me/*` carries `Cache-Control: private, no-store`.
-Plumbing: `GET /api/health`, `GET /api/ready` (200 `ready` or `degraded`; Network, JWKS and the OAuth client are
+Plumbing: `GET /api/health`, `GET /api/ready` (the database is required; Network, JWKS and the OAuth client are
optional checks), `GET /release.json`, `POST /release-metrics`, `/shared/*` (the pinned Frame), `/css/*` and
optional checks), `GET /release.json`, `POST /release-metrics`, `/shared/*` (the pinned Frame), `/css/*` and
`/js/*` (content-hashed `?v=` → immutable), `GET /robots.txt`, `GET /sitemap.xml` (the public themes),
`/js/*` (content-hashed `?v=` → immutable), `GET /robots.txt`, `GET /sitemap.xml` (the public themes),
`GET /llms.txt` and `GET /metrics` (loopback only).
`GET /llms.txt` and `GET /metrics` (loopback only).
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"repository": "OpenVibers/OpenVibe.Design",
"repository": "OpenVibers/OpenVibe.Design",
"version": "0.1.0",
"version": "0.1.0",
- "stage": "v1",
+ "stage": "v2",
"code": true,
"code": true,
"wave": "W20",
"wave": "W20",
- "plan": "OpenVibe.Design: the Design room's theme studio. Browse OpenVibe's themes, try them on previews, edit one with contrast checks, use it, submit it to the gallery, and roll back to any earlier setting. Theme selection stays in OpenVibe.Network; this service signs the person in and proxies their calls.",
- "runtime": "node 22, express 4, openvibe-shared/shell; stateless (no database, no Valkey, no Events)",
+ "plan": "OpenVibe.Design: the Design room's theme studio. Browse OpenVibe's themes, try them on previews, edit one with contrast checks, use it, submit it to the gallery, and roll back to any earlier setting. Theme selection stays in OpenVibe.Network; this service signs the person in and proxies their calls. v2 adds the Design Arena: one prompt, three AI models, blind votes and an Elo leaderboard per category, with cost and time per generation and an open dataset.",
+ "runtime": "node 22, express 4, openvibe-shared/shell; PostgreSQL (or embedded PGlite in development), OpenVibe.AI generate jobs, OpenVibe.Events account data in, no Valkey",
"port": "5090",
"port": "5090",
"domain": "openvibe.design",
"domain": "openvibe.design",
"contracts": "openvibe-contracts v0.227.0",
"contracts": "openvibe-contracts v0.227.0",
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"openvibe-sdk v0.70.0",
"openvibe-sdk v0.70.0",
"openvibe-shared v4.33.0"
"openvibe-shared v4.33.0"
],
],
- "summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore.",
+ "summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.",
"works": [
"works": [
"Sign-in with OpenVibe.Network: OAuth 2 authorization code, PKCE S256, state check, server-side exchange, httpOnly design_at/design_rt cookies, sanitizeNext (own origins only, no control characters or backslashes)",
"Sign-in with OpenVibe.Network: OAuth 2 authorization code, PKCE S256, state check, server-side exchange, httpOnly design_at/design_rt cookies, sanitizeNext (own origins only, no control characters or backslashes)",
"The theme API proxy: GET /api/themes (5-minute in-memory cache), GET /api/themes/:idOrSlug, GET/PUT /api/me/theme, GET /api/me/theme/history, POST /api/me/theme/history/:id/restore, POST /api/themes/submit",
"The theme API proxy: GET /api/themes (5-minute in-memory cache), GET /api/themes/:idOrSlug, GET/PUT /api/me/theme, GET /api/me/theme/history, POST /api/me/theme/history/:id/restore, POST /api/themes/submit",
@@ -25,7 +25,14 @@
@@ -25,7 +25,14 @@
"Crawl artifacts (robots.txt, sitemap.xml with the public themes, llms.txt), /api/health, /api/ready (degraded when Network does not answer), /release.json, /release-metrics, /shared/*",
"Crawl artifacts (robots.txt, sitemap.xml with the public themes, llms.txt), /api/health, /api/ready (degraded when Network does not answer), /release.json, /release-metrics, /shared/*",
"Helmet with the network's CSP, per-IP 120/min on /api and /auth, content-hashed /css and /js, graceful stop",
"Helmet with the network's CSP, per-IP 120/min on /api and /auth, content-hashed /css and /js, graceful stop",
"Deploy files: openvibe-design.service, nginx vhost for openvibe.design on 127.0.0.1:5090",
"Deploy files: openvibe-design.service, nginx vhost for openvibe.design on 127.0.0.1:5090",
- "Tests: sanitizeNext, the login redirect and callback, /auth/me, the proxy (cache, 401, CSRF, pass-through, down), the pages and the crawl artifacts, health and readiness"
+ "Tests: sanitizeNext, the login redirect and callback, /auth/me, the proxy (cache, 401, CSRF, pass-through, down), the pages and the crawl artifacts, health and readiness",
+ "The Design Arena API: POST/GET /api/v1/arena/rounds, GET /vote and POST /vote, GET /leaderboard?category=\u2026, GET /dataset.jsonl",
+ "Arena generation: a job loop off under DESIGN_DRILL, AI generate with a client-credentials token (audience openvibe.ai, scope ai.run.create), three slots from AI's chat route, cost/time/tokens stored, per-person and whole-site daily budgets",
+ "Output safety: scripts, on* handlers and javascript: URLs stripped, 200 KB cap, storage as text, rendering only in <iframe sandbox srcdoc> with a CSP meta that blocks every network fetch",
+ "Blind voting and Elo: two outputs in random order with models hidden, creator cannot vote, one vote per person per round, K=32, tie=0.5, both_bad counts for neither",
+ "Arena pages: /arena (leaderboard and start), /arena/new, /arena/vote, /arena/r/:id with 390 px and 1280 px frames",
+ "ADR-033 account export/deletion for a person's rounds, outputs and votes through /internal/events",
+ "Tests: the migration, the generation job, the sanitizer and iframe markup, daily limits, blind pairs, one vote, creator refusal, Elo win/tie/both_bad, leaderboard numbers, dataset privacy and account export/erase"
],
],
"notYet": [
"notYet": [
"The pages' design and public/js/studio.js: the main session writes them; today the views are plain placeholders and design.css and studio.js are one-comment files",
"The pages' design and public/js/studio.js: the main session writes them; today the views are plain placeholders and design.css and studio.js are one-comment files",
@@ -44,7 +51,12 @@
@@ -44,7 +51,12 @@
"server-rendered pages",
"server-rendered pages",
"crawl artifacts",
"crawl artifacts",
"limits and readiness",
"limits and readiness",
- "deploy files"
+ "deploy files",
+ "Design Arena",
+ "Arena generation jobs",
+ "Blind voting and Elo",
+ "Open Arena dataset",
+ "Account export/deletion"
],
],
"updated": "2026-10-28"
"updated": "2026-10-28"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+-- phase: expand
+-- OpenVibe.Design's Arena (plan 2026-10-28 §5.2 v2): one prompt, several models, blind votes and an Elo
+-- leaderboard per category. The model's page is stored as text and rendered only in a sandboxed iframe; no
+-- column here is a secret. Votes keep the output ids exactly as the voter saw them, never a person's name in
+-- the open dataset.
+
+CREATE TABLE arena_rounds (
+ id text COLLATE "C" PRIMARY KEY, -- arr_ + ULID
+ category text COLLATE "C" NOT NULL, -- landing | dashboard | mobile_app | game_ui | logo | slides
+ prompt text NOT NULL,
+ created_by text COLLATE "C" NOT NULL, -- usr_…; the person who started the round
+ status text COLLATE "C" NOT NULL DEFAULT 'queued', -- queued | generating | ready | failed
+ offers jsonb NOT NULL DEFAULT '[]'::jsonb, -- the {provider, model} slots this round asked AI for
+ pool_fallback boolean NOT NULL DEFAULT false, -- true when fewer than three offers were listed
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT arena_rounds_category CHECK (category IN ('landing', 'dashboard', 'mobile_app', 'game_ui', 'logo', 'slides')),
+ CONSTRAINT arena_rounds_status CHECK (status IN ('queued', 'generating', 'ready', 'failed'))
+);
+CREATE INDEX arena_rounds_status_created ON arena_rounds (status, created_at);
+CREATE INDEX arena_rounds_by_creator ON arena_rounds (created_by, created_at);
+CREATE INDEX arena_rounds_ready_by_category ON arena_rounds (category, created_at) WHERE status = 'ready';
+
+CREATE TABLE arena_outputs (
+ id text COLLATE "C" PRIMARY KEY, -- aro_ + ULID
+ round text COLLATE "C" NOT NULL REFERENCES arena_rounds (id) ON DELETE CASCADE,
+ model text COLLATE "C" NOT NULL, -- the model AI reports, e.g. gpt-5-mini
+ provider text COLLATE "C", -- the provider AI reports, e.g. shared
+ html text NOT NULL DEFAULT '', -- sanitized, at most 200 KB
+ cost_usd numeric(12, 6) NOT NULL DEFAULT 0, -- what the run reported, in USD
+ ms integer NOT NULL DEFAULT 0, -- the generation's measured time
+ tokens integer NOT NULL DEFAULT 0,
+ error text,
+ ui_check jsonb, -- Vision's checks, when it has run
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX arena_outputs_by_round ON arena_outputs (round);
+CREATE INDEX arena_outputs_by_model ON arena_outputs (model);
+
+CREATE TABLE arena_votes (
+ round text COLLATE "C" NOT NULL REFERENCES arena_rounds (id) ON DELETE CASCADE,
+ voter_subject text COLLATE "C" NOT NULL, -- usr_…; never exported or shown
+ left_output text COLLATE "C" NOT NULL REFERENCES arena_outputs (id) ON DELETE CASCADE,
+ right_output text COLLATE "C" NOT NULL REFERENCES arena_outputs (id) ON DELETE CASCADE,
+ winner text COLLATE "C" NOT NULL, -- left | right | tie | both_bad
+ at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (round, voter_subject),
+ CONSTRAINT arena_votes_winner CHECK (winner IN ('left', 'right', 'tie', 'both_bad')),
+ CONSTRAINT arena_votes_distinct_outputs CHECK (left_output <> right_output)
+);
+CREATE INDEX arena_votes_by_round ON arena_votes (round);
+CREATE INDEX arena_votes_by_voter ON arena_votes (voter_subject, at);
+
+CREATE TABLE arena_ratings (
+ category text COLLATE "C" NOT NULL,
+ model text COLLATE "C" NOT NULL,
+ elo numeric(8, 2) NOT NULL DEFAULT 1000,
+ games integer NOT NULL DEFAULT 0,
+ wins integer NOT NULL DEFAULT 0,
+ losses integer NOT NULL DEFAULT 0,
+ ties integer NOT NULL DEFAULT 0,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (category, model),
+ CONSTRAINT arena_ratings_category CHECK (category IN ('landing', 'dashboard', 'mobile_app', 'game_ui', 'logo', 'slides')),
+ CONSTRAINT arena_ratings_counters CHECK (games >= 0 AND wins >= 0 AND losses >= 0 AND ties >= 0)
+);
+CREATE INDEX arena_ratings_order ON arena_ratings (category, elo DESC, games DESC);
+
+-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033): a redelivered export or deletion
+-- changes nothing. It holds an export_id or deletion_id, the subject and the counts sent to Network.
+CREATE TABLE account_data_events (
+ id text PRIMARY KEY,
+ kind text NOT NULL,
+ subject text NOT NULL,
+ outcome jsonb,
+ applied_at timestamptz NOT NULL DEFAULT now(),
+ sent_at timestamptz
+);
@@ -14,14 +14,25 @@
@@ -14,14 +14,25 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.227.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "pg": "^8.23.1"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.13"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.13",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
+ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",
+ "devOptional": true,
+ "license": "Apache-2.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",
@@ -784,9 +795,9 @@
@@ -784,9 +795,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.227.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.227.0",
- "integrity": "sha512-5B3PUpcN2+WGq/8tEai+fpTznza8PcPTTYKkmhkx8Vpn/nRuQV1DupZwAciSzGbZufgdaNI/28b4AVLtylwrAA==",
+ "version": "0.236.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
+ "integrity": "sha512-SPq+/t6ohdwW2DPqK9nZmtbeFex7q6KN/eQVc19MTQ30Df/m7A/idixZGcfs4I3Z43pUpLN8I4ylfOLlFShqsA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -854,6 +865,134 @@
@@ -854,6 +865,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/proxy-addr": {
"node_modules/proxy-addr": {
"version": "2.0.8",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -1081,6 +1220,15 @@
@@ -1081,6 +1220,15 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1138,6 +1286,15 @@
@@ -1138,6 +1286,15 @@
"engines": {
"engines": {
"node": ">= 0.8"
"node": ">= 0.8"
}
}
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-design",
"name": "openvibe-design",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Design \u2014 Make it yours, find the best AI designer.",
+ "description": "OpenVibe.Design — Make it yours, find the best AI designer.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -20,11 +20,15 @@
@@ -20,11 +20,15 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.227.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "pg": "^8.23.1"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.13"
}
}
}
}
@@ -143,3 +143,109 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -143,3 +143,109 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.ds-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
.ds-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
@media (prefers-reduced-motion: reduce) { .ds-orb-ring { animation: none; } .ds-swatch, .ds-previews { transition: none; } }
@media (prefers-reduced-motion: reduce) { .ds-orb-ring { animation: none; } .ds-swatch, .ds-previews { transition: none; } }
html[data-ov-motion=reduced] .ds-orb-ring { animation: none; }
html[data-ov-motion=reduced] .ds-orb-ring { animation: none; }
+
+/* ── The Design Arena (plan 2026-10-28 §5.2 v2) ──────────────────────────────────────────────────────────────
+ Server-rendered placeholders for the main session's design. The generated page only ever appears inside an
+ iframe, so nothing here can affect the Arena's own layout. Phone first: a pair stacks; from 1000 px it is two
+ columns, and a desktop shot is scaled down rather than widening the page. */
+.ar-page { padding-top: clamp(18px, 4vw, 40px); }
+.ar-hero { max-width: 900px; padding-bottom: 18px; }
+.ar-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
+.ar-hero h1, .ar-page-h h1 { margin: 0; font-size: clamp(1.7rem, 4.5vw, 3rem); line-height: 1.06; letter-spacing: -.035em; }
+.ar-lede { margin: 10px 0 0; max-width: 780px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
+.ar-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 18px 0 0; }
+.ar-fine { color: var(--text-muted, #7386a3); font-size: .84rem; line-height: 1.5; }
+.ar-notice { margin: 14px 0; padding: 12px 14px; border: 1px solid var(--border, #26344d); border-radius: 12px; background: var(--bg-card, #0f1729); }
+.ar-status { min-height: 1.4em; color: var(--text-secondary, #96a7c2); }
+.ar-meta { color: var(--text-secondary, #96a7c2); }
+.ar-boards, .ar-open { margin-top: 30px; }
+.ar-lb { margin-top: 22px; }
+.ar-lb-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 14px; }
+.ar-lb-head h2 { margin: 0; font-size: 1.05rem; }
+.ar-more { font-weight: 700; font-size: .88rem; color: var(--room-strong); text-decoration: none; }
+.ar-table { width: 100%; border-collapse: collapse; margin-top: 8px; font-variant-numeric: tabular-nums; }
+.ar-table th, .ar-table td { padding: 9px 8px; text-align: left; border-bottom: 1px solid var(--border, #26344d); white-space: nowrap; }
+.ar-table th { font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.ar-table .ar-lb-model { font-weight: 700; }
+.ar-table .ar-lb-elo { font-weight: 800; color: var(--room-strong); }
+.ar-lb-empty td, .ar-empty { color: var(--text-muted, #7386a3); }
+.ar-form { display: grid; gap: 14px; max-width: 760px; margin-top: 18px; }
+.ar-form label { display: grid; gap: 6px; font-weight: 700; font-size: .9rem; }
+.ar-form select, .ar-form textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border, #26344d); background: var(--bg-card, #0f1729); color: inherit; font: inherit; }
+.ar-form textarea { resize: vertical; line-height: 1.5; }
+.ar-vote-pair { display: grid; gap: 18px; margin-top: 18px; }
+.ar-vote-side { min-width: 0; padding: 12px; border: 1px solid var(--border, #26344d); border-radius: 14px; background: var(--bg-card, #0f1729); }
+.ar-vote-side h2 { margin: 0 0 8px; font-size: .82rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.ar-output { padding: 14px; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); min-width: 0; }
+.ar-output-h { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 14px; margin-bottom: 10px; }
+.ar-output-h h2 { margin: 0; font-size: 1rem; }
+.ar-output-error { padding: 12px; border-radius: 10px; background: color-mix(in srgb, #b42318 18%, transparent); color: var(--text-primary, #e6edf7); }
+.ar-shot-label { margin: 4px 0 0; font-size: .74rem; color: var(--text-muted, #7386a3); }
+.ar-reveal { display: grid; gap: 14px; margin-top: 18px; }
+@media (min-width: 760px) { .ar-reveal { grid-template-columns: 1fr 1fr; } }
+.ar-reveal-side { padding: 14px; border: 1px solid var(--border, #26344d); border-radius: 14px; background: var(--bg-card, #0f1729); }
+.ar-reveal-side h2 { margin: 0 0 6px; font-size: .8rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+
+/* Arena design pass. A pair is compared one viewport at a time (the switch); arena.js scales each 1280 or 390 px
+ frame to the width it has (--s), and without it a desktop shot is a fixed half-size and a phone 0.6. */
+.ar-view { display: inline-flex; gap: 2px; margin: 18px 0 0; padding: 3px; border: 1px solid var(--border, #26344d); border-radius: 999px; background: var(--bg-card, #0f1729); }
+.ar-view input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
+.ar-view label { min-height: 34px; display: inline-flex; align-items: center; padding: 0 16px; border-radius: 999px; font-weight: 800; font-size: .84rem; color: var(--text-secondary, #96a7c2); cursor: pointer; }
+.ar-view input:checked + label { background: color-mix(in srgb, var(--room-strong) 20%, transparent); color: var(--text-primary, #e6edf7); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--room-strong) 45%, transparent); }
+.ar-view input:focus-visible + label { outline: 2px solid var(--room-strong); outline-offset: 2px; }
+.ar-page:has(#ar-view-phone:checked) .ar-shot--desktop, .ar-page:has(#ar-view-desktop:checked) .ar-shot--phone { display: none; }
+
+.ar-shot { margin: 0; min-width: 0; }
+.ar-shot + .ar-shot { margin-top: 16px; }
+.ar-shot-frame { --s: .5; position: relative; overflow: hidden; background: #fff; }
+.ar-shot-frame .ar-frame { position: absolute; top: 0; left: 0; display: block; border: 0; background: #fff; transform: scale(var(--s)); transform-origin: top left; }
+.ar-shot-label { margin: 6px 0 0; font-size: .72rem; letter-spacing: .04em; color: var(--text-muted, #7386a3); }
+/* Desktop: a browser window, the title bar drawn in CSS */
+.ar-shot--desktop::before { content: ''; display: block; height: 22px; border: 1px solid var(--border, #26344d); border-bottom: 0; border-radius: 10px 10px 0 0;
+ background: radial-gradient(circle at 13px 50%, #ff5f57 3.5px, transparent 4px), radial-gradient(circle at 26px 50%, #febc2e 3.5px, transparent 4px), radial-gradient(circle at 39px 50%, #28c840 3.5px, transparent 4px), var(--bg-elevated, var(--bg-secondary, #16213a)); }
+.ar-shot--desktop .ar-shot-frame { width: min(100%, 640px); aspect-ratio: 1280 / 800; border: 1px solid var(--border, #26344d); border-top: 0; border-radius: 0 0 10px 10px; }
+.ar-shot--desktop::before { width: min(100%, 640px); box-sizing: border-box; }
+html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::before { width: 100%; }
+/* Phone: a handset, centred in its column */
+.ar-shot--phone { display: grid; justify-items: center; }
+.ar-shot--phone .ar-shot-frame { --s: .6; box-sizing: content-box; width: calc(390px * var(--s)); aspect-ratio: 390 / 844; border: 7px solid #0b1020; border-radius: calc(46px * var(--s) + 7px);
+ box-shadow: 0 0 0 1px var(--border, #26344d), 0 22px 44px -28px rgba(0, 0, 0, .7); }
+
+/* Vote: two sides; the side a vote button names lights up while it is hovered or focused */
+.ar-vote-pair { display: grid; gap: 14px; margin-top: 14px; }
+@media (min-width: 900px) { .ar-vote-pair { grid-template-columns: 1fr 1fr; align-items: start; gap: 18px; } }
+.ar-page:has(#ar-view-phone:checked) .ar-vote-pair { grid-template-columns: 1fr 1fr; gap: 10px; }
+.ar-vote-side { transition: border-color .15s, box-shadow .15s; }
+.ar-vote-side h2 { display: flex; align-items: center; gap: 8px; }
+#ar-vote-left h2::before, #ar-vote-right h2::before { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px; background: color-mix(in srgb, var(--room-strong) 18%, transparent); color: var(--room-strong); font-size: .78rem; }
+#ar-vote-left h2::before { content: 'L'; }
+#ar-vote-right h2::before { content: 'R'; }
+.ar-page:has(.ar-vote-btn[data-winner=left]:is(:hover, :focus-visible)) #ar-vote-left,
+.ar-page:has(.ar-vote-btn[data-winner=right]:is(:hover, :focus-visible)) #ar-vote-right,
+.ar-page:has(.ar-vote-btn[data-winner=tie]:is(:hover, :focus-visible)) .ar-vote-side { border-color: var(--room-strong); box-shadow: 0 0 0 1px var(--room-strong); }
+.ar-page:has(.ar-vote-btn[data-winner=both_bad]:is(:hover, :focus-visible)) .ar-vote-side { border-color: color-mix(in srgb, #ef4444 60%, var(--border, #26344d)); }
+/* The verdict stays in reach while the pair scrolls past (above the phone app bar) */
+.ar-vote-actions { position: sticky; bottom: var(--ovnav-appbar-h, 0px); z-index: 3; display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin: 16px 0 0; padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0px));
+ border-top: 1px solid var(--border, #26344d); background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 88%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
+@media (max-width: 759px) { .ar-vote-actions { display: grid; grid-template-columns: 1fr 1fr; } .ar-vote-actions .ovk-btn { justify-content: center; width: 100%; } }
+
+/* Round: the outputs side by side where they fit */
+.ar-outputs { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); align-items: start; }
+.ar-output-h h2 { overflow-wrap: anywhere; }
+
+/* Leaderboards: one card per category, numbers right-aligned, the leader in the room colour */
+.ar-lb { padding: 14px 16px 4px; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); overflow-x: auto; }
+.ar-boards > .ar-lb-head h2 { font-size: clamp(1.15rem, 2.4vw, 1.45rem); }
+.ar-table tr:last-child td { border-bottom: 0; }
+.ar-table th:nth-child(n+3), .ar-table td:nth-child(n+3) { text-align: right; }
+.ar-table .ar-lb-rank { width: 2em; font-weight: 800; color: var(--text-muted, #7386a3); }
+.ar-table .ar-lb-row:first-child .ar-lb-rank, .ar-table .ar-lb-row:first-child .ar-lb-model { color: var(--room-strong); }
+.ar-table .ar-lb-row:hover td { background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); }
+.ar-boards > p.ar-fine { margin-top: 12px; }
+.ar-open { padding: 18px; border: 1px dashed var(--border, #26344d); border-radius: 16px; }
+.ar-open h2 { margin: 0; }
+
+/* Forms */
+.ar-form select:focus-visible, .ar-form textarea:focus-visible { outline: 2px solid var(--room-strong); outline-offset: 1px; }
+.ar-form textarea::placeholder { color: var(--text-muted, #7386a3); }
+@media (prefers-reduced-motion: reduce) { .ar-vote-side { transition: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+/**
+ * The Arena's small in-browser conveniences. The pages work without this file: /arena/new is a real form that
+ * posts to the page, /arena/vote is a real form that posts the winner, and every iframe is already sandboxed.
+ *
+ * With it, the vote and create forms stay on the page (fetch + JSON), a refusal is shown in words, and a round
+ * that is still generating refreshes itself. Nothing from a model is ever inserted into this document.
+ */
+(function () {
+ 'use strict';
+ const $ = (id) => document.getElementById(id);
+ const status = (el, text) => { if (el) el.textContent = text; };
+
+ async function postJson(url, body) {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify(body),
+ credentials: 'same-origin',
+ });
+ const json = await res.json().catch(() => null);
+ if (!res.ok) {
+ const detail = json && (json.detail || json.title || json.code);
+ throw new Error(detail || `The request failed (${res.status}).`);
+ }
+ return json;
+ }
+
+ const vote = $('ar-vote-form');
+ if (vote) {
+ vote.addEventListener('submit', async (event) => {
+ const button = event.submitter;
+ if (!button || !button.dataset.winner) return;
+ event.preventDefault();
+ const out = $('ar-vote-status');
+ status(out, 'Recording your vote…');
+ try {
+ const data = await postJson('/api/v1/arena/vote', {
+ round_id: $('ar-vote-round').value,
+ left_output: $('ar-vote-left-id').value,
+ right_output: $('ar-vote-right-id').value,
+ winner: button.dataset.winner,
+ });
+ const left = data.revealed.left;
+ const right = data.revealed.right;
+ const reveal = document.createElement('div');
+ reveal.id = 'ar-reveal';
+ reveal.className = 'ar-reveal';
+ for (const [side, o] of [['Left', left], ['Right', right]]) {
+ const box = document.createElement('div');
+ box.className = 'ar-reveal-side';
+ const h = document.createElement('h2');
+ h.textContent = side;
+ const p = document.createElement('p');
+ const b = document.createElement('b');
+ b.textContent = o.model;
+ p.append(b, document.createTextNode(o.provider ? ` · ${o.provider}` : ''));
+ const meta = document.createElement('p');
+ meta.className = 'ar-fine';
+ meta.textContent = `$${Number(o.cost_usd || 0).toFixed(4)} · ${o.ms} ms · ${o.tokens} tokens`;
+ box.append(h, p, meta);
+ reveal.append(box);
+ }
+ vote.replaceWith(reveal);
+ status(out, `Vote recorded: ${data.vote.winner === 'both_bad' ? 'both bad' : data.vote.winner}. The models are revealed above.`);
+ } catch (err) {
+ status(out, err.message || 'The vote could not be recorded. Try again.');
+ }
+ });
+ }
+
+ const create = $('ar-new-form');
+ if (create) {
+ create.addEventListener('submit', async (event) => {
+ if (create.getAttribute('data-json') === '0') return;
+ event.preventDefault();
+ const out = $('ar-new-status');
+ status(out, 'Starting the round…');
+ try {
+ const data = await postJson('/api/v1/arena/rounds', {
+ category: $('ar-category').value,
+ prompt: $('ar-prompt').value,
+ });
+ window.location.assign(`/arena/r/${encodeURIComponent(data.round.id)}`);
+ } catch (err) {
+ status(out, err.message || 'The round could not be started. Try again.');
+ }
+ });
+ }
+
+ const round = $('ar-round');
+ if (round) {
+ const state = ($('ar-round-meta') || {}).textContent || '';
+ if (/Status:\s*(queued|generating)/i.test(state)) setTimeout(() => window.location.reload(), 4000);
+ }
+
+ // Fit every generated frame to the column it sits in: a desktop frame fills the width, a phone stays a handset.
+ var shots = document.querySelectorAll('.ar-shot');
+ if (shots.length && 'ResizeObserver' in window) {
+ document.documentElement.classList.add('ar-fit');
+ var ro = new ResizeObserver(function (entries) {
+ entries.forEach(function (entry) {
+ var shot = entry.target, frame = shot.querySelector('.ar-shot-frame'), iframe = shot.querySelector('iframe');
+ var w = entry.contentRect.width, base = Number(iframe && iframe.getAttribute('width')) || 1280;
+ if (!frame || !w) return;
+ var s = shot.classList.contains('ar-shot--phone') ? Math.min(0.72, (w - 14) / base) : w / base;
+ frame.style.setProperty('--s', String(Math.max(0.12, s)));
+ });
+ });
+ shots.forEach(function (shot) { ro.observe(shot); });
+ }
+})();
@@ -1,17 +1,19 @@
@@ -1,17 +1,19 @@
'use strict';
'use strict';
/**
/**
- * OpenVibe.Design — Express app factory; server/index.js listens, tests build their own instance with a mock
- * Network.
+ * OpenVibe.Design — Express app factory; server/index.js listens, tests build their own instance with a temporary
+ * database and mock Network/AI.
*
*
- * /, /studio, /t/:slug, /developers the pages (http/pages.js)
- * /api/themes/*, /api/me/theme* the theme API proxy to OpenVibe.Network (http/api.js)
- * /auth/* Network SSO with PKCE (auth/sso.js)
- * /api/health, /api/ready, /release.json liveness, truthful readiness, release metadata
- * /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
+ * /, /studio, /t/:slug, /arena/*, /developers the pages (http/pages.js)
+ * /api/v1/arena/* the Arena API (http/arena-api.js)
+ * /api/themes/*, /api/me/theme* the theme API proxy to OpenVibe.Network (http/api.js)
+ * /auth/* Network SSO with PKCE (auth/sso.js)
+ * /internal/events ADR-033 account export/deletion (identity/account-data.js)
+ * /api/health, /api/ready, /release.json liveness, truthful readiness, release metadata
+ * /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
*
*
- * Stateless: no database, no Valkey, no Events. The only dependency is OpenVibe.Network, for sign-in and the theme
- * API; the pages and the public catalogue degrade honestly without it.
+ * The Arena's rounds are generated by a job loop (server/index.js starts it; DESIGN_DRILL=1 keeps it off). The
+ * request that starts a round only queues it.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -23,35 +25,53 @@ const cache = require('openvibe-shared/cache-policy');
@@ -23,35 +25,53 @@ const cache = require('openvibe-shared/cache-policy');
const releaseLib = require('openvibe-shared/release');
const releaseLib = require('openvibe-shared/release');
const metricsLib = require('openvibe-shared/metrics');
const metricsLib = require('openvibe-shared/metrics');
const serve = require('openvibe-shared/serve');
const serve = require('openvibe-shared/serve');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
const configLib = require('./config');
const configLib = require('./config');
+const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createThemeCatalog } = require('./themes');
const { createThemeCatalog } = require('./themes');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
+const { createArenaApi } = require('./http/arena-api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
+const { createArenaStore } = require('./arena/store');
+const { createArenaAi } = require('./arena/ai');
+const { createArenaJobs } = require('./arena/jobs');
+const accountDataLib = require('./identity/account-data');
const { PUBLIC_DIR, assetVersion } = require('./assets');
const { PUBLIC_DIR, assetVersion } = require('./assets');
const views = require('./views');
const views = require('./views');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
/**
/**
- * opts: config, catalog, fetchImpl, log, now, release
- * `catalog` is the public theme client; a test passes an object with the same methods, or a fetchImpl that talks to
- * an in-process Network.
+ * opts: config, store, catalog, arena, arenaAi, accountSend, fetchImpl, log, now, release
+ * A test passes a migrated store and can inject the Arena, the AI client and the account sender.
*/
*/
-function createApp(opts = {}) {
+async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const now = opts.now || (() => Date.now());
const now = opts.now || (() => Date.now());
+ const s = opts.store || await openStore(config, { now, log });
const keys = createKeyStore({ config, fetchImpl, log });
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, log, now });
const sso = createSso({ config, keys, fetchImpl, log, now });
const catalog = opts.catalog || createThemeCatalog({ config, fetchImpl, log, now });
const catalog = opts.catalog || createThemeCatalog({ config, fetchImpl, log, now });
- const ctx = { config, keys, sso, catalog, log };
+ const arena = opts.arena || createArenaStore({ s, config, log });
+ const ai = opts.arenaAi || opts.ai || createArenaAi({ config, fetchImpl, log });
+ const jobs = createArenaJobs({ s, arena, ai, config, log });
+ const accountData = accountDataLib.create({ db: s.db, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
+
+ const ctx = {
+ config, s, store: s, keys, sso, catalog, arena, ai, jobs, accountData, accountSend, log,
+ random: opts.random || Math.random,
+ };
const release = opts.release || releaseLib.createRelease({ service: 'design', root: path.join(__dirname, '..') });
const release = opts.release || releaseLib.createRelease({ service: 'design', root: path.join(__dirname, '..') });
views.setRelease(release.release);
views.setRelease(release.release);
@@ -68,7 +88,7 @@ function createApp(opts = {}) {
@@ -68,7 +88,7 @@ function createApp(opts = {}) {
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-design', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-design', version: VERSION }));
- const readiness = createServiceReadiness({ config, catalog, keys, release: release.release });
+ const readiness = createServiceReadiness({ s, config, catalog, keys, release: release.release });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
@@ -84,6 +104,7 @@ function createApp(opts = {}) {
@@ -84,6 +104,7 @@ function createApp(opts = {}) {
imgSrc: ["'self'", 'data:', 'https:'],
imgSrc: ["'self'", 'data:', 'https:'],
// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.
// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
+ // 'self' covers the Arena's about:srcdoc iframes; generated pages cannot navigate the top window.
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
objectSrc: ["'none'"],
@@ -115,6 +136,10 @@ function createApp(opts = {}) {
@@ -115,6 +136,10 @@ function createApp(opts = {}) {
app.use(sso.middleware());
app.use(sso.middleware());
app.use('/auth', sso.routes());
app.use('/auth', sso.routes());
+ // ── Account export/deletion from OpenVibe.Events (loopback only) ──
+ // Raw body first: the SDK verifies the delivery signature over the exact bytes.
+ app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend }));
+
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', serve.handler());
app.use('/shared', serve.handler());
const setAssetHeaders = (res, filePath) => {
const setAssetHeaders = (res, filePath) => {
@@ -125,8 +150,9 @@ function createApp(opts = {}) {
@@ -125,8 +150,9 @@ function createApp(opts = {}) {
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
- // ── API: the theme proxy ────────────────────────────────
+ // ── API: the Arena, then the theme proxy ────────────────
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api', express.json({ limit: '64kb' }));
+ app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api', createApi(ctx));
app.use('/api', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
detail: 'No such API route.',
detail: 'No such API route.',
@@ -141,7 +167,7 @@ function createApp(opts = {}) {
@@ -141,7 +167,7 @@ function createApp(opts = {}) {
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
- // Never log request bodies (a theme's variables can be large, a person's request can be personal).
+ // Never log request bodies (a prompt or a generated page can be large or personal).
log.error('[OpenVibe.Design]', err && err.message ? err.message.slice(0, 300) : err);
log.error('[OpenVibe.Design]', err && err.message ? err.message.slice(0, 300) : err);
if (res.headersSent) return;
if (res.headersSent) return;
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
new file mode 100644
new file mode 100644
@@ -0,0 +1,301 @@
@@ -0,0 +1,301 @@
+'use strict';
+
+/**
+ * The Arena's only model gateway: OpenVibe.AI's `generate` operation, called as the first-party service `design`
+ * with a client-credentials token (audience openvibe.ai, scope ai.run.create). Network grants the token, and the
+ * `ai.*` namespace lets it run `ai.generate`.
+ *
+ * AI's routing is the authority. Design reads the chat route's offered providers/models (GET /api/v1/routes) and
+ * asks for three slots; each run reports the provider and model that actually answered, and those are what the
+ * leaderboard ranks. `DESIGN_ARENA_MODELS` can name explicit `provider:model` slots, but the reported provenance
+ * always wins, so a fallback or a pool's choice is never mislabelled.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const { sanitizeHtml } = require('./sanitize');
+
+class ArenaAiError extends Error {
+ constructor(status, code, detail) {
+ super(detail || code);
+ this.name = 'ArenaAiError';
+ this.status = status;
+ this.code = code;
+ this.detail = detail || code;
+ }
+}
+
+const CATEGORY_BRIEF = {
+ landing: 'A marketing landing page for a product or place: a hero, a clear proof section, and one call to action.',
+ dashboard: 'A dense analytics dashboard: navigation, KPI cards, a chart area, a table and useful empty states.',
+ mobile_app: 'A phone-first app screen or flow: one clear task, thumb-sized controls, safe areas and a bottom bar if it helps.',
+ game_ui: 'A game interface: HUD, inventory or lobby that is readable at a glance and feels like a game.',
+ logo: 'A logo and small brand sheet: the mark, a wordmark, a colour palette and a few lockups.',
+ slides: 'A short slide deck: a title slide and two or three content slides with a clear visual hierarchy.',
+};
+
+function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const ai = config.ai;
+ const tokenClient = config.oauth.clientSecret
+ ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: ai.audience,
+ scope: ai.scope,
+ fetchImpl,
+ })
+ : null;
+ const enabled = Boolean(ai.url && tokenClient);
+
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+ async function call(method, path, body, { retried = false } = {}) {
+ if (!enabled) throw new ArenaAiError(503, 'ai.not_configured', 'OpenVibe.AI is not configured for the Arena.');
+ let headers;
+ try {
+ headers = { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokenClient.authHeaders()) };
+ } catch (err) {
+ throw new ArenaAiError(502, 'ai.token_failed', 'OpenVibe.Network did not issue an AI token for Design.');
+ }
+ let res;
+ try {
+ res = await fetchImpl(`${ai.url.replace(/\/+$/, '')}${path}`, {
+ method,
+ headers,
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(ai.timeoutMs),
+ });
+ } catch (err) {
+ throw new ArenaAiError(502, 'ai.unreachable', err && err.name === 'TimeoutError'
+ ? 'OpenVibe.AI did not answer in time.'
+ : 'OpenVibe.AI could not be reached.');
+ }
+ if (res.status === 401 && !retried) {
+ tokenClient.invalidate();
+ return call(method, path, body, { retried: true });
+ }
+ const json = await res.json().catch(() => null);
+ if (!res.ok) {
+ const code = (json && (json.code || json.error)) || `ai.http_${res.status}`;
+ const detail = (json && (json.detail || json.message)) || `OpenVibe.AI refused the run (${res.status}).`;
+ throw new ArenaAiError(res.status >= 500 ? 502 : res.status, String(code).slice(0, 80), String(detail).slice(0, 500));
+ }
+ return json;
+ }
+
+ /**
+ * The offers the routing lists for `generate` (GET /api/v1/routes?operation=generate). AI's route
+ * response is the source: an `offers` array of { provider, model }. A route that predates offers
+ * still answers with primary/fallbacks/pinned, which are read as before. The default.chat route is
+ * the one ai.generate uses, so its offers are preferred; distinct providers come first, then a
+ * second model of one provider.
+ */
+ async function offeredModels() {
+ let body;
+ try { body = await call('GET', '/api/v1/routes?operation=generate'); } catch (err) {
+ log.warn('[OpenVibe.Design] AI routes unavailable:', (err && err.message) || err);
+ return [];
+ }
+ let routes = Array.isArray(body) ? body : (body && Array.isArray(body.routes) ? body.routes : []);
+ if (!routes.length && body && body.routes && typeof body.routes === 'object' && !Array.isArray(body.routes)) {
+ routes = Object.entries(body.routes).map(([key, r]) => ({ key, ...(r || {}) }));
+ }
+ if (!routes.length && body && body.key) routes = [body];
+ if (!routes.length && body && Array.isArray(body.models)) {
+ routes = body.models.map((m) => ({ key: 'default.chat', capability: 'chat', primary: { provider: m.provider || m.provider_key, model: m.model || m.model_key } }));
+ }
+ const chat = routes.filter((r) => r && (r.capability === 'chat' || /chat|generate/i.test(String(r.key || ''))));
+ const ordered = [
+ ...chat.filter((r) => r.key === 'default.chat'),
+ ...chat.filter((r) => r.key !== 'default.chat'),
+ ];
+ const out = [];
+ const seen = new Set();
+ const add = (provider, model) => {
+ const p = String(provider || '').trim();
+ const m = model == null ? null : String(model).trim();
+ if (!p) return;
+ const key = `${p}|${m || ''}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ out.push({ provider: p, model: m || null });
+ };
+ // AI's offers are authoritative when the response carries them. The default.chat route is
+ // ai.generate's route; when it has no offers, no other route's offer is a valid pin here.
+ const hasOffers = ordered.some((r) => Array.isArray(r.offers));
+ if (hasOffers) {
+ const preferred = ordered.find((r) => r.key === 'default.chat' && Array.isArray(r.offers));
+ const source = preferred ? [preferred] : ordered.filter((r) => Array.isArray(r.offers));
+ for (const r of source) for (const o of r.offers) add(o && o.provider, o && o.model);
+ } else {
+ // An older AI lists definitions only. These are read as offers for compatibility; a pin AI
+ // does not list is refused by its 422, which the job records on the output.
+ for (const r of ordered) {
+ if (r.primary) add(r.primary.provider, r.primary.model);
+ const pinned = Array.isArray(r.pinned) ? r.pinned : [];
+ for (const p of pinned) add(p && p.provider, p && p.model);
+ const fallbacks = Array.isArray(r.fallbacks) ? r.fallbacks : [];
+ for (const f of fallbacks) add(f && f.provider, f && f.model);
+ if (r.constraints && r.constraints.model && r.primary) add(r.primary.provider, r.constraints.model);
+ }
+ }
+ return rankOffers(out);
+ }
+
+ /** Distinct offers, one per provider first, then further models of a provider already seen. */
+ function rankOffers(list) {
+ const out = [];
+ const seen = new Set();
+ const providers = new Set();
+ const rest = [];
+ for (const c of list) {
+ const p = String((c && c.provider) || '').trim();
+ if (!p) continue;
+ const m = c.model == null ? null : String(c.model).trim();
+ const key = `${p}|${m || ''}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const offer = { provider: p, model: m || null };
+ if (!providers.has(p)) { providers.add(p); out.push(offer); } else rest.push(offer);
+ }
+ return [...out, ...rest];
+ }
+
+ function configuredModels() {
+ return (ai.models || []).map((s) => {
+ const i = s.indexOf(':');
+ return i > 0 ? { provider: s.slice(0, i), model: s.slice(i + 1) || null } : { provider: s, model: null };
+ });
+ }
+
+ /**
+ * Three distinct offers to ask AI for. When the routing offers fewer than three, the remainder is
+ * an unpinned slot answered by AI's pool, and the round records that it fell back (`pool_fallback`).
+ */
+ async function slotPlan() {
+ let list = configuredModels();
+ if (!list.length) list = await offeredModels();
+ const ranked = rankOffers(list);
+ const slots = ranked.slice(0, 3);
+ const poolFallback = slots.length < 3;
+ while (slots.length < 3) slots.push({ provider: 'ai', model: null });
+ return { slots, pool_fallback: poolFallback, offered: ranked.length };
+ }
+
+ async function slots() { return (await slotPlan()).slots; }
+
+ function systemPrompt(category) {
+ return [
+ 'You are a senior product designer and front-end engineer competing in the OpenVibe Design Arena.',
+ 'Build the page the person describes as one complete, self-contained HTML document.',
+ `Category: ${category}. ${CATEGORY_BRIEF[category] || ''}`,
+ 'Rules: return only the HTML document, starting with <!doctype html>. Use inline CSS in a <style> tag or style attributes.',
+ 'No JavaScript, no event-handler attributes, no external stylesheets, fonts, scripts or images. Images may be inline SVG or data: URIs.',
+ 'Make it responsive enough to be judged at 390 px and 1280 px wide. Use real, plausible content, not lorem ipsum. No explanations outside the HTML.',
+ ].join('\n');
+ }
+
+ function textOf(output) {
+ if (typeof output === 'string') return output;
+ if (!output || typeof output !== 'object') return '';
+ for (const k of ['text', 'html', 'content', 'body']) if (typeof output[k] === 'string') return output[k];
+ if (output.message && typeof output.message.content === 'string') return output.message.content;
+ return '';
+ }
+
+ function msOf(run, fallback) {
+ for (const v of [run && run.ms, run && run.latency_ms, run && run.duration_ms, run && run.usage && run.usage.ms, run && run.usage && run.usage.latency_ms, run && run.usage && run.usage.duration_ms]) {
+ if (Number.isFinite(Number(v)) && Number(v) >= 0) return Math.round(Number(v));
+ }
+ const start = run && (run.started_at || run.created_at);
+ const end = run && run.finished_at;
+ if (start && end) {
+ const d = Date.parse(end) - Date.parse(start);
+ if (Number.isFinite(d) && d >= 0) return Math.round(d);
+ }
+ return Math.max(0, Math.round(fallback));
+ }
+
+ async function waitFor(run, startedAt) {
+ let r = run;
+ const deadline = Date.now() + ai.timeoutMs;
+ while (r && ['queued', 'running'].includes(r.status)) {
+ if (!r.id) throw new ArenaAiError(502, 'ai.bad_answer', 'OpenVibe.AI answered with a running run that has no id.');
+ if (Date.now() > deadline) throw new ArenaAiError(504, 'ai.timeout', `The AI run ${r.id} did not finish in time.`);
+ await sleep(ai.pollMs);
+ const body = await call('GET', `/api/v1/runs/${encodeURIComponent(r.id)}`);
+ r = body && body.run ? body.run : body;
+ }
+ if (!r) throw new ArenaAiError(502, 'ai.bad_answer', 'OpenVibe.AI answered without a run.');
+ const done = ['succeeded', 'cached', 'completed', 'ok'].includes(r.status)
+ || (r.status == null && r.output != null && !r.error);
+ if (!done) {
+ const e = r.error || {};
+ throw new ArenaAiError(502, 'ai.run_failed', `The AI run ${r.id || '?'} ${r.status || 'did not finish'}${e.code ? ` (${e.code}: ${e.detail || ''})` : ''}.`);
+ }
+ return { run: r, ms: msOf(r, Date.now() - startedAt) };
+ }
+
+ async function generate({ prompt, category, slot, roundId, index }) {
+ if (!enabled) throw new ArenaAiError(503, 'ai.not_configured', 'OpenVibe.AI is not configured for the Arena.');
+ // One offer from GET /api/v1/routes: AI runs that provider/model alone, or refuses it in words.
+ // A pool slot (fewer than three offered) sends no route and lets the routing choose.
+ const pinned = slot && slot.provider && slot.provider !== 'ai' ? { provider: slot.provider, model: slot.model || null } : null;
+ const body = {
+ prompt: `${prompt}\n\nCategory: ${category}\nReturn one complete HTML document.`,
+ system: systemPrompt(category),
+ max_tokens: 8000,
+ temperature: 0.7,
+ options: { cache: false },
+ ...(pinned ? { route: pinned } : {}),
+ target: { service: 'design', type: 'arena_output', id: `${roundId}_${index}`.slice(0, 128) },
+ };
+ const startedAt = Date.now();
+ const wait = Math.min(ai.waitMs, 60_000);
+ let created;
+ try {
+ created = await call('POST', `/api/v1/generate?wait=${wait}`, body);
+ } catch (err) {
+ // A deployment that exposes the run API but not the convenience operation: the same run, by workflow.
+ if (!err || err.status !== 404) throw err;
+ created = await call('POST', `/api/v1/runs?wait=${wait}`, {
+ workflow: 'ai.generate',
+ input: { prompt: body.prompt, system: body.system, max_tokens: body.max_tokens, temperature: body.temperature },
+ options: body.options,
+ ...(body.route ? { route: body.route } : {}),
+ target: body.target,
+ });
+ }
+ const first = created && created.run ? created.run : created;
+ const { run, ms } = await waitFor(first, startedAt);
+ const text = textOf(run.output);
+ if (!text.trim()) throw new ArenaAiError(502, 'ai.empty', `The AI run ${run.id} returned no HTML.`);
+ let html = text.trim().replace(/^\s*```(?:html)?\s*\n?/i, '').replace(/\n?```\s*$/, '');
+ const doc = /<!doctype\s+html[\s\S]*/i.exec(html) || /<html[\s\S]*/i.exec(html);
+ if (doc) html = doc[0];
+ const usage = run.usage || {};
+ const prov = run.provenance || {};
+ const tokens = usage.tokens && typeof usage.tokens === 'object'
+ ? (Number(usage.tokens.input) || 0) + (Number(usage.tokens.output) || 0)
+ : Number.isFinite(Number(usage.tokens)) ? Number(usage.tokens)
+ : (Number(usage.tokens_in) || 0) + (Number(usage.tokens_out) || 0)
+ || (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0)
+ || (Number(run.tokens) || 0);
+ return {
+ html: sanitizeHtml(html, { maxBytes: ai.maxHtmlBytes }),
+ cost_usd: Number(usage.cost_usd != null ? usage.cost_usd
+ : usage.cost != null ? usage.cost
+ : run.cost_usd != null ? run.cost_usd
+ : run.cost != null ? run.cost : 0) || 0,
+ ms,
+ tokens: Math.max(0, Math.round(tokens)),
+ model: String(prov.model || run.model || (run.output && run.output.model) || (slot && slot.model) || 'unknown').slice(0, 120),
+ provider: String(prov.provider || run.provider || (slot && slot.provider) || 'ai').slice(0, 80),
+ ui_check: run.ui_check || (run.output && run.output.ui_check) || null,
+ run_id: run.id || null,
+ };
+ }
+
+ return { enabled, slots, slotPlan, offeredModels, rankOffers, generate, systemPrompt, CATEGORY_BRIEF };
+}
+
+module.exports = { createArenaAi, ArenaAiError, CATEGORY_BRIEF };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+
+/**
+ * The Arena's generation worker. A round is created as `queued` by the request; this loop claims it, asks AI for
+ * three outputs, stores each (sanitized) with its reported provider, model, cost, time and tokens, then marks the
+ * round `ready` (at least two usable outputs) or `failed`.
+ *
+ * The loop starts in server/index.js and never under DESIGN_DRILL=1. Tests call tick() explicitly.
+ */
+const ERROR_MAX = 500;
+
+function createArenaJobs({ s, arena, ai, config, log = console }) {
+ let timer = null;
+ let running = false;
+ let stopped = false;
+
+ async function claim() {
+ return await s.tx(async () => {
+ const r = await s.db.maybe("SELECT * FROM arena_rounds WHERE status = 'queued' ORDER BY created_at, id LIMIT 1 FOR UPDATE");
+ if (!r) return null;
+ await s.db.exec("UPDATE arena_rounds SET status = 'generating' WHERE id = $1", [r.id]);
+ return { ...r, status: 'generating' };
+ });
+ }
+
+ async function generateRound(round) {
+ let plan;
+ try {
+ plan = typeof ai.slotPlan === 'function' ? await ai.slotPlan() : { slots: await ai.slots(), pool_fallback: false };
+ } catch (err) {
+ log.warn('[OpenVibe.Design] AI slots unavailable:', (err && err.message) || err);
+ plan = { slots: [{ provider: 'ai', model: null }], pool_fallback: true };
+ }
+ const slots = (Array.isArray(plan.slots) ? plan.slots : []).slice(0, 3);
+ while (slots.length < 3) slots.push({ provider: 'ai', model: null });
+ // Say on the round what was asked for, and whether (and why) the pool filled a slot.
+ await s.db.exec('UPDATE arena_rounds SET offers = $1::jsonb, pool_fallback = $2 WHERE id = $3',
+ [JSON.stringify(slots), Boolean(plan.pool_fallback), round.id]);
+ let ok = 0;
+ for (let i = 0; i < 3; i++) {
+ const slot = slots[i] || { provider: 'ai', model: null };
+ try {
+ const out = await ai.generate({ prompt: round.prompt, category: round.category, slot, roundId: round.id, index: i });
+ await s.db.exec(`INSERT INTO arena_outputs (id, "round", model, provider, html, cost_usd, ms, tokens, error, ui_check, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, $10::timestamptz)`,
+ [`aro_${require('openvibe-contracts').ids.ulid(s.now())}`, round.id, out.model, out.provider, out.html,
+ out.cost_usd, out.ms, out.tokens, out.ui_check == null ? null : JSON.stringify(out.ui_check), s.iso()]);
+ ok++;
+ } catch (err) {
+ const detail = String((err && (err.detail || err.message)) || err).slice(0, ERROR_MAX);
+ log.warn(`[OpenVibe.Design] Arena round ${round.id} output ${i} failed:`, detail);
+ await s.db.exec(`INSERT INTO arena_outputs (id, "round", model, provider, html, cost_usd, ms, tokens, error, ui_check, created_at)
+ VALUES ($1, $2, $3, $4, '', 0, 0, 0, $5, NULL, $6::timestamptz)`,
+ [`aro_${require('openvibe-contracts').ids.ulid(s.now())}`, round.id,
+ String((slot && slot.model) || 'unknown').slice(0, 120), String((slot && slot.provider) || 'ai').slice(0, 80), detail, s.iso()]);
+ }
+ }
+ await s.db.exec('UPDATE arena_rounds SET status = $1 WHERE id = $2', [ok >= 2 ? 'ready' : 'failed', round.id]);
+ return ok;
+ }
+
+ async function tick() {
+ if (running || stopped) return null;
+ running = true;
+ try {
+ const round = await claim();
+ if (!round) return null;
+ try {
+ await generateRound(round);
+ return await arena.getRound(round.id);
+ } catch (err) {
+ log.warn('[OpenVibe.Design] Arena round failed:', (err && err.message) || err);
+ await s.db.exec("UPDATE arena_rounds SET status = 'failed' WHERE id = $1", [round.id]).catch(() => {});
+ throw err;
+ }
+ } finally {
+ running = false;
+ }
+ }
+
+ function start() {
+ if (timer || stopped || config.drill) return null;
+ const run = () => tick().catch((err) => log.warn('[OpenVibe.Design] Arena job:', (err && err.message) || err));
+ timer = setInterval(run, config.arena.intervalMs);
+ if (timer.unref) timer.unref();
+ run();
+ return timer;
+ }
+
+ function stop() {
+ stopped = true;
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ return { tick, start, stop, claim, generateRound, get running() { return running; } };
+}
+
+module.exports = { createArenaJobs };
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+
+/**
+ * The Arena's output safety. A model writes a page; Design stores it as text and never puts it in its own
+ * document. Two layers:
+ *
+ * 1. sanitizeHtml(): strip <script>, on* attributes, javascript: URLs and a few navigation tags before storing,
+ * and cap the text at 200 KB.
+ * 2. iframeHtml(): the only place an output is rendered is `<iframe sandbox srcdoc="…">` with no allow-scripts
+ * and no allow-same-origin, and the srcdoc document starts with a CSP meta that blocks every network fetch:
+ * `default-src 'none'; style-src 'unsafe-inline'; img-src data:`.
+ *
+ * Nothing here trusts the model. The escaped srcdoc is built with attrEscape(), so the output can never close the
+ * attribute or the iframe.
+ */
+const { esc } = require('openvibe-shared/kit');
+
+const MAX_BYTES = 200 * 1024;
+const CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
+
+/** An attribute value for a double-quoted srcdoc: apostrophes may stay literal (CSP's 'none' stays readable). */
+const attrEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/** What a browser would read from a URL attribute, enough to catch javascript: split by entities or whitespace. */
+function urlLooksLikeScript(raw) {
+ const decoded = String(raw)
+ .replace(/&#x([0-9a-f]+);?/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
+ .replace(/&#(\d+);?/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
+ .replace(/:?/gi, ':').replace(/&tab;?/gi, '\t').replace(/&newline;?/gi, '\n');
+ return /javascript\s*:/i.test(decoded.replace(/[\s\u0000-\u001f]+/g, ''));
+}
+
+
+/** The end of a tag, skipping a `>` inside a quoted attribute value (the HTML parser does the same). */
+function tagEnd(s, start) {
+ let quote = null;
+ for (let i = start + 1; i < s.length; i++) {
+ const c = s[i];
+ if (quote) { if (c === quote) quote = null; }
+ else if (c === '"' || c === "'") quote = c;
+ else if (c === '>') return i;
+ }
+ return -1;
+}
+
+/** Strip event handlers and javascript: URLs from one tag; the tag's own angle brackets are kept. */
+function sanitizeTag(tag) {
+ let t = tag;
+ t = t.replace(/([\s/])on[a-z0-9_:.-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '$1');
+ t = t.replace(/\s(on[a-z0-9_:.-]*)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
+ t = t.replace(/(\b(?:href|src|action|formaction|xlink:href|poster|background|data)\s*=\s*)((?:"[^"]*"|'[^']*'|[^\s>]+))/gi,
+ (m, name, value) => (urlLooksLikeScript(value) ? `${name}"#"` : m));
+ return t;
+}
+
+/** Walk tags with the parser's quote rule instead of `[^>]*`, so a `>` inside a value cannot hide a handler. */
+function sanitizeTags(s) {
+ let out = '';
+ let i = 0;
+ while (i < s.length) {
+ const lt = s.indexOf('<', i);
+ if (lt === -1) { out += s.slice(i); break; }
+ out += s.slice(i, lt);
+ const next = s[lt + 1] || '';
+ if (!/[a-z/]/i.test(next)) { out += '<'; i = lt + 1; continue; }
+ const end = tagEnd(s, lt);
+ if (end === -1) { out += s.slice(lt); break; }
+ out += sanitizeTag(s.slice(lt, end + 1));
+ i = end + 1;
+ }
+ return out;
+}
+
+function truncateBytes(s, maxBytes) {
+ if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
+ // Slice bytes, then drop a half-written UTF-8 character at the end.
+ const buf = Buffer.from(s, 'utf8').subarray(0, maxBytes);
+ let out = buf.toString('utf8');
+ if (out.endsWith('\uFFFD')) out = out.slice(0, -1);
+ return out;
+}
+
+function sanitizeHtml(input, { maxBytes = MAX_BYTES } = {}) {
+ let s = input == null ? '' : String(input);
+ s = s.replace(/\u0000/g, '');
+ s = truncateBytes(s, maxBytes);
+
+ // Comments can hide a script from a naive parser; remove them before anything else.
+ s = s.replace(/<!--[\s\S]*?-->/g, '');
+
+ // <script>…</script>, including attributes and whitespace; repeat for nested or overlapping attempts.
+ for (let i = 0; i < 4; i++) {
+ const before = s;
+ s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '');
+ s = s.replace(/<script\b[^>]*\/\s*>/gi, '');
+ if (s === before) break;
+ }
+ // An unclosed script would swallow the rest of the page: drop its opening tag (and any stray closing one).
+ s = s.replace(/<\/?script\b[^>]*>/gi, '');
+ // Anything that still looks like a script tag (a malformed `<script/x>`) is escaped, never a tag.
+ s = s.replace(/<(\/?)(script)\b/gi, '<$1$2');
+
+ // Navigation / embedding tags that a generated page never needs.
+ s = s.replace(/<base\b[^>]*>/gi, '');
+ s = s.replace(/<meta\b[^>]*http-equiv\s*=\s*(?:"refresh"|'refresh'|refresh)[^>]*>/gi, '');
+ s = s.replace(/<\/?(?:iframe|object|embed|applet)\b[^>]*>/gi, '');
+
+ // Attributes: on* handlers and javascript: URLs, with quoted `>` handled as the browser handles it.
+ s = sanitizeTags(s);
+
+ // Belt and braces: no javascript: URL survives in any form (including whitespace/HTML entities between letters).
+ s = s.replace(/j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:/gi, 'blocked:');
+ if (/<script/i.test(s)) s = s.replace(/<(\/?)(script)/gi, '<$1$2');
+
+ return truncateBytes(s, maxBytes);
+}
+
+/** The document inside the iframe: CSP first, then the model's sanitized page. */
+function frameDocument(html) {
+ return `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`
+ + `<meta http-equiv="Content-Security-Policy" content="${CSP}">`
+ + `<style>html,body{margin:0;min-height:100%;background:#fff;}img{max-width:100%;height:auto;}</style>`
+ + sanitizeHtml(html);
+}
+
+/**
+ * The only renderer for a model output at a viewport width. `sandbox=""` is the strictest sandbox: no scripts,
+ * no same-origin, no forms, no popups, no top navigation. The CSP meta is already inside the escaped srcdoc.
+ */
+function iframeHtml({ html = '', width = 390, height = 844, title = 'A generated design', id = null, className = '' } = {}) {
+ const w = Math.max(1, Math.round(Number(width) || 390));
+ const h = Math.max(1, Math.round(Number(height) || 844));
+ // Keep the attribute list explicit rather than clever: a generated page must never influence it.
+ return `<iframe${id ? ` id="${esc(id)}"` : ''} class="ar-frame${className ? ` ${esc(className)}` : ''}"`
+ + ` title="${esc(title)}" width="${w}" height="${h}" data-width="${w}"`
+ + ` sandbox="" loading="lazy" referrerpolicy="no-referrer"`
+ + ` srcdoc="${attrEscape(frameDocument(html))}"></iframe>`;
+}
+
+module.exports = { sanitizeHtml, frameDocument, iframeHtml, truncateBytes, MAX_BYTES, CSP, attrEscape, urlLooksLikeScript };
new file mode 100644
new file mode 100644
@@ -0,0 +1,277 @@
@@ -0,0 +1,277 @@
+'use strict';
+
+/**
+ * The Arena's data access: rounds, outputs, votes, ratings, the open dataset and the day's budgets. Every
+ * person-facing write is a transaction; a vote and the rating changes it causes commit together, so a crash can
+ * never leave a vote without its Elo update. Votes are keyed by (round, voter_subject): one per person per round.
+ */
+const { ids } = require('openvibe-contracts');
+
+const CATEGORIES = Object.freeze(['landing', 'dashboard', 'mobile_app', 'game_ui', 'logo', 'slides']);
+const WINNERS = Object.freeze(['left', 'right', 'tie', 'both_bad']);
+const K = 32;
+const BASE_ELO = 1000;
+
+class ArenaError extends Error {
+ constructor(status, code, detail, extra = {}) {
+ super(detail);
+ this.name = 'ArenaError';
+ this.status = status;
+ this.code = code;
+ this.detail = detail;
+ this.extra = extra;
+ }
+}
+
+const num = (v, d = 0) => (v == null || v === '' || Number.isNaN(Number(v)) ? d : Number(v));
+const roundOf = (r) => (r ? {
+ id: r.id,
+ category: r.category,
+ prompt: r.prompt,
+ status: r.status,
+ // The offers the round asked AI for ({provider, model}); a pool slot is provider 'ai' with no model.
+ offers: Array.isArray(r.offers) ? r.offers : [],
+ // True only when the routing offered fewer than three distinct offers, so the rest came from the pool.
+ pool_fallback: Boolean(r.pool_fallback),
+ created_at: r.created_at,
+} : null);
+const outputOf = (o) => ({
+ id: o.id,
+ round: o.round,
+ model: o.model,
+ provider: o.provider,
+ html: o.html,
+ cost_usd: num(o.cost_usd),
+ ms: num(o.ms),
+ tokens: num(o.tokens),
+ error: o.error || null,
+ ui_check: o.ui_check == null ? null : o.ui_check,
+ created_at: o.created_at,
+});
+const voteOf = (v) => ({ round: v.round, left_output: v.left_output, right_output: v.right_output, winner: v.winner, at: v.at });
+
+/** Fisher–Yates, with an injectable random source so a test can pin the order. */
+function shuffle(list, random = Math.random) {
+ const a = list.slice();
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
+
+function createArenaStore({ s, config, log = console }) {
+ const db = s.db;
+ const perDay = config.arena.perDay;
+ const daily = config.arena.daily;
+
+ const dayStart = (at = s.now()) => {
+ const d = new Date(at);
+ d.setUTCHours(0, 0, 0, 0);
+ return d.toISOString();
+ };
+
+ async function counts(subject) {
+ const start = dayStart();
+ const used = num(await db.value('SELECT COUNT(*)::int AS n FROM arena_rounds WHERE created_by = $1 AND created_at >= $2::timestamptz', [subject, start]));
+ const siteUsed = num(await db.value('SELECT COUNT(*)::int AS n FROM arena_rounds WHERE created_at >= $1::timestamptz', [start]));
+ return {
+ used,
+ remaining: Math.max(0, perDay - used),
+ per_day: perDay,
+ site_used: siteUsed,
+ site_daily: daily,
+ site_remaining: Math.max(0, daily - siteUsed),
+ };
+ }
+
+ async function createRound({ category, prompt, subject }) {
+ if (!CATEGORIES.includes(category)) throw new ArenaError(422, 'arena.invalid_category', `Pick a category: ${CATEGORIES.join(', ')}.`);
+ const text = String(prompt == null ? '' : prompt).trim();
+ if (!text) throw new ArenaError(422, 'arena.prompt_required', 'Describe the page you want the models to design.');
+ if (text.length > config.arena.maxPrompt) throw new ArenaError(422, 'arena.prompt_too_long', `The prompt must be at most ${config.arena.maxPrompt} characters.`);
+ const at = s.now();
+ const start = dayStart(at);
+ const id = `arr_${ids.ulid(at)}`;
+ const row = await db.maybe(
+ `INSERT INTO arena_rounds (id, category, prompt, created_by, status, created_at)
+ SELECT $1, $2, $3, $4, 'queued', $5::timestamptz
+ WHERE (SELECT COUNT(*) FROM arena_rounds WHERE created_by = $4 AND created_at >= $6::timestamptz) < $7
+ AND (SELECT COUNT(*) FROM arena_rounds WHERE created_at >= $6::timestamptz) < $8
+ RETURNING *`,
+ [id, category, text, subject, s.iso(), start, perDay, daily],
+ );
+ if (!row) {
+ const c = await counts(subject);
+ if (c.used >= perDay) {
+ throw new ArenaError(429, 'arena.limit_person',
+ `You can start ${perDay} Arena round${perDay === 1 ? '' : 's'} a day. You have started ${c.used} today; try again tomorrow.`);
+ }
+ throw new ArenaError(429, 'arena.limit_site',
+ `The Arena has reached its ${daily} round${daily === 1 ? '' : 's'} for today. Try again tomorrow.`);
+ }
+ return await getRound(id);
+ }
+
+ async function getRound(id) {
+ const r = await db.maybe('SELECT * FROM arena_rounds WHERE id = $1', [id]);
+ if (!r) return null;
+ const outputs = (await db.many('SELECT * FROM arena_outputs WHERE "round" = $1 ORDER BY created_at, id', [id])).map(outputOf);
+ const votes = num(await db.value('SELECT COUNT(*)::int AS n FROM arena_votes WHERE "round" = $1', [id]));
+ return { ...roundOf(r), outputs, votes };
+ }
+
+ /** A ready round this person has not voted on and did not start, with its two successful outputs shuffled. */
+ async function nextPair(subject, { random = Math.random } = {}) {
+ const r = await db.maybe(
+ `SELECT r.* FROM arena_rounds r
+ WHERE r.status = 'ready' AND r.created_by <> $1
+ AND (SELECT COUNT(*) FROM arena_outputs o WHERE o."round" = r.id AND o.error IS NULL AND o.html <> '') >= 2
+ AND NOT EXISTS (SELECT 1 FROM arena_votes v WHERE v."round" = r.id AND v.voter_subject = $1)
+ ORDER BY random() LIMIT 1`,
+ [subject],
+ );
+ if (!r) return null;
+ const outputs = (await db.many('SELECT * FROM arena_outputs WHERE "round" = $1 AND error IS NULL AND html <> \'\' ORDER BY id', [r.id])).map(outputOf);
+ if (outputs.length < 2) return null;
+ const [left, right] = shuffle(outputs, random).slice(0, 2);
+ // Blind: the model, provider, cost and time are not part of this answer. The HTML is (in the iframe).
+ return {
+ round: { id: r.id, category: r.category, prompt: r.prompt },
+ left: { id: left.id, html: left.html },
+ right: { id: right.id, html: right.html },
+ };
+ }
+
+ async function applyElo(t, category, left, right, winner, atIso) {
+ if (winner === 'both_bad') return; // a game for neither
+ if (left.model === right.model) return; // two outputs of one model are not a match between designers
+ const models = [left.model, right.model];
+ for (const model of models) {
+ await t.exec(`INSERT INTO arena_ratings (category, model, elo, games, wins, losses, ties, updated_at)
+ VALUES ($1, $2, $3, 0, 0, 0, 0, $4::timestamptz)
+ ON CONFLICT (category, model) DO NOTHING`, [category, model, BASE_ELO, atIso]);
+ }
+ const rows = await t.many('SELECT * FROM arena_ratings WHERE category = $1 AND model = ANY($2::text[]) ORDER BY model FOR UPDATE', [category, models]);
+ const byModel = new Map(rows.map((r) => [r.model, r]));
+ const a = byModel.get(left.model);
+ const b = byModel.get(right.model);
+ if (!a || !b) return;
+ const ra = num(a.elo, BASE_ELO);
+ const rb = num(b.elo, BASE_ELO);
+ const ea = 1 / (1 + (10 ** ((rb - ra) / 400)));
+ const eb = 1 - ea;
+ const sa = winner === 'left' ? 1 : (winner === 'right' ? 0 : 0.5);
+ const sb = 1 - sa;
+ const nextA = Math.round((ra + K * (sa - ea)) * 100) / 100;
+ const nextB = Math.round((rb + K * (sb - eb)) * 100) / 100;
+ const update = async (row, elo, score, oppScore) => {
+ await t.exec(`UPDATE arena_ratings SET elo = $1, games = games + 1,
+ wins = wins + $2, losses = losses + $3, ties = ties + $4, updated_at = $5::timestamptz
+ WHERE category = $6 AND model = $7`,
+ [elo, score > oppScore ? 1 : 0, score < oppScore ? 1 : 0, score === oppScore ? 1 : 0, atIso, category, row.model]);
+ };
+ await update(a, nextA, sa, sb);
+ await update(b, nextB, sb, sa);
+ }
+
+ async function vote({ roundId, subject, leftOutput, rightOutput, winner }) {
+ if (!WINNERS.includes(winner)) throw new ArenaError(422, 'arena.invalid_winner', 'The winner must be left, right, tie or both_bad.');
+ if (!leftOutput || !rightOutput || leftOutput === rightOutput) throw new ArenaError(422, 'arena.invalid_pair', 'A vote needs two different outputs.');
+ try {
+ const result = await s.tx(async () => {
+ const r = await db.maybe('SELECT * FROM arena_rounds WHERE id = $1 FOR UPDATE', [roundId]);
+ if (!r) throw new ArenaError(404, 'arena.round_not_found', 'No such Arena round.');
+ if (r.created_by === subject) throw new ArenaError(403, 'arena.creator_cannot_vote', 'You cannot vote on a round you started.');
+ if (r.status !== 'ready') throw new ArenaError(409, 'arena.round_not_ready', 'This round is not ready to vote on yet.');
+ const outputs = (await db.many('SELECT * FROM arena_outputs WHERE "round" = $1 AND id = ANY($2::text[]) AND error IS NULL AND html <> \'\'', [roundId, [leftOutput, rightOutput]])).map(outputOf);
+ if (outputs.length !== 2) throw new ArenaError(422, 'arena.invalid_pair', 'Those two outputs are not both part of this round.');
+ const left = outputs.find((o) => o.id === leftOutput);
+ const right = outputs.find((o) => o.id === rightOutput);
+ const prior = await db.maybe('SELECT 1 AS n FROM arena_votes WHERE "round" = $1 AND voter_subject = $2', [roundId, subject]);
+ if (prior) throw new ArenaError(409, 'arena.already_voted', 'You have already voted on this round.');
+ const at = s.iso();
+ await db.exec('INSERT INTO arena_votes ("round", voter_subject, left_output, right_output, winner, at) VALUES ($1, $2, $3, $4, $5, $6::timestamptz)',
+ [roundId, subject, leftOutput, rightOutput, winner, at]);
+ await applyElo(db, r.category, left, right, winner, at);
+ return { round: roundOf(r), winner, at, left, right };
+ });
+ return result;
+ } catch (err) {
+ if (err && err.code === '23505') throw new ArenaError(409, 'arena.already_voted', 'You have already voted on this round.');
+ throw err;
+ }
+ }
+
+ async function leaderboard(category = null) {
+ const rows = await db.many(
+ `WITH models AS (
+ SELECT category, model FROM arena_ratings WHERE ($1::text IS NULL OR category = $1)
+ UNION
+ SELECT r.category, o.model FROM arena_outputs o JOIN arena_rounds r ON r.id = o."round"
+ WHERE r.status = 'ready' AND o.error IS NULL AND ($1::text IS NULL OR r.category = $1)
+ ), medians AS (
+ SELECT r.category, o.model,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY o.cost_usd) AS median_cost,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY o.ms) AS median_ms
+ FROM arena_outputs o JOIN arena_rounds r ON r.id = o."round"
+ WHERE r.status = 'ready' AND o.error IS NULL AND ($1::text IS NULL OR r.category = $1)
+ GROUP BY r.category, o.model
+ )
+ SELECT m.category, m.model, COALESCE(rt.elo, $2) AS elo,
+ COALESCE(rt.games, 0) AS games, COALESCE(rt.wins, 0) AS wins,
+ COALESCE(rt.losses, 0) AS losses, COALESCE(rt.ties, 0) AS ties,
+ med.median_cost, med.median_ms
+ FROM models m
+ LEFT JOIN arena_ratings rt ON rt.category = m.category AND rt.model = m.model
+ LEFT JOIN medians med ON med.category = m.category AND med.model = m.model
+ ORDER BY m.category, elo DESC, games DESC, m.model`,
+ [category, BASE_ELO],
+ );
+ const all = rows.map((r) => {
+ const games = num(r.games);
+ const wins = num(r.wins);
+ const ties = num(r.ties);
+ return {
+ category: r.category,
+ model: r.model,
+ elo: Math.round(num(r.elo, BASE_ELO) * 100) / 100,
+ games,
+ wins,
+ losses: num(r.losses),
+ ties,
+ // A tie is half a win, as in Elo's expected score.
+ win_rate: games ? Math.round(((wins + ties * 0.5) / games) * 10000) / 10000 : 0,
+ median_cost_usd: r.median_cost == null ? null : Math.round(num(r.median_cost) * 1e6) / 1e6,
+ median_cost: r.median_cost == null ? null : Math.round(num(r.median_cost) * 1e6) / 1e6,
+ median_ms: r.median_ms == null ? null : Math.round(num(r.median_ms)),
+ };
+ });
+ if (category) return all;
+ return CATEGORIES.map((c) => ({ category: c, leaderboard: all.filter((r) => r.category === c) }));
+ }
+
+ /** The open dataset: prompts, every output (HTML included) and every vote without its voter. */
+ async function dataset() {
+ const rounds = await db.many('SELECT id, category, prompt, status, created_at FROM arena_rounds ORDER BY created_at, id');
+ const outputs = (await db.many('SELECT * FROM arena_outputs ORDER BY created_at, id')).map(outputOf);
+ const votes = (await db.many('SELECT "round", left_output, right_output, winner, at FROM arena_votes ORDER BY at, "round"')).map(voteOf);
+ const byRound = new Map(rounds.map((r) => [r.id, { ...r, outputs: [], votes: [] }]));
+ for (const o of outputs) { const r = byRound.get(o.round); if (r) r.outputs.push(o); }
+ for (const v of votes) { const r = byRound.get(v.round); if (r) r.votes.push(v); }
+ return [...byRound.values()];
+ }
+
+ async function datasetJsonl() {
+ const lines = (await dataset()).map((r) => JSON.stringify(r));
+ return lines.length ? `${lines.join('\n')}\n` : '';
+ }
+
+ /** A current day's budget summary for the pages and the create answer. */
+ return {
+ CATEGORIES, WINNERS, K, BASE_ELO,
+ counts, createRound, getRound, nextPair, vote, leaderboard, dataset, datasetJsonl, shuffle,
+ };
+}
+
+module.exports = { createArenaStore, ArenaError, CATEGORIES, WINNERS, K, BASE_ELO, shuffle };
@@ -153,10 +153,21 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -153,10 +153,21 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
}
}
}
}
- /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
+ /**
+ * req.viewer for every page: a Bearer session token when the caller sends one (an API client, not a
+ * browser form), else the httpOnly cookie, refreshed when its token expired.
+ */
function middleware() {
function middleware() {
return async (req, res, next) => {
return async (req, res, next) => {
req.viewer = { kind: 'anonymous' };
req.viewer = { kind: 'anonymous' };
+ const header = String(req.get('authorization') || '');
+ const bearerToken = /^Bearer\s+\S/i.test(header) ? header.replace(/^Bearer\s+/i, '').trim() : null;
+ if (bearerToken) {
+ await keys.ensure();
+ const v = await verifySession(bearerToken);
+ if (v.ok) req.viewer = viewerFromClaims(v.claims, bearerToken);
+ return next();
+ }
const token = req.cookies && req.cookies[ACCESS_COOKIE];
const token = req.cookies && req.cookies[ACCESS_COOKIE];
if (!token) return next();
if (!token) return next();
await keys.ensure();
await keys.ensure();
@@ -22,6 +22,7 @@ function load(env = process.env) {
@@ -22,6 +22,7 @@ function load(env = process.env) {
// token ever talks to it directly.
// token ever talks to it directly.
const networkPublicUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
const networkPublicUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
const networkInternalUrl = trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000');
const networkInternalUrl = trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000');
+ const aiUrl = trim(env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700');
return {
return {
service: 'design',
service: 'design',
@@ -32,6 +33,7 @@ function load(env = process.env) {
@@ -32,6 +33,7 @@ function load(env = process.env) {
isProduction,
isProduction,
baseUrl,
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ drill: env.DESIGN_DRILL === '1' || env.DESIGN_DRILL === 'true',
// The theme API proxy (server/http/api.js): every call goes to the Network's /api/themes. `networkUrl`
// The theme API proxy (server/http/api.js): every call goes to the Network's /api/themes. `networkUrl`
// is the internal origin the proxy and the token exchange use; `networkPublicUrl` is the browser's.
// is the internal origin the proxy and the token exchange use; `networkPublicUrl` is the browser's.
@@ -42,6 +44,42 @@ function load(env = process.env) {
@@ -42,6 +44,42 @@ function load(env = process.env) {
themeCacheMs: Math.max(0, int(env.DESIGN_THEME_CACHE_MS, 5 * 60_000)),
themeCacheMs: Math.max(0, int(env.DESIGN_THEME_CACHE_MS, 5 * 60_000)),
timeoutMs: Math.max(200, int(env.DESIGN_UPSTREAM_TIMEOUT_MS, 8000)),
timeoutMs: Math.max(200, int(env.DESIGN_UPSTREAM_TIMEOUT_MS, 8000)),
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
+ // development without DATABASE_URL an embedded PGlite database in data/pglite is used.
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.DESIGN_PGLITE_DIR || env.DESIGN_DATABASE_DIR || env.PGLITE_DIR || '' },
+
+ // OpenVibe.AI: the generate operation. Design is a first-party service and asks for a client-credentials
+ // token (audience openvibe.ai, scope ai.run.create; Network grants the ai.* namespace). Generation is
+ // picked up by the job loop, never by the request that creates a round.
+ ai: {
+ url: aiUrl,
+ audience: env.DESIGN_AI_AUDIENCE || 'openvibe.ai',
+ scope: env.DESIGN_AI_SCOPE || 'ai.run.create',
+ waitMs: Math.max(1000, int(env.DESIGN_AI_WAIT_MS, 55_000)),
+ timeoutMs: Math.max(1000, int(env.DESIGN_AI_TIMEOUT_MS, 120_000)),
+ pollMs: Math.max(100, int(env.DESIGN_AI_POLL_MS, 1500)),
+ // Optional explicit slots, `provider:model,provider:model,provider:model`. Without it Design reads the
+ // models the chat/generate route offers and asks the router for three of them.
+ models: String(env.DESIGN_ARENA_MODELS || '').split(',').map((x) => x.trim()).filter(Boolean),
+ maxHtmlBytes: 200 * 1024,
+ },
+
+ // Arena budgets and jobs. Both limits are per UTC day; the drill flag keeps the worker from starting.
+ arena: {
+ perDay: Math.max(1, int(env.DESIGN_ARENA_PER_DAY, 3)),
+ daily: Math.max(1, int(env.DESIGN_ARENA_DAILY, 20)),
+ intervalMs: Math.max(250, int(env.DESIGN_ARENA_JOB_INTERVAL_MS, 1500)),
+ pollMs: Math.max(250, int(env.DESIGN_ARENA_POLL_MS, 20_000)),
+ maxPrompt: Math.max(20, int(env.DESIGN_ARENA_MAX_PROMPT, 4000)),
+ },
+
+ // OpenVibe.Events → this service for ADR-033 account export/deletion. Off when the secret is unset.
+ events: {
+ secrets: String(env.DESIGN_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.DESIGN_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.DESIGN_EVENTS_ENDPOINT || '',
+ },
+
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'design',
clientId: env.OV_OAUTH_CLIENT_ID || 'design',
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+
+/**
+ * OpenVibe.Design's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
+ * The Arena's rounds, outputs, votes and ratings live here, and so does the SDK's account_data_events
+ * idempotency table (ADR-033). Nothing written here is a secret.
+ */
+const fs = require('fs');
+const path = require('path');
+const { createDb } = require('openvibe-sdk/db');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
+
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh)');
+ const dir = config.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[OpenVibe.Design] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'design', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
+ }
+ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: config.db.directUrl, service: 'design-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'design', registry, log });
+}
+
+/** The store every arena module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
+function createStore(db, { now = () => Date.now() } = {}) {
+ return {
+ db,
+ now,
+ iso: () => new Date(now()).toISOString(),
+ newId: (prefix) => `${prefix}_${require('openvibe-contracts').ids.ulid(now())}`,
+ tx: async (fn) => await db.tx(() => fn()),
+ close: () => db.close(),
+ };
+}
+
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+module.exports = { openDb, openStore, createStore, MIGRATIONS };
@@ -20,10 +20,7 @@
@@ -20,10 +20,7 @@
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { ACCESS_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE } = require('../auth/sso');
-
-function ownOrigin(config) {
- try { return new URL(config.baseUrl).origin; } catch { return null; }
-}
+const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');
/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */
/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */
function passThrough(res, r) {
function passThrough(res, r) {
@@ -51,23 +48,12 @@ function forward(res, req, up) {
@@ -51,23 +48,12 @@ function forward(res, req, up) {
function createApi(ctx) {
function createApi(ctx) {
const { config, catalog, log } = ctx;
const { config, catalog, log } = ctx;
const r = asyncRouter();
const r = asyncRouter();
- const origin = ownOrigin(config);
-
- /** Writes (PUT/POST): only this site's own origin may ask for them. */
- function requireOrigin(req, res, next) {
- const sent = String(req.get('origin') || '');
- if (!origin || sent !== origin) {
- return http.sendProblem(res, 403, 'design.cross_site', {
- detail: 'A change to a theme must come from openvibe.design itself.',
- ctx: req.ov,
- });
- }
- return next();
- }
+ // A cookie write needs a same-origin signal; a Bearer write is not cookie-authenticated and is left alone.
+ const requireOrigin = requireSameOrigin(config, { detail: 'A change to a theme must come from openvibe.design itself.' });
- /** The person's Network access token, from the httpOnly cookie only. */
+ /** The person's Network access token: the Bearer they sent, else the httpOnly cookie. */
function requireToken(req, res, next) {
function requireToken(req, res, next) {
- const token = req.cookies && req.cookies[ACCESS_COOKIE];
+ const token = bearer(req) || (req.cookies && req.cookies[ACCESS_COOKIE]);
if (!token) {
if (!token) {
return http.sendProblem(res, 401, 'token.required', {
return http.sendProblem(res, 401, 'token.required', {
detail: 'Sign in at openvibe.design to use your theme.',
detail: 'Sign in at openvibe.design to use your theme.',
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+
+/**
+ * The Arena API, /api/v1/arena/*:
+ *
+ * GET /categories the six categories
+ * POST /rounds start a round (signed in, same-origin, daily budgets)
+ * GET /rounds/:id one round with its outputs
+ * GET /vote the next blind pair for this person
+ * POST /vote record a vote, reveal the two models and update Elo
+ * GET /leaderboard[?category=…] Elo, games, win rate, median cost and ms
+ * GET /dataset.jsonl every prompt, output and vote, without voter ids
+ *
+ * A round is queued here; the job loop generates it. The request never waits for a model.
+ */
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('../http/router');
+const { ArenaError, CATEGORIES } = require('../arena/store');
+const { ownOrigin, requireSameOrigin } = require('./same-origin');
+
+function subjectOf(req) {
+ return req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? req.viewer.subject : null;
+}
+
+function createArenaApi(ctx) {
+ const { arena, config, log } = ctx;
+ const r = asyncRouter();
+
+ const problem = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+
+ // A cookie-authenticated write must carry Sec-Fetch-Site: same-origin or an Origin equal to this
+ // site; a missing signal is refused. A Bearer write is not cookie-authenticated and is unaffected.
+ const requireOrigin = requireSameOrigin(config, { detail: 'An Arena change must come from openvibe.design itself.' });
+
+ function requireSubject(req, res, next) {
+ if (!subjectOf(req)) return problem(res, req, 401, 'token.required', 'Sign in at openvibe.design to use the Arena.');
+ return next();
+ }
+
+ const failed = (res, req, err) => {
+ if (err instanceof ArenaError) return problem(res, req, err.status, err.code, err.detail);
+ log.warn('[OpenVibe.Design] Arena API failed:', (err && err.message) || err);
+ return problem(res, req, 500, 'internal.error', 'The Arena could not answer. Try again in a moment.');
+ };
+
+ r.get('/categories', (_req, res) => res.json({ categories: CATEGORIES }));
+
+ async function leaderboard(req, res) {
+ const category = req.query.category == null || req.query.category === '' ? null : String(req.query.category);
+ if (category && !CATEGORIES.includes(category)) {
+ return problem(res, req, 400, 'arena.invalid_category', `Pick a category: ${CATEGORIES.join(', ')}.`);
+ }
+ try {
+ const list = await arena.leaderboard(category);
+ res.set('Cache-Control', 'public, max-age=30');
+ if (category) return res.json({ category, leaderboard: list });
+ return res.json({ categories: list, leaderboard: list.flatMap((c) => c.leaderboard) });
+ } catch (err) { return failed(res, req, err); }
+ }
+ r.get('/leaderboard', leaderboard);
+ r.get('/leaderboards', leaderboard);
+
+ async function dataset(req, res) {
+ try {
+ const text = await arena.datasetJsonl();
+ res.set('Cache-Control', 'public, max-age=60');
+ res.set('Content-Disposition', 'attachment; filename="arena-dataset.jsonl"');
+ return res.type('application/x-ndjson').send(text);
+ } catch (err) { return failed(res, req, err); }
+ }
+ r.get('/dataset.jsonl', dataset);
+ r.get('/dataset', dataset);
+
+ r.post('/rounds', requireOrigin, requireSubject, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ try {
+ const round = await arena.createRound({ category: String(b.category || ''), prompt: b.prompt, subject: subjectOf(req) });
+ res.set('Cache-Control', 'private, no-store');
+ res.set('Location', `/api/v1/arena/rounds/${round.id}`);
+ return res.status(201).json({ round });
+ } catch (err) { return failed(res, req, err); }
+ });
+
+ async function readRound(req, res) {
+ try {
+ const round = await arena.getRound(String(req.params.id || ''));
+ if (!round) return problem(res, req, 404, 'arena.round_not_found', 'No such Arena round.');
+ res.set('Cache-Control', 'public, max-age=30');
+ return res.json({ round });
+ } catch (err) { return failed(res, req, err); }
+ }
+ r.get('/rounds/:id', readRound);
+ r.get('/round/:id', readRound);
+
+ async function nextPair(req, res) {
+ try {
+ const pair = await arena.nextPair(subjectOf(req), { random: ctx.random || Math.random });
+ res.set('Cache-Control', 'private, no-store');
+ if (!pair) return res.json({ pair: null, detail: 'There is no round waiting for your vote. Start one, or come back when another is ready.' });
+ return res.json(pair);
+ } catch (err) { return failed(res, req, err); }
+ }
+ r.get('/vote', requireSubject, nextPair);
+ r.get('/vote/next', requireSubject, nextPair);
+ r.get('/next', requireSubject, nextPair);
+
+ async function castVote(req, res) {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ try {
+ const out = await arena.vote({
+ roundId: String(b.round_id || b.round || ''),
+ subject: subjectOf(req),
+ leftOutput: String(b.left_output || b.left || ''),
+ rightOutput: String(b.right_output || b.right || ''),
+ winner: String(b.winner || ''),
+ });
+ res.set('Cache-Control', 'private, no-store');
+ return res.status(201).json({
+ vote: { round: out.round.id, left_output: out.left.id, right_output: out.right.id, winner: out.winner, at: out.at },
+ revealed: { left: out.left, right: out.right },
+ });
+ } catch (err) { return failed(res, req, err); }
+ }
+ r.post('/vote', requireOrigin, requireSubject, castVote);
+ r.post('/votes', requireOrigin, requireSubject, castVote);
+
+ return r;
+}
+
+module.exports = { createArenaApi, ownOrigin, subjectOf };
@@ -29,6 +29,7 @@ function publicPages(themes = []) {
@@ -29,6 +29,7 @@ function publicPages(themes = []) {
const static_ = [
const static_ = [
{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },
{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },
{ path: '/studio', changefreq: 'weekly', priority: 0.9, title: 'The theme studio', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },
{ path: '/studio', changefreq: 'weekly', priority: 0.9, title: 'The theme studio', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },
+ { path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
];
];
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
path: `/t/${encodeURIComponent(String(t.slug))}`,
path: `/t/${encodeURIComponent(String(t.slug))}`,
@@ -67,11 +68,13 @@ function createDiscoveryRoutes(ctx) {
@@ -67,11 +68,13 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
+ { title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
] },
] },
{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },
{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },
{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },
+ { title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
] },
] },
@@ -1,19 +1,25 @@
@@ -1,19 +1,25 @@
'use strict';
'use strict';
/**
/**
- * The public pages:
- * / the theme catalogue's front door
- * /studio the theme studio (the shell the main session's public/js/studio.js fills)
- * /t/:slug one public theme
- * /developers the API, for people and agents
+ * The pages:
+ * / the theme catalogue's front door
+ * /studio the theme studio
+ * /t/:slug one public theme
+ * /arena the Arena leaderboard and its front door
+ * /arena/new start a round (the form also posts here, so it works without JavaScript)
+ * /arena/vote the next blind pair, and the reveal after a vote
+ * /arena/r/:id one round: every output at 390 px and 1280 px, with cost and time
+ * /developers the API
*
*
- * Every page is server-rendered and complete without JavaScript. A page for a signed-in person is private and
- * never cached; a public page gets the short 120-second window. `me` comes from the session the SSO middleware
- * already resolved — a guest never causes a Network call to find out they are a guest.
+ * Every page is server-rendered. The Arena's model HTML is only ever inside `<iframe sandbox srcdoc>`; the pages
+ * themselves never contain it. `me` comes from the session the SSO middleware already resolved.
*/
*/
+const express = require('express');
const views = require('../views');
const views = require('../views');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
+const { ArenaError } = require('../arena/store');
+const { writeAllowed } = require('./same-origin');
function meOf(viewer) {
function meOf(viewer) {
if (!viewer || viewer.kind !== 'user') return { signedIn: false, user: null };
if (!viewer || viewer.kind !== 'user') return { signedIn: false, user: null };
@@ -30,15 +36,21 @@ function meOf(viewer) {
@@ -30,15 +36,21 @@ function meOf(viewer) {
};
};
}
}
+const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
+
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { catalog, log } = ctx;
+ const { config, catalog, arena, log } = ctx;
const r = asyncRouter();
const r = asyncRouter();
- // The catalogue never throws in a page: a Network failure degrades to the page without its list.
const safe = (fn) => Promise.resolve().then(fn).catch((err) => {
const safe = (fn) => Promise.resolve().then(fn).catch((err) => {
log.warn('[OpenVibe.Design] page catalogue failed:', (err && err.message) || err);
log.warn('[OpenVibe.Design] page catalogue failed:', (err && err.message) || err);
return null;
return null;
});
});
const page = (res, status, html, viewer) => send(res, status, html, { viewer });
const page = (res, status, html, viewer) => send(res, status, html, { viewer });
+ const categories = arena.CATEGORIES;
+ const aria = async (viewer) => {
+ const subject = viewer && viewer.kind === 'user' ? viewer.subject : null;
+ return subject ? await arena.counts(subject).catch(() => null) : null;
+ };
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const list = await safe(() => catalog.list());
const list = await safe(() => catalog.list());
@@ -67,6 +79,83 @@ function createPageRoutes(ctx) {
@@ -67,6 +79,83 @@ function createPageRoutes(ctx) {
return page(res, 200, views.theme({ theme: up.data.theme }), req.viewer);
return page(res, 200, views.theme({ theme: up.data.theme }), req.viewer);
});
});
+ // ── Arena ───────────────────────────────────────────────
+ r.get('/arena', async (req, res) => {
+ const me = meOf(req.viewer);
+ let boards;
+ try { boards = await arena.leaderboard(null); } catch (err) {
+ log.warn('[OpenVibe.Design] leaderboard failed:', (err && err.message) || err);
+ boards = categories.map((category) => ({ category, leaderboard: [] }));
+ }
+ return page(res, 200, views.arenaHome({ categories: boards, me, limits: await aria(req.viewer) }), req.viewer);
+ });
+
+ r.get('/arena/new', async (req, res) => {
+ return page(res, 200, views.arenaNew({ me: meOf(req.viewer), limits: await aria(req.viewer), categories }), req.viewer);
+ });
+
+ r.post('/arena/new', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
+ const me = meOf(req.viewer);
+ if (!subjectOf(req)) return res.redirect(303, '/auth/login?next=%2Farena%2Fnew');
+ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('A round must be started from openvibe.design itself.');
+ const b = req.body || {};
+ try {
+ const round = await arena.createRound({ category: String(b.category || ''), prompt: b.prompt, subject: subjectOf(req) });
+ return res.redirect(303, `/arena/r/${encodeURIComponent(round.id)}`);
+ } catch (err) {
+ const notice = err instanceof ArenaError ? err.detail : 'The round could not be started. Try again in a moment.';
+ if (!(err instanceof ArenaError)) log.warn('[OpenVibe.Design] starting a round failed:', (err && err.message) || err);
+ return page(res, err instanceof ArenaError ? err.status : 500,
+ views.arenaNew({ me, limits: await aria(req.viewer), categories, notice }), req.viewer);
+ }
+ });
+
+ r.get('/arena/vote', async (req, res) => {
+ const me = meOf(req.viewer);
+ let pair = null;
+ let notice = null;
+ if (subjectOf(req)) {
+ try { pair = await arena.nextPair(subjectOf(req), { random: ctx.random || Math.random }); } catch (err) {
+ log.warn('[OpenVibe.Design] picking a pair failed:', (err && err.message) || err);
+ notice = 'The next pair could not be read. Try again in a moment.';
+ }
+ }
+ return page(res, 200, views.arenaVote({ pair, me, notice }), req.viewer);
+ });
+
+ r.post('/arena/vote', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
+ const me = meOf(req.viewer);
+ if (!subjectOf(req)) return res.redirect(303, '/auth/login?next=%2Farena%2Fvote');
+ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('A vote must come from openvibe.design itself.');
+ const b = req.body || {};
+ try {
+ const out = await arena.vote({
+ roundId: String(b.round_id || ''),
+ subject: subjectOf(req),
+ leftOutput: String(b.left_output || ''),
+ rightOutput: String(b.right_output || ''),
+ winner: String(b.winner || ''),
+ });
+ return page(res, 201, views.arenaVote({ me, result: {
+ vote: { round: out.round.id, winner: out.winner, at: out.at },
+ revealed: { left: out.left, right: out.right },
+ } }), req.viewer);
+ } catch (err) {
+ const notice = err instanceof ArenaError ? err.detail : 'The vote could not be recorded. Try again in a moment.';
+ if (!(err instanceof ArenaError)) log.warn('[OpenVibe.Design] vote failed:', (err && err.message) || err);
+ return page(res, err instanceof ArenaError ? err.status : 500, views.arenaVote({ me, notice }), req.viewer);
+ }
+ });
+
+ r.get('/arena/r/:id', async (req, res) => {
+ let round = null;
+ try { round = await arena.getRound(String(req.params.id || '')); } catch (err) {
+ log.warn('[OpenVibe.Design] round page failed:', (err && err.message) || err);
+ }
+ if (!round) return page(res, 404, views.notFound(), req.viewer);
+ return page(res, 200, views.arenaRound({ round, me: meOf(req.viewer) }), req.viewer);
+ });
+
r.get('/developers', (req, res) => page(res, 200, views.developers(), req.viewer));
r.get('/developers', (req, res) => page(res, 200, views.developers(), req.viewer));
r.use((req, res) => page(res, 404, views.notFound(), req.viewer));
r.use((req, res) => page(res, 404, views.notFound(), req.viewer));
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+
+/**
+ * Same-origin writes (CSRF). A write authenticated by the httpOnly session cookie must come from this
+ * site: `Sec-Fetch-Site: same-origin`, or an `Origin` equal to BASE_URL's origin. A missing header is
+ * refused on a cookie write. A write carrying a Bearer token is not cookie-authenticated and is left
+ * alone (a browser never attaches Authorization cross-site by itself); an anonymous request passes
+ * through to the route's own 401.
+ */
+const { http } = require('openvibe-contracts');
+const { ACCESS_COOKIE } = require('../auth/sso');
+
+function ownOrigin(config) {
+ try { return new URL(config.baseUrl).origin; } catch { return null; }
+}
+
+function bearer(req) {
+ const h = String(req.get('authorization') || '');
+ return /^Bearer\s+\S/i.test(h) ? h.replace(/^Bearer\s+/i, '').trim() : null;
+}
+
+function cookieAuthenticated(req) {
+ return Boolean(req.cookies && req.cookies[ACCESS_COOKIE]);
+}
+
+/** `Sec-Fetch-Site: same-origin`, else an Origin equal to this site's; both absent is not same-origin. */
+function sameOrigin(req, config) {
+ const site = String(req.get('sec-fetch-site') || '').trim().toLowerCase();
+ if (site === 'same-origin') return true;
+ const sent = String(req.get('origin') || '');
+ const own = ownOrigin(config);
+ return Boolean(sent) && Boolean(own) && sent === own;
+}
+
+/** A cookie write needs a same-origin signal; Bearer and anonymous writes are not cookie writes. */
+function writeAllowed(req, config) {
+ if (bearer(req)) return true;
+ if (!cookieAuthenticated(req)) return true;
+ return sameOrigin(req, config);
+}
+
+function requireSameOrigin(config, { detail = 'A change must come from this site itself.', code = 'design.cross_site' } = {}) {
+ return (req, res, next) => {
+ if (writeAllowed(req, config)) return next();
+ return http.sendProblem(res, 403, code, { detail, ctx: req.ov });
+ };
+}
+
+module.exports = { ownOrigin, bearer, cookieAuthenticated, sameOrigin, writeAllowed, requireSameOrigin };
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+
+/**
+ * Account export and deletion → Design's Arena (ADR-033; openvibe-sdk/account-data). The rows a person owns are
+ * the rounds they started and the votes they cast. A round's outputs are exported with it; deleting the round
+ * cascades its outputs and every vote on it (including other people's votes on that round, which is the right
+ * outcome: the round is gone). Ratings are aggregate and name no person, so they stay.
+ *
+ * A vote's voter_subject is never exported or shown in the open dataset.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const TABLES = [
+ { table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },
+ { table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },
+];
+
+async function extraExport(db, subject) {
+ const rows = await db.many(
+ `SELECT o.* FROM arena_outputs o
+ JOIN arena_rounds r ON r.id = o."round"
+ WHERE r.created_by = $1
+ ORDER BY o.created_at DESC LIMIT 5000`,
+ [subject],
+ );
+ return rows.length ? [{ name: 'arena_outputs.json', content: rows }] : [];
+}
+
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db,
+ service: 'design',
+ tables: TABLES,
+ extraExport,
+ note: 'Arena votes are exported without another voter\'s identity; outputs travel with the round that made them.',
+ log,
+ });
+}
+
+module.exports = { create, TABLES, TOPICS, extraExport };
@@ -2,14 +2,15 @@
@@ -2,14 +2,15 @@
/**
/**
* OpenVibe.Design — process entry. `node server/index.js` listens on PORT (5090) behind nginx (deploy/).
* OpenVibe.Design — process entry. `node server/index.js` listens on PORT (5090) behind nginx (deploy/).
- * Stateless: the HTTP server and the JWKS refresher are what stop.
+ * The Arena's job loop starts here, never under DESIGN_DRILL; the HTTP server, the loop, the JWKS refresher and
+ * the database are what stop.
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
/**
/**
- * The process stop (openvibe-sdk/service): stop taking connections, drain the requests in flight, stop the JWKS
- * refresher, exit. Exported so a test can inject `exit` and `signals: false`.
+ * The process stop (openvibe-sdk/service): stop taking connections, drain the requests in flight, stop the Arena
+ * loop, the JWKS refresher and the database, then exit. Exported so a test can inject `exit` and `signals: false`.
*/
*/
function createLifecycle({ server, ctx, exit, signals } = {}) {
function createLifecycle({ server, ctx, exit, signals } = {}) {
return gracefulStop({
return gracefulStop({
@@ -19,7 +20,11 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {
@@ -19,7 +20,11 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {
exit,
exit,
signals,
signals,
deadlineMs: 10_000,
deadlineMs: 10_000,
- close: [() => ctx.keys.client.stop()],
+ close: [
+ () => ctx.jobs.stop(),
+ () => ctx.keys.client.stop(),
+ () => ctx.s.close(),
+ ],
});
});
}
}
@@ -27,9 +32,11 @@ async function start() {
@@ -27,9 +32,11 @@ async function start() {
const { app, ctx } = await createApp();
const { app, ctx } = await createApp();
const { config } = ctx;
const { config } = ctx;
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
- console.log(`[OpenVibe.Design] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (network ${config.networkUrl})`);
+ console.log(`[OpenVibe.Design] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (network ${config.networkUrl}, ai ${config.ai.url})`);
// Load the Network's signing key now, not on the first sign-in, so /api/ready reports sign-in as it is.
// Load the Network's signing key now, not on the first sign-in, so /api/ready reports sign-in as it is.
ctx.keys.ensure().catch(() => {});
ctx.keys.ensure().catch(() => {});
+ // Generate queued Arena rounds. DESIGN_DRILL=1 keeps the loop off (a test or a drill drives tick()).
+ ctx.jobs.start();
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
createLifecycle({ server, ctx });
createLifecycle({ server, ctx });
@@ -1,22 +1,31 @@
@@ -1,22 +1,31 @@
'use strict';
'use strict';
/**
/**
- * Truthful readiness for GET /api/ready (openvibe-shared/ready). There is no database: the service is stateless.
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready). The database is required now that the Arena
+ * keeps rounds, outputs and votes; without it the Arena cannot answer and a restart should be visible.
*
*
+ * db required PostgreSQL/PGlite answers
* network optional OpenVibe.Network answers its theme catalogue
* network optional OpenVibe.Network answers its theme catalogue
* network_jwks optional the Network signing key is loaded; without it nobody can sign in
* network_jwks optional the Network signing key is loaded; without it nobody can sign in
- * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in needs it)
+ * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in and AI tokens need it)
*
*
- * No check is required: if Network does not answer, the process still serves the pages and the crawl artifacts, so
- * the answer is 200 with status "degraded" — never 503 (the same rule OpenVibe.Domains uses for its two upstreams).
+ * If only an optional check fails the answer is 200 with status "degraded"; a required check failing is 503.
*/
*/
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
-function createServiceReadiness({ config, catalog, keys = null, release = null }) {
+function createServiceReadiness({ s, config, catalog, keys = null, release = null }) {
return createReadiness({
return createReadiness({
service: 'design',
service: 'design',
release,
release,
checks: [
checks: [
+ {
+ name: 'db',
+ required: true,
+ check: async () => {
+ const r = await s.db.ready();
+ return r.ok ? { ok: true, detail: r.detail } : r.error;
+ },
+ },
{
{
name: 'network',
name: 'network',
required: false,
required: false,
@@ -24,6 +24,7 @@ const esc = kit.esc;
@@ -24,6 +24,7 @@ const esc = kit.esc;
const NAV = [
const NAV = [
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Studio', href: '/studio', icon: 'fa-palette' },
{ label: 'Studio', href: '/studio', icon: 'fa-palette' },
+ { label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
];
];
@@ -42,7 +43,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
@@ -42,7 +43,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
service: 'design',
service: 'design',
apiBase: NETWORK,
apiBase: NETWORK,
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && String(active).startsWith(`${l.href}/`)) })),
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && String(active).startsWith(`${l.href}/`)) })),
- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Studio', href: '/studio', icon: 'fa-palette' }],
+ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Studio', href: '/studio', icon: 'fa-palette' }, { label: 'Arena', href: '/arena', icon: 'fa-crown' }],
history: { type: 'page', title: title || NAME },
history: { type: 'page', title: title || NAME },
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -15,6 +15,7 @@ const layoutLib = require('../render/layout');
@@ -15,6 +15,7 @@ const layoutLib = require('../render/layout');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const sc = require('openvibe-shared/showcase');
const sc = require('openvibe-shared/showcase');
const rooms = require('openvibe-shared/rooms');
const rooms = require('openvibe-shared/rooms');
+const { iframeHtml } = require('../arena/sanitize');
const { layout, esc, NAME } = layoutLib;
const { layout, esc, NAME } = layoutLib;
const ROOM = rooms.get('design');
const ROOM = rooms.get('design');
@@ -153,6 +154,146 @@ function developers() {
@@ -153,6 +154,146 @@ function developers() {
return layout({ title: 'The Design API', path: '/developers', body: `<div class="ds-wrap">${body}</div>`, active: '/developers' });
return layout({ title: 'The Design API', path: '/developers', body: `<div class="ds-wrap">${body}</div>`, active: '/developers' });
}
}
+const ARENA_CATEGORY = {
+ landing: 'Landing page',
+ dashboard: 'Dashboard',
+ mobile_app: 'Mobile app',
+ game_ui: 'Game UI',
+ logo: 'Logo',
+ slides: 'Slides',
+};
+
+const arenaCost = (v) => `$${Number(v || 0).toFixed(4)}`;
+const arenaMs = (v) => (Number(v || 0) >= 1000 ? `${(Number(v) / 1000).toFixed(1)} s` : `${Math.round(Number(v) || 0)} ms`);
+const arenaPct = (v) => `${(Number(v || 0) * 100).toFixed(1)}%`;
+
+/** One output at one viewport width, inside the sandboxed, CSP-capped iframe. */
+function arenaShot(o, width) {
+ const phone = Number(width) === 390;
+ return `<div class="ar-shot ${phone ? 'ar-shot--phone' : 'ar-shot--desktop'}" data-width="${phone ? 390 : 1280}">
+<div class="ar-shot-frame">${iframeHtml({
+ html: o.html || '',
+ width: phone ? 390 : 1280,
+ height: phone ? 844 : 800,
+ title: `${o.model ? `${o.model} ` : ''}at ${phone ? 390 : 1280} pixels`,
+ id: `ar-frame-${String(o.id || 'x').replace(/[^a-z0-9_-]/gi, '')}-${phone ? 390 : 1280}`,
+ className: phone ? 'ar-frame--phone' : 'ar-frame--desktop',
+ })}</div>
+<p class="ar-shot-label">${phone ? 'Phone · 390 px' : 'Desktop · 1280 px'}</p></div>`;
+}
+
+// Which viewport the pair (or the round's outputs) shows. The radios belong to no form, so a vote never sends them.
+function arenaViewSwitch() {
+ return `<div class="ar-view" role="radiogroup" aria-label="Viewport">
+<input type="radio" id="ar-view-desktop" name="ar_view" value="desktop" form="ar-view-none" checked><label for="ar-view-desktop">Desktop</label>
+<input type="radio" id="ar-view-phone" name="ar_view" value="phone" form="ar-view-none"><label for="ar-view-phone">Phone</label></div>`;
+}
+
+function arenaLeaderboard(category, rows) {
+ const body = rows.length ? rows.map((r, i) => `<tr class="ar-lb-row" data-model="${esc(r.model)}">
+<td class="ar-lb-rank">${i + 1}</td>
+<td class="ar-lb-model">${esc(r.model)}</td>
+<td class="ar-lb-elo">${Number(r.elo).toFixed(0)}</td>
+<td class="ar-lb-games">${r.games}</td>
+<td class="ar-lb-winrate">${arenaPct(r.win_rate)}</td>
+<td class="ar-lb-cost">${r.median_cost_usd == null ? '—' : arenaCost(r.median_cost_usd)}</td>
+<td class="ar-lb-ms">${r.median_ms == null ? '—' : arenaMs(r.median_ms)}</td></tr>`).join('') : '<tr class="ar-lb-empty"><td colspan="7">No votes yet. Be the first to judge this category.</td></tr>';
+ return `<section class="ar-lb" id="ar-lb-${esc(category)}" aria-labelledby="ar-lb-h-${esc(category)}">
+<div class="ar-lb-head"><h2 id="ar-lb-h-${esc(category)}">${esc(ARENA_CATEGORY[category] || category)}</h2><a class="ar-more" href="/arena/vote">Vote on this category</a></div>
+<table class="ar-table"><thead><tr><th scope="col">#</th><th scope="col">Model</th><th scope="col">Elo</th><th scope="col">Games</th><th scope="col">Win rate</th><th scope="col">Median cost</th><th scope="col">Median time</th></tr></thead>
+<tbody>${body}</tbody></table></section>`;
+}
+
+function arenaHome({ categories = [], me = {}, limits = null } = {}) {
+ const signedIn = Boolean(me.signedIn);
+ const boards = categories.map((c) => arenaLeaderboard(c.category, c.leaderboard || [])).join('');
+ const rows = categories.flatMap((c) => c.leaderboard || []);
+ const body = `<div class="ar-page" id="ar-home">
+<header class="ar-hero"><p class="ar-kicker">Design Arena</p><h1>Which model designs it best?</h1>
+<p class="ar-lede">One prompt goes to three models. They each build the same page; you see two at phone and desktop sizes, vote blind, and an Elo leaderboard per category ranks the designers, with the cost and time of every generation.</p>
+<div class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/new">Start a round</a> <a class="ovk-btn ovk-btn--quiet" href="/arena/vote">Vote blind</a></div>
+<p class="ar-fine" id="ar-limits">${signedIn
+ ? (limits ? `You have ${limits.remaining} of ${limits.per_day} rounds left today. The Arena has ${limits.site_remaining} of ${limits.site_daily} left.` : 'Signed in: you can start a round or vote.')
+ : 'Sign in to start a round or vote. The leaderboard and the dataset are open to everyone.'}</p></header>
+<section class="ar-boards" id="ar-leaderboard" aria-labelledby="ar-boards-h"><div class="ar-lb-head"><h2 id="ar-boards-h">The leaderboard</h2><span class="ar-fine">${rows.length} model${rows.length === 1 ? '' : 's'} ranked · K = 32</span></div>
+${boards || '<p class="ar-empty">No rounds have been judged yet. Start the first one.</p>'}
+<p class="ar-fine">A win rate counts a tie as half a win. <code>both_bad</code> is a game for neither model. The cost and time are the median over the outputs in this category.</p></section>
+<section class="ar-open" id="ar-open"><h2>Everything is open</h2><p class="ar-lede">The prompts, every generated page and every vote are downloadable. No voter is named.</p>
+<p><a class="ovk-btn ovk-btn--quiet" href="/api/v1/arena/dataset.jsonl">Download dataset.jsonl</a></p></section>
+</div>`;
+ return layout({ title: 'The Design Arena', description: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.', path: '/arena', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });
+}
+
+function arenaNew({ me = {}, limits = null, categories = [], notice = null } = {}) {
+ const signedIn = Boolean(me.signedIn);
+ const options = (categories.length ? categories : Object.keys(ARENA_CATEGORY)).map((c) => `<option value="${esc(c)}">${esc(ARENA_CATEGORY[c] || c)}</option>`).join('');
+ const body = `<div class="ar-page" id="ar-new">
+<header class="ar-page-h"><p class="ar-kicker">Start a round</p><h1>Describe the page</h1>
+<p class="ar-lede">Three models will build it from this prompt. You cannot vote on a round you started, so the vote stays blind.</p></header>
+${notice ? `<p class="ar-notice" id="ar-new-notice" role="alert">${esc(notice)}</p>` : ''}
+${signedIn ? '' : `<p class="ar-notice" id="ar-signin">${kit.button({ href: '/auth/login?next=%2Farena%2Fnew', label: 'Sign in to start a round' })}</p>`}
+<form class="ar-form" id="ar-new-form" method="post" action="/arena/new">
+<label>Category <select id="ar-category" name="category" ${signedIn ? '' : 'disabled'}>${options}</select></label>
+<label>Prompt <textarea id="ar-prompt" name="prompt" rows="8" maxlength="4000" required ${signedIn ? '' : 'disabled'} placeholder="A landing page for a tiny coffee roastery in Seattle: warm, editorial, one product shot and a subscribe form."></textarea></label>
+<div class="ar-actions"><button type="submit" class="ovk-btn ovk-btn--primary" ${signedIn ? '' : 'disabled'}>Start the round</button><a class="ovk-btn ovk-btn--quiet" href="/arena">Back to the Arena</a></div>
+<p class="ar-status" id="ar-new-status" role="status"></p>
+<p class="ar-fine" id="ar-new-limits">${limits ? `${limits.remaining} of ${limits.per_day} rounds left for you today; ${limits.site_remaining} of ${limits.site_daily} for the whole site.` : 'A signed-in person may start a few rounds a day; the whole site has a daily cap too.'}</p>
+</form></div>`;
+ return layout({ title: 'Start an Arena round', description: 'Describe a page once; three AI designers build it and the Arena ranks them.', path: '/arena/new', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });
+}
+
+function arenaVote({ pair = null, me = {}, result = null, notice = null } = {}) {
+ const signedIn = Boolean(me.signedIn);
+ let body;
+ if (!signedIn) {
+ body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind</p><h1>Sign in to judge</h1><p class="ar-lede">The two designs are shown without their models. Sign in to record one vote per round; you cannot vote on a round you started.</p></header><p>${kit.button({ href: '/auth/login?next=%2Farena%2Fvote', label: 'Sign in to vote' })}</p></div>`;
+ } else if (result) {
+ const left = result.revealed.left;
+ const right = result.revealed.right;
+ body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote recorded</p><h1>The models are revealed</h1><p class="ar-lede">You chose <b>${esc(result.vote.winner === 'both_bad' ? 'both bad' : result.vote.winner)}</b>. The Elo leaderboard has been updated.</p></header>
+<div class="ar-reveal" id="ar-reveal"><div class="ar-reveal-side" id="ar-reveal-left"><h2>Left</h2><p><b>${esc(left.model)}</b>${left.provider ? ` · ${esc(left.provider)}` : ''}</p><p class="ar-fine">${arenaCost(left.cost_usd)} · ${arenaMs(left.ms)} · ${left.tokens} tokens</p></div>
+<div class="ar-reveal-side" id="ar-reveal-right"><h2>Right</h2><p><b>${esc(right.model)}</b>${right.provider ? ` · ${esc(right.provider)}` : ''}</p><p class="ar-fine">${arenaCost(right.cost_usd)} · ${arenaMs(right.ms)} · ${right.tokens} tokens</p></div></div>
+<p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/vote">Vote on another</a> <a class="ovk-btn ovk-btn--quiet" href="/arena/r/${encodeURIComponent(result.vote.round)}">See the round</a></p></div>`;
+ } else if (!pair) {
+ body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind</p><h1>Nothing waiting</h1><p class="ar-lede">${esc(notice || 'There is no ready round you have not voted on. Start one, or come back when another is ready.')}</p></header><p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/new">Start a round</a> <a class="ovk-btn ovk-btn--quiet" href="/arena">The leaderboard</a></p></div>`;
+ } else {
+ body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind · ${esc(ARENA_CATEGORY[pair.round.category] || pair.round.category)}</p><h1>Which design is better?</h1><p class="ar-lede">${esc(pair.round.prompt)}</p><p class="ar-fine">The models are hidden until you vote. Compare at phone and desktop sizes.</p></header>
+<form id="ar-vote-form" class="ar-vote" method="post" action="/arena/vote">
+<input type="hidden" id="ar-vote-round" name="round_id" value="${esc(pair.round.id)}">
+<input type="hidden" id="ar-vote-left-id" name="left_output" value="${esc(pair.left.id)}">
+<input type="hidden" id="ar-vote-right-id" name="right_output" value="${esc(pair.right.id)}">
+${arenaViewSwitch()}
+<div class="ar-vote-pair" id="ar-vote-pair">
+<section class="ar-vote-side" id="ar-vote-left" data-side="left"><h2>Left</h2>${arenaShot(pair.left, 390)}${arenaShot(pair.left, 1280)}</section>
+<section class="ar-vote-side" id="ar-vote-right" data-side="right"><h2>Right</h2>${arenaShot(pair.right, 390)}${arenaShot(pair.right, 1280)}</section></div>
+<div class="ar-vote-actions" id="ar-vote-actions"><button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="left" data-winner="left">Left is better</button>
+<button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="right" data-winner="right">Right is better</button>
+<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="tie" data-winner="tie">It is a tie</button>
+<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="both_bad" data-winner="both_bad">Both are bad</button></div>
+<p class="ar-status" id="ar-vote-status" role="status"></p></form></div>`;
+ }
+ return layout({ title: 'Vote blind · The Design Arena', description: 'Two generated designs, no model names: vote for the better one.', path: '/arena/vote', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });
+}
+
+function arenaRound({ round, me = {} } = {}) {
+ const outputs = round.outputs || [];
+ const ok = outputs.filter((o) => !o.error);
+ const totalCost = ok.reduce((a, o) => a + Number(o.cost_usd || 0), 0);
+ const totalMs = ok.reduce((a, o) => a + Number(o.ms || 0), 0);
+ const cards = outputs.map((o, i) => `<li class="ar-output" id="ar-output-${esc(o.id)}" data-model="${esc(o.model)}">
+<div class="ar-output-h"><h2>${esc(o.model)}</h2><p class="ar-fine">${esc(o.provider || 'unknown provider')} · ${arenaCost(o.cost_usd)} · ${arenaMs(o.ms)} · ${o.tokens} tokens</p></div>
+${o.error ? `<p class="ar-output-error" role="status">This model did not finish: ${esc(o.error)}</p>` : `<div class="ar-output-shots">${arenaShot(o, 390)}${arenaShot(o, 1280)}</div>`}</li>`).join('');
+ const body = `<div class="ar-page" id="ar-round" data-round="${esc(round.id)}">
+<header class="ar-page-h"><p class="ar-kicker">${esc(ARENA_CATEGORY[round.category] || round.category)} · Arena round</p><h1>${esc(String(round.prompt).slice(0, 240))}</h1>
+<p class="ar-meta" id="ar-round-meta">Status: <b>${esc(round.status)}</b> · ${outputs.length} model${outputs.length === 1 ? '' : 's'} · ${arenaCost(totalCost)} · ${arenaMs(totalMs)} in generation · ${round.votes || 0} vote${Number(round.votes) === 1 ? '' : 's'}</p>
+<p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/vote">Vote blind</a> <a class="ovk-btn ovk-btn--quiet" href="/arena">The leaderboard</a> <a class="ovk-btn ovk-btn--quiet" href="/api/v1/arena/rounds/${encodeURIComponent(round.id)}">This round as JSON</a></p></header>
+${round.status === 'queued' || round.status === 'generating' ? '<p class="ar-notice" id="ar-round-wait">The models are working. This page refreshes itself.</p>' : ''}
+${round.pool_fallback ? '<p class="ar-notice" id="ar-round-pool">Fewer than three distinct offers were available for this round, so the rest came from the routing pool.</p>' : ''}
+${outputs.some((o) => !o.error) ? arenaViewSwitch() : ''}
+<ul class="ar-outputs" id="ar-outputs">${cards || '<li class="ar-empty">No output yet.</li>'}</ul></div>`;
+ return layout({ title: `${ARENA_CATEGORY[round.category] || round.category}: an Arena round`, description: `Three AI designs for: ${round.prompt.slice(0, 140)}`, path: `/arena/r/${encodeURIComponent(round.id)}`, body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'], robots: 'noindex, follow' });
+}
+
function notFound() {
function notFound() {
const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/studio">Open the studio</a> or <a href="/">browse the themes</a>.</p></header>`;
const body = `<header class="ds-page-h"><h1>Nothing here</h1><p class="ds-lede">That page does not exist. <a href="/studio">Open the studio</a> or <a href="/">browse the themes</a>.</p></header>`;
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<div class="ds-wrap">${body}</div>`, active: '/' });
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<div class="ds-wrap">${body}</div>`, active: '/' });
@@ -160,6 +301,7 @@ function notFound() {
@@ -160,6 +301,7 @@ function notFound() {
module.exports = {
module.exports = {
home, studio, theme, developers, notFound,
home, studio, theme, developers, notFound,
+ arenaHome, arenaNew, arenaVote, arenaRound, ARENA_CATEGORY, arenaShot,
layout,
layout,
setRelease: layoutLib.setRelease,
setRelease: layoutLib.setRelease,
configure: layoutLib.configure,
configure: layoutLib.configure,
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+/**
+ * ADR-033: the Arena's part of an account export and deletion, through /internal/events with the SDK's signed
+ * delivery and a stand-in Network. A's rounds and outputs and A's votes are exported; B's identity is not; deleting
+ * A removes their rounds (cascading every vote on them) and their votes, and a redelivery changes nothing.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+
+const A = 'usr_01JZ0000000000000000000AAA';
+const B = 'usr_01JZ0000000000000000000BBB';
+const EXP = 'exp_01JZ0000000000000000000EXP';
+const DEL = 'del_01JZ0000000000000000000DEX';
+const SECRET = `fixture_${'arena'.repeat(8)}`;
+const WRONG = `fixture_${'wrong'.repeat(8)}`;
+const ORIGIN = 'https://openvibe.design';
+
+const exportEvent = { event_id: 'evt_01JZ0000000000000000000E01', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
+const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D01', event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };
+const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
+
+(async () => {
+ const calls = [];
+ const sender = async (path, body) => { calls.push({ path, body }); return { ok: true, status: 201, json: async () => ({}) }; };
+ const t = await boot({ env: { DESIGN_EVENTS_SECRET: SECRET }, accountSend: sender });
+ const ana = t.network.addUser('ana');
+ const bo = t.network.addUser('bo');
+ try {
+ // A starts a round (then it is given A's fixed account subject), and B votes on it.
+ const aRound = (await t.get('/api/v1/arena/rounds', { as: ana, json: { category: 'landing', prompt: 'A round to export' }, origin: ORIGIN })).json().round;
+ await t.tick();
+ await t.ctx.s.db.exec('UPDATE arena_rounds SET created_by = $1 WHERE id = $2', [A, aRound.id]);
+ const aOutputs = (await t.ctx.arena.getRound(aRound.id)).outputs;
+ const pair = await t.ctx.arena.nextPair(bo.subject);
+ const vote = await t.get('/api/v1/arena/vote', { as: bo, json: { round_id: pair.round.id, left_output: pair.left.id, right_output: pair.right.id, winner: 'left' }, origin: ORIGIN });
+ assert.strictEqual(vote.status, 201, vote.text);
+
+ // B starts a round and A's vote is stored on it directly (A is a fixture account, not a Network token here).
+ const bRound = (await t.get('/api/v1/arena/rounds', { as: bo, json: { category: 'dashboard', prompt: 'B round stays' }, origin: ORIGIN })).json().round;
+ await t.tick();
+ const bOutputs = (await t.ctx.arena.getRound(bRound.id)).outputs;
+ await t.ctx.s.db.exec(`INSERT INTO arena_votes ("round", voter_subject, left_output, right_output, winner) VALUES ($1, $2, $3, $4, 'tie')`,
+ [bRound.id, A, bOutputs[0].id, bOutputs[1].id]);
+
+ await check('an export carries A\'s rounds, their outputs and A\'s votes, never B\'s identity', async () => {
+ const raw = bodyOf(exportEvent);
+ const res = await t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'exported');
+ const part = calls.find((c) => c.path === `/internal/account-exports/${EXP}/parts`);
+ assert.ok(part, 'the part was pushed to Network');
+ assert.strictEqual(part.body.subject, A);
+ const names = part.body.files.map((f) => f.name).sort();
+ assert.deepStrictEqual(names, ['arena_outputs.json', 'arena_rounds.json', 'arena_votes.json']);
+ const all = JSON.stringify(part.body);
+ assert.ok(all.includes('A round to export'));
+ const outputsFile = part.body.files.find((f) => f.name === 'arena_outputs.json');
+ assert.ok(outputsFile.content.some((o) => o.html === aOutputs[0].html), 'the output HTML travels with the round');
+ assert.ok(all.includes(bRound.id), 'A\'s vote on B\'s round is exported');
+ assert.ok(!all.includes(B), 'another voter is never named');
+ assert.ok(!all.includes(bo.subject), 'another account is never named');
+ });
+
+ await check('a deletion erases A\'s rounds (with their outputs and votes) and A\'s votes; B\'s round stays', async () => {
+ const raw = bodyOf(deleteEvent);
+ const res = await t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'erased');
+ const db = t.ctx.s.db;
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_rounds WHERE id = $1', [aRound.id])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_outputs WHERE "round" = $1', [aRound.id])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_votes WHERE voter_subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_votes WHERE "round" = $1', [aRound.id])), 0, 'B\'s vote on A\'s round went with the round');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_rounds WHERE id = $1', [bRound.id])), 1, 'B\'s round stays');
+ const confirmation = calls.find((c) => c.path === `/internal/account-deletions/${DEL}/confirmations`);
+ assert.ok(confirmation, 'the confirmation was sent');
+ assert.ok(confirmation.body.erased.arena_rounds >= 1);
+ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
+ });
+
+ await check('a redelivery changes nothing; the internal route refuses a bad signature and a forwarded request', async () => {
+ const before = Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM arena_rounds'));
+ const raw = bodyOf(deleteEvent);
+ const again = await t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json().outcome, 'unchanged');
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM arena_rounds')), before);
+ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'content-type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });
+ assert.strictEqual(bad.status, 401);
+ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(forwarded.status, 403);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+/**
+ * migration 0001_arena.sql: the five tables the Arena and ADR-033 need, their columns, checks, defaults and
+ * the one-vote-per-person primary key.
+ */
+const assert = require('assert');
+const { testDb } = require('./helpers/db');
+const { check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await testDb();
+ const db = t.db;
+ try {
+ await check('the Arena tables and their columns exist', async () => {
+ const cols = async (table) => (await db.many('SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 ORDER BY ordinal_position', [table])).map((r) => r.column_name);
+ assert.deepStrictEqual(await cols('arena_rounds'), ['id', 'category', 'prompt', 'created_by', 'status', 'offers', 'pool_fallback', 'created_at']);
+ assert.deepStrictEqual(await cols('arena_outputs'), ['id', 'round', 'model', 'provider', 'html', 'cost_usd', 'ms', 'tokens', 'error', 'ui_check', 'created_at']);
+ assert.deepStrictEqual(await cols('arena_votes'), ['round', 'voter_subject', 'left_output', 'right_output', 'winner', 'at']);
+ assert.deepStrictEqual(await cols('arena_ratings'), ['category', 'model', 'elo', 'games', 'wins', 'losses', 'ties', 'updated_at']);
+ assert.deepStrictEqual(await cols('account_data_events'), ['id', 'kind', 'subject', 'outcome', 'applied_at', 'sent_at']);
+ });
+
+ await check('rounds default to queued and ratings to Elo 1000', async () => {
+ await db.exec("INSERT INTO arena_rounds (id, category, prompt, created_by) VALUES ('arr_A', 'landing', 'p', 'usr_A')");
+ const r = await db.one("SELECT status, created_at FROM arena_rounds WHERE id = 'arr_A'");
+ assert.strictEqual(r.status, 'queued');
+ assert.ok(!Number.isNaN(Date.parse(r.created_at)));
+ await db.exec("INSERT INTO arena_ratings (category, model) VALUES ('landing', 'model-a')");
+ const rt = await db.one("SELECT elo, games, wins, losses, ties FROM arena_ratings WHERE category = 'landing' AND model = 'model-a'");
+ assert.strictEqual(Number(rt.elo), 1000);
+ assert.deepStrictEqual([rt.games, rt.wins, rt.losses, rt.ties], [0, 0, 0, 0]);
+ });
+
+ await check('categories and statuses are checked', async () => {
+ await assert.rejects(() => db.exec("INSERT INTO arena_rounds (id, category, prompt, created_by) VALUES ('arr_B', 'poster', 'p', 'usr_A')"), /check|constraint/i);
+ await assert.rejects(() => db.exec("INSERT INTO arena_rounds (id, category, prompt, created_by, status) VALUES ('arr_C', 'landing', 'p', 'usr_A', 'cooking')"), /check|constraint/i);
+ });
+
+ await check('one vote per person per round is a primary-key refusal', async () => {
+ await db.exec("INSERT INTO arena_outputs (id, \"round\", model, provider, html) VALUES ('aro_L', 'arr_A', 'model-a', 'p', '<h1>L</h1>'), ('aro_R', 'arr_A', 'model-b', 'p', '<h1>R</h1>')");
+ await db.exec("INSERT INTO arena_votes (\"round\", voter_subject, left_output, right_output, winner) VALUES ('arr_A', 'usr_V', 'aro_L', 'aro_R', 'left')");
+ await assert.rejects(() => db.exec("INSERT INTO arena_votes (\"round\", voter_subject, left_output, right_output, winner) VALUES ('arr_A', 'usr_V', 'aro_R', 'aro_L', 'right')"), (err) => err.code === '23505');
+ });
+
+ await check('deleting a round cascades its outputs and votes', async () => {
+ await db.exec("DELETE FROM arena_rounds WHERE id = 'arr_A'");
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_outputs')), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM arena_votes')), 0);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+/**
+ * The output safety rules: the sanitizer strips scripts, event handlers and javascript: URLs and caps at 200 KB;
+ * the only renderer is a strict sandboxed iframe whose srcdoc carries a CSP that blocks every network fetch.
+ */
+const assert = require('assert');
+const { sanitizeHtml, frameDocument, iframeHtml, MAX_BYTES } = require('../server/arena/sanitize');
+const { check, done } = require('./helpers/boot');
+
+(async () => {
+ await check('scripts, event handlers and javascript: URLs never reach storage', async () => {
+ const cases = [
+ '<h1>ok</h1><script>alert(1)</script>',
+ '<SCRIPT SRC="https://evil.example/x.js"></SCRIPT><p>after</p>',
+ '<script>one</script><script>two</script>',
+ '<p>before<script src=x>unclosed',
+ '<img src="x" onerror="alert(1)">',
+ '<svg onload=alert(1)><circle/></svg>',
+ '<div onclick = "x" OnMouseOver="y">safe</div>',
+ '<img alt="x>y" onerror="alert(1)">',
+ '<a href="javascript:alert(1)">x</a>',
+ '<a href="JaVaScRiPt:alert(1)">x</a>',
+ '<a href="java\nscript:alert(1)">x</a>',
+ '<a href="javascript:alert(1)">x</a>',
+ '<a href="java	script:alert(1)">x</a>',
+ '<a href="java	script:alert(1)">x</a>',
+ ];
+ for (const input of cases) {
+ const out = sanitizeHtml(input);
+ assert.ok(!/<script/i.test(out), `script remains in ${JSON.stringify(out)}`);
+ assert.ok(!/\son[a-z]+\s*=/i.test(out), `handler remains in ${JSON.stringify(out)}`);
+ assert.ok(!/javascript\s*:/i.test(out), `javascript: remains in ${JSON.stringify(out)}`);
+ }
+ assert.match(sanitizeHtml('<h1>keep me</h1><script>bad()</script>'), /keep me/);
+ });
+
+ await check('a page larger than 200 KB is cut to the cap', async () => {
+ const huge = `<style>${'body{color:red}'.repeat(20_000)}</style><p>${'a'.repeat(300 * 1024)}</p>`;
+ const out = sanitizeHtml(huge);
+ assert.ok(Buffer.byteLength(out, 'utf8') <= MAX_BYTES, `${Buffer.byteLength(out, 'utf8')} bytes`);
+ assert.strictEqual(MAX_BYTES, 200 * 1024);
+ });
+
+ await check('the frame document carries the blocking CSP before any model content', async () => {
+ const doc = frameDocument('<h1>x</h1><script>bad()</script>');
+ assert.ok(doc.startsWith('<meta charset="utf-8">'));
+ assert.match(doc, /<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">/);
+ assert.ok(!/<script/i.test(doc));
+ });
+
+ await check('iframe markup is sandboxed with no scripts and no same-origin, and cannot break out', async () => {
+ const html = iframeHtml({ html: '<h1>hello</h1>', width: 390, height: 844, title: 'phone' });
+ assert.match(html, /<iframe /);
+ assert.match(html, /sandbox=""/);
+ assert.doesNotMatch(html, /allow-scripts/);
+ assert.doesNotMatch(html, /allow-same-origin/);
+ assert.match(html, /width="390"/);
+ assert.match(html, /srcdoc="/);
+ const evil = iframeHtml({ html: '</iframe><script>window.top.location="https://evil.example"</script>', width: 1280 });
+ assert.strictEqual((evil.match(/<iframe /g) || []).length, 1);
+ assert.ok(!evil.includes('</iframe><script>'));
+ assert.match(evil, /width="1280"/);
+ assert.match(evil, /default-src 'none'/);
+ });
+
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,342 @@
@@ -0,0 +1,342 @@
+'use strict';
+/**
+ * The Arena end to end: a round generates three outputs through the job, the sanitizer is applied before storage,
+ * the budgets refuse in words, the pair is blind, one vote per person, the creator cannot vote, Elo handles a win,
+ * a tie and both_bad, the leaderboard reports games/win rate/medians, and the dataset names no voter.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const ORIGIN = 'https://openvibe.design';
+
+(async () => {
+ const t = await boot({ random: () => 0, env: { DESIGN_ARENA_PER_DAY: '8', DESIGN_ARENA_DAILY: '40' } });
+ const ana = t.network.addUser('ana');
+ const bo = t.network.addUser('bo');
+ const cy = t.network.addUser('cy');
+
+ const create = (user, category = 'landing', prompt = 'A landing page for a tiny coffee roastery in Seattle', extra = {}) => t.get('/api/v1/arena/rounds', {
+ as: user, json: { category, prompt }, origin: ORIGIN, ...extra,
+ });
+ const getRound = async (id) => (await t.get(`/api/v1/arena/rounds/${id}`)).json().round;
+ // Extra rounds would compete with A for the next blind pair; the checks that make them clean up after themselves.
+ const dropRounds = async (...ids) => { for (const id of ids.filter(Boolean)) await t.ctx.s.db.exec('DELETE FROM arena_rounds WHERE id = $1', [id]); };
+
+ let A;
+ await check('a round is queued by the request, and no AI call happens in the request', async () => {
+ const before = t.ai.requests.length;
+ const r = await create(ana);
+ assert.strictEqual(r.status, 201, r.text);
+ A = r.json().round;
+ assert.strictEqual(A.status, 'queued');
+ assert.deepStrictEqual(A.outputs, []);
+ assert.strictEqual(t.ai.requests.length, before, 'the job, not the request, calls AI');
+ });
+
+ await check('the job generates three outputs through AI with its reported model, provider, cost, time and tokens', async () => {
+ const ready = await t.tick();
+ assert.ok(ready, 'a queued round was claimed');
+ assert.strictEqual(ready.id, A.id);
+ assert.strictEqual(ready.status, 'ready');
+ assert.strictEqual(ready.outputs.length, 3);
+ const models = ready.outputs.map((o) => o.model);
+ const providers = ready.outputs.map((o) => o.provider);
+ assert.deepStrictEqual(models, ['alpha-1', 'beta-2', 'gamma-3']);
+ assert.deepStrictEqual(providers, ['acme', 'beta', 'gamma']);
+ for (const o of ready.outputs) {
+ assert.ok(o.id.startsWith('aro_'));
+ assert.strictEqual(o.error, null);
+ assert.match(o.html, /^<!doctype html>/i);
+ assert.strictEqual(o.cost_usd, 0.0012);
+ assert.strictEqual(o.ms, 321);
+ assert.strictEqual(o.tokens, 600);
+ }
+ assert.strictEqual(ready.pool_fallback, false, 'three offers were listed, so no pool fallback');
+ assert.deepStrictEqual(ready.offers, [
+ { provider: 'acme', model: 'alpha-1' },
+ { provider: 'beta', model: 'beta-2' },
+ { provider: 'gamma', model: 'gamma-3' },
+ ]);
+ const posts = t.ai.requests.filter((r) => r.method === 'POST' && r.path === '/api/v1/generate');
+ assert.strictEqual(posts.length, 3);
+ for (const [i, p] of posts.entries()) {
+ assert.ok(p.body.options.cache === false);
+ assert.strictEqual(p.body.options.model, undefined, 'the pin is a route, not an options.model hint');
+ assert.deepStrictEqual(p.body.route, ready.offers[i], 'each slot asks for one distinct offer');
+ assert.match(p.body.prompt, /coffee roastery/);
+ assert.strictEqual(p.body.target.service, 'design');
+ }
+ });
+
+ await check('Design asked Network for a client-credentials token for openvibe.ai with ai.run.create', async () => {
+ const token = t.network.state.tokenRequests.find((f) => f.grant_type === 'client_credentials');
+ assert.ok(token, 'a client-credentials grant was made');
+ assert.strictEqual(token.client_id, 'design');
+ assert.strictEqual(token.audience, 'openvibe.ai');
+ assert.strictEqual(token.scope, 'ai.run.create');
+ assert.ok(t.ai.state.tokenClaims);
+ assert.ok(t.ai.state.tokenClaims.aud.includes('openvibe.ai'));
+ assert.ok(t.ai.state.tokenClaims.cap.includes('ai.run.create'));
+ assert.ok(t.ai.state.tokenClaims.ns.includes('ai.*'));
+ });
+
+ await check('the sanitizer runs before storage, including the 200 KB cap', async () => {
+ t.ai.state.htmlFor = () => '<h1>safe</h1><script>alert(1)</script><img src="x" onerror="alert(2)"><a href="javascript:alert(3)">x</a>';
+ const r = await create(ana, 'logo', 'A mark with a script');
+ const scripted = r.json().round.id;
+ await t.tick();
+ const round = await getRound(scripted);
+ for (const o of round.outputs) {
+ assert.match(o.html, /safe/);
+ assert.ok(!/<script/i.test(o.html));
+ assert.ok(!/\sonerror\s*=/i.test(o.html));
+ assert.ok(!/javascript\s*:/i.test(o.html));
+ }
+ t.ai.state.htmlFor = () => `<p>${'x'.repeat(300 * 1024)}</p>`;
+ const big = await create(ana, 'logo', 'A big page');
+ const bigId = big.json().round.id;
+ await t.tick();
+ const bigRound = await getRound(bigId);
+ for (const o of bigRound.outputs) assert.ok(Buffer.byteLength(o.html) <= 200 * 1024, `${Buffer.byteLength(o.html)} bytes`);
+ t.ai.state.htmlFor = null;
+ await dropRounds(scripted, bigId);
+ });
+
+ await check('bad input and a cross-site Origin are refused in words', async () => {
+ const signedOut = await t.get('/api/v1/arena/rounds', { json: { category: 'landing', prompt: 'x' } });
+ assert.strictEqual(signedOut.status, 401);
+ const badCat = await create(ana, 'poster');
+ assert.strictEqual(badCat.status, 422);
+ assert.match(badCat.json().detail, /category/i);
+ const empty = await create(ana, 'landing', ' ');
+ assert.strictEqual(empty.status, 422);
+ assert.match(empty.json().detail, /describe/i);
+ const cross = await create(ana, 'landing', 'x', { origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ assert.strictEqual(cross.json().code, 'design.cross_site');
+ });
+
+ await check('a cookie write needs same-origin: missing headers are refused, Sec-Fetch-Site or Origin passes', async () => {
+ const missing = await create(ana, 'landing', 'no header', { origin: null });
+ assert.strictEqual(missing.status, 403);
+ assert.strictEqual(missing.json().code, 'design.cross_site');
+ assert.match(missing.json().detail, /openvibe\.design itself/);
+ const byFetchSite = await create(ana, 'landing', 'fetch metadata', { origin: null, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(byFetchSite.status, 201, byFetchSite.text);
+ const crossSite = await create(ana, 'landing', 'cross site', { origin: null, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(crossSite.status, 403);
+ // A Bearer write is not cookie-authenticated: no Origin is needed.
+ const bearer = await t.get('/api/v1/arena/rounds', {
+ method: 'POST', json: { category: 'landing', prompt: 'a bearer write' },
+ headers: { authorization: `Bearer ${t.network.userToken(ana)}` },
+ });
+ assert.strictEqual(bearer.status, 201, bearer.text);
+ await dropRounds(byFetchSite.json().round.id, bearer.json().round.id);
+ });
+
+ await check('the no-JS forms enforce the same policy', async () => {
+ const form = { 'content-type': 'application/x-www-form-urlencoded' };
+ const body = 'category=landing&prompt=a+form+round';
+ const missing = await t.get('/arena/new', { as: ana, method: 'POST', body, headers: form });
+ assert.strictEqual(missing.status, 403);
+ const same = await t.get('/arena/new', { as: ana, method: 'POST', body, headers: { ...form, 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(same.status, 303, same.text);
+ assert.match(same.headers.get('location'), /^\/arena\/r\/arr_/);
+ const cross = await t.get('/arena/new', { as: ana, method: 'POST', body, headers: { ...form, origin: 'https://evil.example' } });
+ assert.strictEqual(cross.status, 403);
+ await dropRounds(String(same.headers.get('location')).split('/').pop());
+ });
+
+ let winner;
+ let loser;
+ await check('the pair is blind: no model, provider, cost or time before the vote', async () => {
+ const pair = await t.get('/api/v1/arena/vote', { as: bo });
+ assert.strictEqual(pair.status, 200, pair.text);
+ const p = pair.json();
+ assert.strictEqual(p.round.id, A.id);
+ assert.deepStrictEqual(Object.keys(p.left).sort(), ['html', 'id']);
+ assert.deepStrictEqual(Object.keys(p.right).sort(), ['html', 'id']);
+ assert.ok(!JSON.stringify(p).includes('alpha-1'));
+ assert.ok(!JSON.stringify(p).includes('beta-2'));
+ const creator = await t.get('/api/v1/arena/vote', { as: ana });
+ assert.strictEqual(creator.json().pair, null, 'the creator cannot be offered their own round');
+ t.state = p;
+ });
+
+ await check('the creator cannot vote on their own round', async () => {
+ const p = t.state;
+ const r = await t.get('/api/v1/arena/vote', { as: ana, json: { round_id: A.id, left_output: p.left.id, right_output: p.right.id, winner: 'left' }, origin: ORIGIN });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'arena.creator_cannot_vote');
+ });
+
+ await check('bo votes once; the models are revealed and the vote is recorded', async () => {
+ const p = t.state;
+ const r = await t.get('/api/v1/arena/vote', { as: bo, json: { round_id: A.id, left_output: p.left.id, right_output: p.right.id, winner: 'left' }, origin: ORIGIN });
+ assert.strictEqual(r.status, 201, r.text);
+ const out = r.json();
+ assert.strictEqual(out.vote.winner, 'left');
+ assert.ok(out.revealed.left.model);
+ assert.ok(out.revealed.right.model);
+ const leftModel = out.revealed.left.model;
+ const rightModel = out.revealed.right.model;
+ assert.notStrictEqual(leftModel, rightModel);
+ winner = leftModel;
+ loser = rightModel;
+ const again = await t.get('/api/v1/arena/vote', { as: bo, json: { round_id: A.id, left_output: p.left.id, right_output: p.right.id, winner: 'right' }, origin: ORIGIN });
+ assert.strictEqual(again.status, 409);
+ assert.strictEqual(again.json().code, 'arena.already_voted');
+ });
+
+ await check('the leaderboard updates Elo K=32, games and win rate, with median cost and time', async () => {
+ const lb = (await t.get('/api/v1/arena/leaderboard?category=landing')).json().leaderboard;
+ const w = lb.find((r) => r.model === winner);
+ const l = lb.find((r) => r.model === loser);
+ assert.ok(w && l);
+ assert.strictEqual(w.elo, 1016);
+ assert.strictEqual(l.elo, 984);
+ assert.strictEqual(w.games, 1);
+ assert.strictEqual(w.wins, 1);
+ assert.strictEqual(w.losses, 0);
+ assert.strictEqual(w.win_rate, 1);
+ assert.strictEqual(l.games, 1);
+ assert.strictEqual(l.wins, 0);
+ assert.strictEqual(l.losses, 1);
+ assert.strictEqual(l.win_rate, 0);
+ assert.strictEqual(w.median_cost_usd, 0.0012);
+ assert.strictEqual(w.median_ms, 321);
+ });
+
+ await check('a tie is half a win for each; both_bad is a game for neither', async () => {
+ const b = (await create(bo, 'dashboard', 'An analytics dashboard')).json().round;
+ // Earlier same-origin tests queued rounds too: drain until this one is generated.
+ let bRound;
+ for (let i = 0; i < 12; i++) {
+ bRound = await getRound(b.id);
+ if (bRound.status === 'ready' || bRound.status === 'failed') break;
+ await t.tick();
+ }
+ assert.strictEqual(bRound.status, 'ready');
+ const tie = await t.ctx.arena.vote({ roundId: b.id, subject: cy.subject, leftOutput: bRound.outputs[0].id, rightOutput: bRound.outputs[1].id, winner: 'tie' });
+ assert.strictEqual(tie.winner, 'tie');
+ const lb = (await t.get('/api/v1/arena/leaderboard?category=dashboard')).json().leaderboard;
+ const tied = lb.filter((r) => [bRound.outputs[0].model, bRound.outputs[1].model].includes(r.model));
+ for (const row of tied) {
+ assert.strictEqual(row.games, 1);
+ assert.strictEqual(row.ties, 1);
+ assert.strictEqual(row.wins, 0);
+ assert.strictEqual(row.win_rate, 0.5);
+ assert.strictEqual(row.elo, 1000, 'equal ratings tie to the same Elo');
+ }
+
+ const c = (await create(cy, 'game_ui', 'A game HUD')).json().round;
+ await t.tick();
+ const cRound = await getRound(c.id);
+ const before = new Map(lb.map((r) => [r.model, r.games]));
+ const both = await t.ctx.arena.vote({ roundId: c.id, subject: ana.subject, leftOutput: cRound.outputs[0].id, rightOutput: cRound.outputs[1].id, winner: 'both_bad' });
+ assert.strictEqual(both.winner, 'both_bad');
+ const after = (await t.get('/api/v1/arena/leaderboard?category=game_ui')).json().leaderboard;
+ for (const row of after) {
+ assert.strictEqual(row.games, 0, 'both_bad counted a game for neither');
+ assert.strictEqual(row.elo, 1000);
+ assert.strictEqual(row.ties, 0);
+ }
+ assert.ok(before.size >= 0);
+ });
+
+ await check('the dataset has prompts, output HTML and votes, and no voter ids', async () => {
+ const ds = await t.get('/api/v1/arena/dataset.jsonl');
+ assert.strictEqual(ds.status, 200);
+ assert.match(ds.headers.get('content-type'), /ndjson|jsonl/);
+ const lines = ds.text.trim().split('\n').map((l) => JSON.parse(l));
+ assert.ok(lines.length >= 3);
+ const a = lines.find((r) => r.id === A.id);
+ assert.strictEqual(a.prompt, 'A landing page for a tiny coffee roastery in Seattle');
+ assert.strictEqual(a.outputs.length, 3);
+ assert.ok(a.outputs.every((o) => typeof o.html === 'string' && o.html.length));
+ assert.strictEqual(a.votes.length, 1);
+ assert.strictEqual(a.votes[0].winner, 'left');
+ assert.ok(!ds.text.includes(ana.subject));
+ assert.ok(!ds.text.includes(bo.subject));
+ assert.ok(!ds.text.includes(cy.subject));
+ assert.ok(!/voter_subject|"voter"/.test(ds.text));
+ });
+
+ await check('the pages render the leaderboard, the form and the frames with the CSP and both widths', async () => {
+ const home = await t.get('/arena');
+ assert.strictEqual(home.status, 200);
+ assert.match(home.text, /id="ar-leaderboard"/);
+ assert.match(home.text, /id="ar-lb-landing"/);
+ assert.match(home.text, new RegExp(winner.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+ const round = await t.get(`/arena/r/${A.id}`);
+ assert.strictEqual(round.status, 200);
+ assert.match(round.text, /sandbox=""/);
+ assert.match(round.text, /default-src 'none'/);
+ assert.match(round.text, /width="390"/);
+ assert.match(round.text, /width="1280"/);
+ assert.match(round.text, /Phone · 390 px/);
+ assert.match(round.text, /Desktop · 1280 px/);
+ assert.doesNotMatch(round.text, /allow-scripts/);
+ assert.doesNotMatch(round.text, /allow-same-origin/);
+ const form = await t.get('/arena/new', { as: ana });
+ assert.match(form.text, /id="ar-new-form"/);
+ assert.match(form.text, /id="ar-category"/);
+ assert.match(form.text, /id="ar-prompt"/);
+ const anonVote = await t.get('/arena/vote');
+ assert.match(anonVote.text, /Sign in to vote/);
+ const gone = await t.get('/arena/r/nope');
+ assert.strictEqual(gone.status, 404);
+ });
+
+ await check('only when fewer than three offers are listed does a round fall back to the pool, and say so', async () => {
+ const two = await boot({ aiOptions: { models: [
+ { provider: 'acme', model: 'alpha-1' },
+ { provider: 'beta', model: 'beta-2' },
+ ] } });
+ try {
+ const u = two.network.addUser('few');
+ const r = await two.get('/api/v1/arena/rounds', { as: u, json: { category: 'landing', prompt: 'Two offers only' }, origin: ORIGIN });
+ assert.strictEqual(r.status, 201, r.text);
+ const done = await two.tick();
+ assert.strictEqual(done.status, 'ready');
+ assert.strictEqual(done.pool_fallback, true);
+ assert.deepStrictEqual(done.offers, [
+ { provider: 'acme', model: 'alpha-1' },
+ { provider: 'beta', model: 'beta-2' },
+ { provider: 'ai', model: null },
+ ]);
+ const posts = two.ai.requests.filter((x) => x.method === 'POST' && x.path === '/api/v1/generate');
+ assert.strictEqual(posts.length, 3);
+ assert.deepStrictEqual(posts[0].body.route, { provider: 'acme', model: 'alpha-1' });
+ assert.deepStrictEqual(posts[1].body.route, { provider: 'beta', model: 'beta-2' });
+ assert.strictEqual(posts[2].body.route, undefined, 'the third slot is the routing pool');
+ const page = await two.get(`/arena/r/${done.id}`);
+ assert.match(page.text, /id="ar-round-pool"/);
+ assert.match(page.text, /Fewer than three distinct offers/);
+ } finally { await two.close(); }
+ });
+
+ await check('the create page refuses over the person budget in words and keeps a 429', async () => {
+ const small = await boot({ env: { DESIGN_ARENA_PER_DAY: '2', DESIGN_ARENA_DAILY: '3' } });
+ try {
+ const u = small.network.addUser('limit');
+ const v = small.network.addUser('other');
+ assert.strictEqual((await small.get('/api/v1/arena/rounds', { as: u, json: { category: 'landing', prompt: 'one' }, origin: ORIGIN })).status, 201);
+ assert.strictEqual((await small.get('/api/v1/arena/rounds', { as: u, json: { category: 'landing', prompt: 'two' }, origin: ORIGIN })).status, 201);
+ const refused = await small.get('/api/v1/arena/rounds', { as: u, json: { category: 'landing', prompt: 'three' }, origin: ORIGIN });
+ assert.strictEqual(refused.status, 429);
+ assert.strictEqual(refused.json().code, 'arena.limit_person');
+ assert.match(refused.json().detail, /You can start 2 Arena rounds a day/);
+ const page = await small.get('/arena/new', { as: u });
+ assert.match(page.text, /0 of 2 rounds left/);
+ assert.strictEqual((await small.get('/api/v1/arena/rounds', { as: v, json: { category: 'landing', prompt: 'v one' }, origin: ORIGIN })).status, 201);
+ const site = await small.get('/api/v1/arena/rounds', { as: v, json: { category: 'landing', prompt: 'v two' }, origin: ORIGIN });
+ assert.strictEqual(site.status, 429);
+ assert.strictEqual(site.json().code, 'arena.limit_site');
+ assert.match(site.json().detail, /reached its 3 rounds for today/);
+ } finally { await small.close(); }
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+
+/**
+ * An in-process stand-in for OpenVibe.AI: the chat route's offers and the `generate` operation, in the shapes
+ * server/api/runs.js returns. It verifies the client-credentials token Design got from the Network stub
+ * (audience openvibe.ai, capability ai.run.create), records every request, and can fail, delay or answer
+ * asynchronously. Tests read state.requests and state.tokenClaims.
+ */
+const http = require('http');
+const crypto = require('crypto');
+
+const DEFAULT_MODELS = [
+ { provider: 'acme', model: 'alpha-1' },
+ { provider: 'beta', model: 'beta-2' },
+ { provider: 'gamma', model: 'gamma-3' },
+];
+
+function payload(token) {
+ const part = String(token || '').split('.')[1];
+ if (!part) return null;
+ try { return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); } catch { return null; }
+}
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj, headers = {}) => {
+ res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
+ res.end(JSON.stringify(obj));
+ };
+ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { code: 'stub.error', detail: String(err && err.message) }));
+ });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({
+ server,
+ url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ }));
+ });
+}
+
+async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.0012, ms = 321, tokensIn = 120, tokensOut = 480, failModels = [], asyncMode = false, routesStatus = 200 } = {}) {
+ const st = {
+ models: models.map((m) => ({ ...m })),
+ requests: [], routeRequests: 0, lastAuth: null, tokenClaims: null,
+ costUsd, ms, tokensIn, tokensOut,
+ failModels: new Set(failModels), asyncMode, routesStatus,
+ delayMs: 0, runCount: 0, runs: new Map(), htmlFor: null,
+ };
+
+ function routeFor(body) {
+ const asked = body && body.route;
+ if (!asked || typeof asked !== 'object') return null;
+ return st.models.find((x) => x.provider === asked.provider && (asked.model == null || x.model === asked.model)) || false;
+ }
+
+ function modelFor(body) {
+ const hit = routeFor(body);
+ if (hit) return hit;
+ const i = st.runCount % st.models.length;
+ return st.models[i] || { provider: 'stub', model: 'stub-1' };
+ }
+
+ const srv = await listen(async (req, raw, json) => {
+ const url = new URL(req.url, 'http://x');
+ const text = raw.toString('utf8');
+ let body = {};
+ try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
+ const auth = String(req.headers.authorization || '');
+ st.lastAuth = auth || null;
+ const claims = auth.startsWith('Bearer ') ? payload(auth.slice(7)) : null;
+ if (claims) st.tokenClaims = claims;
+ if (!claims || !(claims.aud || []).includes('openvibe.ai') || !(claims.cap || []).includes('ai.run.create')) {
+ return json(401, { code: 'token.invalid', detail: 'a Design client-credentials token with ai.run.create is required' });
+ }
+ if (st.delayMs) await new Promise((r) => setTimeout(r, st.delayMs));
+
+ if (url.pathname === '/api/v1/routes' && req.method === 'GET') {
+ st.routeRequests++;
+ if (st.routesStatus !== 200) return json(st.routesStatus, { code: 'route.unavailable', detail: 'routes are down' });
+ const [first, ...rest] = st.models;
+ return json(200, {
+ routes: [{
+ key: 'default.chat', version: 3, status: 'active', capability: 'chat',
+ primary: { provider: first.provider, model: first.model },
+ fallbacks: rest.map((m) => ({ provider: m.provider, model: m.model })),
+ pinned: [], constraints: {}, alias_of: null,
+ // AI's routing lists the offers for the operation; Design pins one per slot.
+ offers: st.models.map((m) => ({ provider: m.provider, model: m.model })),
+ }],
+ });
+ }
+
+ if (url.pathname === '/api/v1/generate' && req.method === 'POST') {
+ st.requests.push({ method: 'POST', path: url.pathname, query: Object.fromEntries(url.searchParams), body, claims });
+ if (body.route && !routeFor(body)) {
+ return json(422, { code: 'input.invalid', detail: `route ${body.route.provider}/${body.route.model} is not offered` });
+ }
+ const pick = modelFor(body);
+ st.runCount++;
+ const id = `run_${crypto.randomBytes(8).toString('hex')}`;
+ if (st.failModels.has(pick.model)) {
+ return json(502, { code: 'provider.unavailable', detail: `${pick.model} is down` });
+ }
+ const make = st.htmlFor || htmlFor;
+ const html = make
+ ? make(body, pick, st)
+ : `<!doctype html><html><head><meta charset="utf-8"><title>A generated design</title></head><body><h1>A page that works</h1><p>${String(body.prompt || '').split('\n')[0].slice(0, 200)}</p><button>Start</button></body></html>`;
+ const started = new Date(Date.now() - st.ms).toISOString();
+ const finished = new Date().toISOString();
+ const run = {
+ id, status: 'succeeded', workflow: { key: 'ai.generate', version: 1 },
+ provenance: { origin: 'ai', workflow: 'ai.generate', provider: pick.provider, model: pick.model, run_id: id },
+ output: { text: html },
+ usage: { tokens_in: st.tokensIn, tokens_out: st.tokensOut, cost_usd: st.costUsd, attempts: 1 },
+ started_at: started, finished_at: finished, created_at: started,
+ };
+ st.runs.set(id, run);
+ if (st.asyncMode) return json(202, { run: { ...run, status: 'running', output: null, usage: {} } }, { Location: `/api/v1/runs/${id}` });
+ return json(201, { run });
+ }
+
+ const one = /^\/api\/v1\/runs\/([^/]+)$/.exec(url.pathname);
+ if (one && req.method === 'GET') {
+ st.requests.push({ method: 'GET', path: url.pathname, claims });
+ const run = st.runs.get(decodeURIComponent(one[1]));
+ if (!run) return json(404, { code: 'run.not_found' });
+ return json(200, { run });
+ }
+ return json(404, { code: 'not_found', detail: 'not found' });
+ });
+
+ return {
+ url: srv.url,
+ state: st,
+ models: st.models,
+ requests: st.requests,
+ close: srv.close,
+ };
+}
+
+module.exports = { startAi, DEFAULT_MODELS };
@@ -1,23 +1,33 @@
@@ -1,23 +1,33 @@
'use strict';
'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way.
+process.env.DESIGN_RATE_LIMIT_PER_MIN = process.env.DESIGN_RATE_LIMIT_PER_MIN || '100000';
/**
/**
- * Boots OpenVibe.Design on a random port against an in-process Network (test/helpers/network.js), and returns a
- * small HTTP client. Every test file gets its own process, Network and app.
+ * Boots OpenVibe.Design on a temp database with a mock Network and AI, captures log lines, and returns a small
+ * HTTP client. Every test file gets its own process, database, Network, AI and app.
*
*
* const t = await boot();
* const t = await boot();
- * t.get(path, { as: user, cookie, json, headers, method })
+ * t.get(path, { as: user, json, headers, method, origin })
* t.signIn(user) → cookie header value (design_at = a Network user token, as after /auth/callback)
* t.signIn(user) → cookie header value (design_at = a Network user token, as after /auth/callback)
- * t.logs(), t.close()
+ * await t.tick() → drive the Arena job loop once
+ * t.logs(), t.dbDump()
*/
*/
const http = require('http');
const http = require('http');
const { startNetwork } = require('./network');
const { startNetwork } = require('./network');
+const { startAi } = require('./ai');
async function boot(opts = {}) {
async function boot(opts = {}) {
const network = await startNetwork(opts.network || {});
const network = await startNetwork(opts.network || {});
+ const ai = opts.ai || await startAi(opts.aiOptions || {});
const env = {
const env = {
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.design', TRUST_PROXY: '1',
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.design', TRUST_PROXY: '1',
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url, OV_NETWORK_ISSUER: network.url,
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url, OV_NETWORK_ISSUER: network.url,
OV_OAUTH_CLIENT_ID: 'design', OV_OAUTH_CLIENT_SECRET: 'design-secret', COOKIE_SECURE: 'false',
OV_OAUTH_CLIENT_ID: 'design', OV_OAUTH_CLIENT_SECRET: 'design-secret', COOKIE_SECURE: 'false',
+ OV_AI_INTERNAL_URL: ai.url,
DESIGN_RATE_LIMIT_PER_MIN: '100000',
DESIGN_RATE_LIMIT_PER_MIN: '100000',
+ DESIGN_DRILL: '1',
+ DESIGN_AI_POLL_MS: '10',
+ DESIGN_AI_WAIT_MS: '5000',
+ DESIGN_ARENA_JOB_INTERVAL_MS: '100000',
...(opts.env || {}),
...(opts.env || {}),
};
};
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
@@ -32,8 +42,11 @@ async function boot(opts = {}) {
@@ -32,8 +42,11 @@ async function boot(opts = {}) {
const configLib = require('../../server/config');
const configLib = require('../../server/config');
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
+ const { createStore } = require('../../server/db');
const config = configLib.load(env);
const config = configLib.load(env);
- const built = createApp({ config, log, now: opts.now });
+ const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });
+ const store = opts.store || createStore(testdb.db, { now: opts.now });
+ const built = await createApp({ config, store, arenaAi: opts.arenaAi, log, now: opts.now, accountSend: opts.accountSend, random: opts.random });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -46,23 +59,37 @@ async function boot(opts = {}) {
@@ -46,23 +59,37 @@ async function boot(opts = {}) {
if (o.cookie) headers.cookie = o.cookie;
if (o.cookie) headers.cookie = o.cookie;
let body = o.body;
let body = o.body;
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
- const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
- const text = Buffer.from(await res.arrayBuffer()).toString('utf8');
+ const method = o.method || (body ? 'POST' : 'GET');
+ if (o.origin) headers.origin = o.origin;
+ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const text = buf.toString('utf8');
return {
return {
status: res.status,
status: res.status,
headers: res.headers,
headers: res.headers,
text,
text,
+ buffer: buf,
json() { return JSON.parse(text); },
json() { return JSON.parse(text); },
};
};
}
}
+ async function dbDump() {
+ const db = built.ctx.s.db;
+ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
+ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');
+ }
+
return {
return {
- base, network, config, ctx: built.ctx, get, signIn,
+ base, network, ai, config, ctx: built.ctx, store: built.ctx.s, get, signIn, dbDump,
+ tick: () => built.ctx.jobs.tick(),
logs: () => logLines.join('\n'),
logs: () => logLines.join('\n'),
async close() {
async close() {
+ built.ctx.jobs.stop();
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
built.ctx.keys.client.stop();
built.ctx.keys.client.stop();
+ if (testdb) await testdb.close();
await network.close();
await network.close();
+ if (!opts.ai) await ai.close();
},
},
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * DESIGN_TEST_STORE=pg the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+const testDb = ({ store = process.env.DESIGN_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'design', max });
+
+module.exports = { testDb, pgAvailable };
@@ -104,6 +104,16 @@ async function startNetwork({ themes = DEFAULT_THEMES } = {}) {
@@ -104,6 +104,16 @@ async function startNetwork({ themes = DEFAULT_THEMES } = {}) {
st.refresh.set(rt, u);
st.refresh.set(rt, u);
return json(200, { access_token: userToken(u), refresh_token: rt, token_type: 'Bearer' });
return json(200, { access_token: userToken(u), refresh_token: rt, token_type: 'Bearer' });
}
}
+ if (form.grant_type === 'client_credentials') {
+ // A first-party service token: audience openvibe.ai, capability ai.run.create, namespace ai.*.
+ const scope = String(form.scope || '');
+ const now = Math.floor(Date.now() / 1000);
+ const access = signJwt({
+ iss: issuer, sub: 'svc:design', actor_type: 'service', aud: [form.audience || 'openvibe.ai'],
+ cap: scope.split(/\s+/).filter(Boolean), ns: ['ai.*'], iat: now, exp: now + 300,
+ }, privatePem);
+ return json(200, { access_token: access, token_type: 'Bearer', expires_in: 300, scope });
+ }
return json(400, { error: 'unsupported_grant_type' });
return json(400, { error: 'unsupported_grant_type' });
}
}
@@ -81,6 +81,19 @@ const ORIGIN = 'https://openvibe.design';
@@ -81,6 +81,19 @@ const ORIGIN = 'https://openvibe.design';
assert.strictEqual(missing.status, 403, 'no Origin is not this site');
assert.strictEqual(missing.status, 403, 'no Origin is not this site');
});
});
+ await check('a cookie write may say same-origin with Sec-Fetch-Site; a Bearer write needs no Origin', async () => {
+ const site = await t.get('/api/me/theme', {
+ ...signedIn, method: 'PUT', json: { theme_id: 'paper' }, headers: { 'sec-fetch-site': 'same-origin' },
+ });
+ assert.strictEqual(site.status, 200, site.text);
+ const bearer = await t.get('/api/me/theme', {
+ method: 'PUT', json: { theme_id: 'aurora' }, headers: { authorization: `Bearer ${token}` },
+ });
+ assert.strictEqual(bearer.status, 200, bearer.text);
+ const sent = writes('/api/themes/me').pop();
+ assert.strictEqual(sent.authorization, `Bearer ${token}`, 'the Bearer token is forwarded, not the cookie');
+ });
+
await check('a Network 422 answer passes through with its status and body', async () => {
await check('a Network 422 answer passes through with its status and body', async () => {
const r = await t.get('/api/themes/submit', {
const r = await t.get('/api/themes/submit', {
...signedIn,
...signedIn,