openvibers/openvibe.exchange Commits

Exchange, remade: every AI model, one account (#81)

OP OpenVibers committed 7 days ago · 50a7f9c8c4 · 65 files, +3598 −4346

* Exchange, remade: every AI model, one account

The site is now the network's AI front, read from OpenVibe.AI:

- Home with live numbers (popular by requests, cheapest per million, uncensored,
  community machines, the endpoint, AI in OpenVibe).
- /models and /m/<org>/<model>: every offer with price in/out per million, p50/p95,
  uptime and data policy; a Try it box; curl and the OpenAI SDK line.
- /chat on the shared conversation UI (openvibe-shared/thread): one to three models
  side by side, system prompt, temperature, history in the account, a daily
  allowance, and the 18+ gate for an uncensored model. Chat and Try it are
  ai.complete runs on the person's behalf.
- /api, /sell (with what is not sold, said plainly), /uncensored (the floor) and
  /ai (product, models and daily cost).

Exchange v1 is retired: every v1 page answers 410 with a pointer, /api/v1 answers
410 exchange.retired, its tables stay read-only, and its tests move to
test/retired/. New migration 0005_chat.sql; account export/deletion covers the
chats and the 18+ confirmation.

* Merge remote-tracking branch 'origin/main' into exchange/remake-site

# Conflicts:
#	README.md

* Exchange: the design review, on the one column

- One centred column everywhere: the head's title and every section start on the
  same 16 px line at 390 and 1280; links sit inside their cards.
- Hero: the stray gradient panel is gone; at 1024 px a side-by-side answer preview
  (family marks, live prices and speed, example words said as an example).
- Numbers: models, providers, cheapest per million and the free daily allowance;
  requests only from 1,000 a day.
- Popular now is one list of up to eight rows (mark, badges, price, speed, uptime,
  Try it), then a three-card strip for the product lines. No repeated model cards.
- Family marks are inline SVG glyphs on theme tokens, never initials in a circle;
  products use their own app marks and full names (OpenVibe.Live).
- Model page: privacy label per offer (Private, Community, Standard), refusal rate
  when one exists, a Presets box (the aliases that work today, the saved shape),
  and a stacked card table on a phone.
- Chat: two or three answers in columns at 1024 px, headed by the model mark, that
  answer's price and latency, with a folded receipt (offer, price, tokens,
  latency, why); the empty avatar above the first answer is gone.
- /sell: seller card, idle-only, kudos, and the privacy sentence said plainly.
- New /status: uptime per offer, the model changelog and incidents.
- AI in OpenVibe: product marks, full names and cost a day.

Fresh screenshots of every page at 390 and 1280 px, dark and light, in
/tmp/exchange-shots; no page scrolls sideways. npm test -- pages chat retirement
account-data agent-layer migration sso: 7/7.

* Exchange: one card per section, and the Anonymized label

- kit.section is already the card, so a section no longer draws a second border
  around itself and a third around its rows: Popular now, Use it from code and AI
  in OpenVibe are one card each, with the list or table flush inside.
- Section prose keeps the heading's left edge (the "Use it from code" paragraph).
- AI in OpenVibe's product mark is 28 px, the same as the model marks.
- A provider that says it anonymises before the model sees the request gets the
  Anonymized privacy label; nothing is shown for an offer where Bench has no
  refusal rate.

* Exchange: keep /openapi.json, and say only what the API says publicly

- /openapi.json reads https://api.openvibe.exchange/openapi.json server-side,
  keeps it ten minutes, sets servers[0] to the API host, and answers from the
  last copy (or a minimal document) when the host is down. Tests cover the
  fetch-once cache, the server URL and the fallback.
- The read API's kind (or type) is read, and every list shows only chat and
  vision models, so embeddings and speech models do not leak into the chat
  catalogue.
- A provider's internal label ("Shared provider (Live's shared key)", "Local
  model (OpenAI-compatible server on this host)") and its access codes are
  never printed: an offer is its public name, OpenVibe for OpenVibe's own
  capacity, or a community machine.

* Exchange: a quiet Free to try badge for free_tier offers

The read API's free_tier (true only where an offer is inside the free-use
ceiling: DeepSeek, local and community capacity) marks the model in Popular
now, in /models, in the model page head and on the offer row. The chat picker
marks the free-tier chips and says plainly that other models need your own
balance or key. No field, no badge.

Changes

ddiff --git a/.env.example b/.env.example
iindex 672d7fb..360e8ae 100644
---- a/.env.example
++++ b/.env.example
@@@ -12,14 +12,14 @@ DATABASE_URL=
DATABASE_DIRECT_URL=
VALKEY_URL=
VALKEY_PREFIX=ov:exchange:
--# Per-actor limits (server/http/api.js) and per-address limits (server/app.js).
--#EXCHANGE_LIMITS_MINUTE=120
--#EXCHANGE_LIMITS_HOUR=3000
--#EXCHANGE_API_RATE_LIMIT_PER_MIN=240
++# Per-address limits (server/app.js) and the chat's per-person limit on top.
#EXCHANGE_RATE_LIMIT_PER_MIN=300
++#EXCHANGE_CHAT_PER_DAY=40
++#EXCHANGE_CHAT_MODELS=3
++#EXCHANGE_CHAT_MAX_CHARS=8000
--# OpenVibe.Network: SSO (OAuth client `exchange`, PKCE S256) and its JWKS; app tokens for audience
--# openvibe.exchange (no v1 route needs one).
++# OpenVibe.Network: SSO (OAuth client `exchange`, PKCE S256) and its JWKS; the client-credentials token for
++# OpenVibe.AI is made from the same client secret (Network grants it ai.run.create, ns ai.complete).
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
# OV_NETWORK_ISSUER=https://openvibe.network
@@@ -30,25 +30,24 @@ OV_OAUTH_CLIENT_SECRET=
# OV_SESSION_AUDIENCE=openvibe.network
# EXCHANGE_PGLITE_DIR=./data/pglite
--# The sandbox: a manifest dry run, not a code execution. A passed trial is good for this many minutes and is
--# required (once) before an install; a person gets at most this many trials a day.
--#EXCHANGE_SANDBOX_TTL_MINUTES=60
--#EXCHANGE_SANDBOX_PER_DAY=20
++# OpenVibe.AI: the public catalog and usage reads, and the ai.complete runs a chat or a Try it makes on the
++# person's behalf. The reads are public; the runs carry this service's token (OV_OAUTH_CLIENT_SECRET).
++#OV_AI_URL=https://ai.openvibe.services
++#OV_AI_AUDIENCE=openvibe.ai
++#OV_AI_SCOPE=ai.run.create
++#EXCHANGE_AI_TIMEOUT_MS=30000
++#EXCHANGE_AI_WAIT_MS=25000
--# The first-party catalogue: Design's built-in themes and token files, Live's OBS scenes and alerts, and Games'
--# public worlds. The job runs at boot and every interval; EXCHANGE_FIRST_PARTY=0 turns it off everywhere.
--#EXCHANGE_FIRST_PARTY_INTERVAL_MS=21600000
--#EXCHANGE_FIRST_PARTY_TIMEOUT_MS=8000
--#OV_DESIGN_URL=https://openvibe.design
--#OV_LIVE_URL=https://openvibe.live
--#OV_GAMES_URL=https://openvibe.games
++# /openapi.json reads the API host's document and keeps it for ten minutes.
++#OV_EXCHANGE_API_URL=https://api.openvibe.exchange
++#OV_EXCHANGE_OPENAPI_URL=https://api.openvibe.exchange/openapi.json
++#EXCHANGE_OPENAPI_CACHE_MS=600000
++#EXCHANGE_OPENAPI_TIMEOUT_MS=15000
# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery
# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).
EXCHANGE_EVENTS_SECRET=
EXCHANGE_EVENTS_URL=
--# OpenVibe.Search's owner API: the queued Search documents (exchange_outbox) are delivered there. Unset: they wait.
--#EXCHANGE_SEARCH_URL=http://127.0.0.1:4710
#EXCHANGE_EVENTS_ENDPOINT=http://127.0.0.1:5150/internal/events
#EXCHANGE_EVENTS_INTERVAL_MS=5000
ddiff --git a/README.md b/README.md
iindex b6e3f33..4f786c4 100644
---- a/README.md
++++ b/README.md
@@@ -1,207 +1,70 @@
# OpenVibe.Exchange
--> Trade what people make.
++> Every AI model, one account.
--**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).
++**Status:** the remake (2026-11-06) — see [STATUS.json](STATUS.json).
**Domain:** `openvibe.exchange` · **Port:** 5150 · **Service id:** `exchange` · **Env prefix:** `EXCHANGE`
**License:** AGPL-3.0-only (same as every OpenVibe service).
--OpenVibe.Exchange is the OpenVibe marketplace for agents, MCP tools, templates (Website, Report, Space), design
--themes and kits, game assets and creator services. v1 is **free and swap only**: every listing shows a permission
--passport, can be tried in a sandbox, is installed with the passport snapshot, and is revoked in one tap. There is no
--money anywhere in v1 — no price, no Billing, no Vibes, no cash — and a passport may not declare a spend.
--
--## Purpose
--
--- **Listings with passports.** A listing names its kind, mode (`free` or `swap`), licence (SPDX), version and
-- manifest. Its passport declares exactly what it can read, write, spend and contact. `spends` is always empty in v1,
-- enforced by the form, the API and the table's check constraint.
--- **Theme colours.** A first-party theme listing keeps the preview palette Design imported: up to six `#rrggbb`
-- colours (background, surface, text, accent and two more), shown as small swatches on its card and page. Only a
-- valid hex colour ever reaches the style attribute.
--- **Conformance tests for this version.** Submitting a listing runs four tests — `manifest`, `passport`, `licence`,
-- `no_money` — and stores one row per version. Only the owner can publish a draft, and only when every test passed.
--- **Try before install.** A sandbox trial is a manifest and passport dry run. It records its checks and gives a
-- one-hour pass; a passed trial is consumed by the install. v1 **does not execute a listing's code**: Run has no
-- sandbox today, and the page, the API and the tests say so.
--- **Install and revoke.** An install keeps the exact passport it was made with, so a later listing edit cannot widen
-- an old install. A revoke marks it revoked on the spot and is idempotent. In v1 the Exchange install record is the
-- entitlement; the Network grant write/revoke is listed under "Grants it will need".
--- **Free and swap.** A free listing takes claims; a swap listing says what it wants in return and takes offers with
-- what the other person gives. Accepting one closes the listing as `given` or `swapped`, declines the other open
-- offers and tombstone its Search document. There is no escrow and no payment.
--- **Safety.** A fixed prohibited-items list (weapons, illegal drugs, malware/stolen access, sexual services or
-- content, forged documents, live animals, money) is applied to every listing and offer. The safety page carries the
-- local-swap rules.
--- **No AI.** v1 writes no model-generated text and holds no AI capability. `server/text/no-looks.js` is the shared
-- `noLooks` helper (the same rule as Live, Community, MediaHub and Meme) for the day a generated summary or sandbox
-- line is added; a maker's own words are never rewritten.
--
--## Owns
--
--| Table | Holds | Migration |
--|---|---|---|
--| `exchange_listings` | `exl_` + ULID, unique slug (`/listings/<slug>`), title, kind `agent\|mcp_tool\|template\|theme\|game_asset\|service`, mode `free\|swap`, summary/description/wants, SPDX licence + URL, version, public creator name, owner `usr_…`, status `draft\|listed\|hidden\|closed`, manifest jsonb, passport jsonb (spends must be `[]`), palette jsonb (at most six `#rrggbb` preview colours), revision, listed/closed timestamps | `migrations/0001_initial.sql`, `0004_theme_palette.sql` |
--| `exchange_listing_tests` | one conformance test row per (listing, version, name): `manifest\|passport\|licence\|no_money`, `passed\|failed`, detail, ran_at | `0001_initial.sql` |
--| `exchange_listing_events` | every status change: from, to, reason, who, when | `0001_initial.sql` |
--| `exchange_sandbox_trials` | `exs_` + ULID, listing, subject, version, `passed\|failed`, passport snapshot, checks jsonb, created/expires/consumed | `0001_initial.sql` |
--| `exchange_installs` | `exi_` + ULID, listing, subject, version, passport snapshot, `active\|revoked`, installed/revoked, revision; one active per (listing, subject) | `0001_initial.sql` |
--| `exchange_offers` | `exo_` + ULID, listing, from subject and display name, `claim\|swap`, body, give, `open\|accepted\|declined\|withdrawn`, timestamps; one open per (listing, sender) | `0001_initial.sql` |
--| `exchange_outbox` | the Search document queue (`evt_` id, topic, payload, attempts, lease) | `0001_initial.sql` |
--| `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` |
--| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
--
--There is deliberately **no price, currency, amount or Billing id column**. v2 adds paid listings through Billing;
--v1 cannot represent one.
--
--## Does not own
--
--- **Identity, roles and grants** — OpenVibe.Network. Exchange verifies session tokens against its JWKS; a staff route
-- asks for `global_mod` or above. A passport is enforced by Network grants in the plan; v1 cannot create one (see
-- "Grants it will need").
--- **The sandbox runtime** — OpenVibe.Run. v1's sandbox is a dry run; a real execution needs a Run capability.
--- **Search** — OpenVibe.Search. Exchange produces `search.index-document@1` documents in its own outbox, and
-- `openvibe-publishing/search-relay` delivers them to Search's owner API with the `search.document.write` grant
-- (`EXCHANGE_SEARCH_URL`; unset, they wait).
--- **Billing, transfers, cashouts** — OpenVibe.Billing. Not used at all in v1.
--- **The listings engine and local safety rules** — OpenVibe.Rent. Exchange borrows the shape of the rules (no street
-- addresses, a fixed prohibited list, staff hide) but has its own table; it does not call Rent.
--- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.
--
--## Depends on
--
--Environment variable names are in [.env.example](.env.example); no value is committed.
--
--- **OpenVibe.Network** — sign-in (OAuth client `exchange`, PKCE S256, cookies
-- `exchange_at`/`exchange_rt`/`exchange_oauth`), JWKS, token exchange/refresh/revoke, account export/deletion, staff
-- roles: `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_NETWORK_ISSUER`, `OV_OAUTH_CLIENT_ID`,
-- `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`, `OV_SESSION_AUDIENCE`, `EXCHANGE_AUDIENCE`.
--- **OpenVibe.Events** — account deliveries: `EXCHANGE_EVENTS_SECRET`, `EXCHANGE_EVENTS_URL`,
-- `EXCHANGE_EVENTS_ENDPOINT`.
--- **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in
-- `EXCHANGE_PGLITE_DIR` for development.
--- **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (readiness lists it; the limits in v1 count in-process).
--- **Packages** — `openvibe-contracts` v0.229.0, `openvibe-sdk` v0.82.0, `openvibe-shared` v4.115.1, the same tags
-- OpenVibe.Meme pins.
--
--## API
--
--Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the
--`exchange_at` session cookie or `Authorization: Bearer <Network user token>`, verified offline with the Network
--JWKS. A write made with the session cookie must come from `https://openvibe.exchange`
--(403 `exchange.cross_site`). A signed-in account also gets per-actor minute/hour limits (`EXCHANGE_LIMITS_MINUTE`,
--`EXCHANGE_LIMITS_HOUR`).
--
--| Route | What it does |
--|---|---|
--| `GET /listings?limit=&cursor=&mode=&kind=&q=` | public listings, interleaved by kind, newest first inside each kind; opaque keyset cursor; `Cache-Control: public, max-age=60`; each row carries its `palette` |
--| `GET /listings/:slug` | one listing with passport, manifest, licence, tests and counters; a draft/hidden/closed one is 404 unless it is yours or you are staff |
--| `GET /listings/:slug/passport` | the exact passport, manifest, licence, version and tests an installer accepts |
--| `GET /listings/:slug/tests` | the conformance tests for the version the listing names |
--| `GET /listings/:slug/offers` | owner or staff: the open claims/offers |
--| `GET /me/listings` | every listing of yours, all statuses |
--| `POST /listings` | signed in: create a draft; `spends` must be empty and a credential-looking manifest is refused |
--| `POST /listings/:slug/list` | owner: publish after every conformance test for the version passed |
--| `POST /listings/:slug/close` | owner: `{ reason: "withdrawn"\|"given"\|"swapped" }`; tombstones the Search document |
--| `POST /listings/:slug/sandbox` | signed in: run a manifest dry run; a passed trial is consumed by an install |
--| `GET /sandbox/:id` | your trial and its checks |
--| `POST /listings/:slug/install` | signed in: install with the passport snapshot; 409 `exchange.sandbox_required` without a fresh passed trial |
--| `GET /installs` | your installations, active and revoked |
--| `POST /installs/:id/revoke` | revoke in one tap; idempotent |
--| `POST /listings/:slug/offers` | signed in: `{ body, give? }` — a claim on a free listing or a swap offer with what you give |
--| `GET /offers` | the offers you sent and received |
--| `POST /offers/:id/accept\|decline\|withdraw` | owner accepts/declines; sender withdraws; accepting closes the listing |
--| `POST /listings/:slug/hide` | staff: hide a listing and tombstone it |
--
--Validation refusals are `400 { code: "exchange.invalid" }`. Other codes: `exchange.no_money`,
--`exchange.prohibited`, `exchange.manifest_secret`, `exchange.sandbox_required`, `exchange.sandbox_limit`,
--`exchange.offer_exists`, `exchange.own_listing`, `exchange.owner_only`, `exchange.cross_site`,
--`exchange.sign_in_required`, `exchange.staff_required`, `exchange.not_found`, `exchange.rate_limited`.
--
--## The permission passport
--
--A passport is public, declarative metadata:
--
--```json
--{
-- "reads": ["search.read", "space.files.read"],
-- "writes": ["space.files.write"],
-- "spends": [],
-- "contacts": ["https://openvibe.space"]
--}
--```
--
--- Reads and writes are lower-case permission names; contacts are `https` origins; spends must be empty in v1.
--- The manifest is `{ "name", "entry", "runtime" }` only. It is public metadata and never a credential: an unknown
-- key or a value that looks like a secret is refused (`exchange.manifest_secret`).
--- The listing page shows the passport before and after install; an install stores its own copy.
--
--## The sandbox
--
--`POST /listings/:slug/sandbox` runs four checks: the listing is public, every conformance test for this version
--passed, the passport declares no spend, and the execution step is a dry run. The trial is `passed` when all four do;
--it expires after `EXCHANGE_SANDBOX_TTL_MINUTES` (60 by default); a person may run at most
--`EXCHANGE_SANDBOX_PER_DAY` (20) trials a day. A passed trial is **consumed** by the install, so installing again
--needs a fresh one.
++OpenVibe.Exchange is the network's AI front: every model OpenVibe can run, with the live price, speed and uptime
++of every offer that serves it, one chat that puts up to three models side by side, and one OpenAI- and
++Anthropic-compatible endpoint. It reads OpenVibe.AI — the catalog, the aggregate usage and the runs a chat makes
++for a person — and keeps only two things of its own: a person's chats and their 18+ confirmation.
--This is deliberately honest: v1 does not run a listing's code. A real sandbox needs Run's new capability (below).
++## Pages
--## Search
--
--Each public listing is one `search.index-document@1` (owner `exchange`, type `listing`, canonical page
--`/listings/<slug>`) queued in `exchange_outbox` in the same transaction as the listing change. A draft writes
--nothing; closing or hiding writes a tombstone. `openvibe-publishing/search-relay` (v1.5.0) sends the rows to Search's owner
--API a few seconds after the change and every minute, oldest first, superseding a listing's older rows.
--
--## Deploy
++| Path | What |
++|---|---|
++| `/` | "Every AI model, one account": search, popular now, cheapest per million, uncensored, community machines, the endpoint, AI in OpenVibe. Every number is live. |
++| `/models`, `/m/<org>/<model>` | Every model and every offer that serves it: price in, out and cached per million, measured p50/p95, uptime, data policy, a **Try it** box, curl and the SDK line. |
++| `/chat` | One model, or up to three side by side; system prompt, temperature, history in the account. The shared conversation UI (`openvibe-shared/thread`, `ov-thread.js`). |
++| `/api` | The endpoint, model ids (`deepseek/deepseek-chat`, `openvibe/auto`), keys from Network, aggregate usage. |
++| `/sell` | OpenVibe Node's "Offer to the network", the dashboard, and what is **not** sold here (personal plans, personal API keys), said plainly. |
++| `/uncensored` | The 18+ gate on the account, the abliterated models and the hard floor. |
++| `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. |
++| `/status` | Uptime per offer, the model changelog and incidents. |
--`deploy/systemd/openvibe-exchange.service` (port 5150, `/opt/openvibe.exchange`,
--`/etc/openvibe/exchange.env`) and `deploy/nginx/openvibe.exchange.conf` (TLS, www redirect, `/auth/me` on the API
--zone, sign-in, API and a forms zone, `/metrics` and `/internal` 404 from outside). No proxy cache: the app decides
--each response's cacheability.
++Every page is server-rendered with openvibe-shared/kit and complete without JavaScript. A page rendered for a
++signed-in person is `private, no-store`; a public page is `public, max-age=60`.
--`npm run seed:proof` creates the first proof through the same store functions as the API: a CC0-1.0 Website template
--listed, tried in the sandbox, installed with its passport and revoked. It is idempotent.
++## What it reads
--`npm run seed:first-party` (and the boot job, every `EXCHANGE_FIRST_PARTY_INTERVAL_MS`) lists the free things the
--network makes: Design's built-in themes, each with its token files as downloads on one listing, the OBS scenes and
--alerts overlay on Live, and public Games worlds. Each carries its real home URL and is marked official with no person
--as owner; `source`/`source_id` is the idempotency key, so a restart or a changed source updates the same row. A source
--that stops answering never empties the catalogue; one that no longer lists an item retires it. The list is
--interleaved by kind, so a page cannot be filled by one kind.
++- `GET https://ai.openvibe.services/api/v1/exchange/catalog` — the models, grouped, with every live offer: price
++ (rate cards), measured speed and uptime (`provider_stats_daily`), and `metadata.data_policy` on the provider
++ when somebody recorded it. Prepaid capacity carries no price (`free: true`); BYOK and restricted capacity is
++ never an offer.
++- `GET .../api/v1/exchange/usage?days=30` — aggregate requests, tokens and spend by day, model and product
++ (`usage_daily`; never a prompt, an answer or a person).
++- `POST .../api/v1/runs` with `workflow: ai.complete` and `on_behalf_of` — one chat turn or Try it, on the
++ person's allowance, with this service's token (`ai.run.create`, namespace `ai.complete`).
--## Grants it will need
++AI unreachable is said, not papered over: the catalog read keeps the last good copy for five minutes and the
++pages carry an honest notice; no number is invented.
--v1 is written so each of these is a single call site; none is used today. They are external grants on other
--services, requested from the main session (this service registers no capability of its own).
++## Money and free use
--| Grant / scope | Service | Used for | State |
--|---|---|---|---|
--| `network.grants.write` | OpenVibe.Network | create the developer-app grant from the passport snapshot at install | **proposed**; not in contracts v0.229.0 yet |
--| `network.grants.revoke` | OpenVibe.Network | revoke that grant everywhere at one-tap revoke | **proposed**; not in contracts v0.229.0 yet |
--| `run.sandbox.execute` | OpenVibe.Run | execute a listing's entry point in a real sandbox and run its own tests | **proposed**; Run has no sandbox today. Existing `run.job.submit` could back it if Run adds a sandbox job class |
--| `search.document.write` | OpenVibe.Search | relay the `exchange_outbox` documents | granted |
--| `network.account.export.contribute` | OpenVibe.Network | push a person's listings/installs/trials/offers export | existing capability, not granted |
--| `network.account.deletion.confirm` | OpenVibe.Network | confirm the erasure and counts | existing capability, not granted |
--| `events.subscription.manage` | OpenVibe.Events | create the two account subscriptions | existing scope, client credentials |
--| `billing.intent.create`, `billing.transfer.create`, `billing.entitlement.check`, `billing.cashout.manage`, `billing.usage.record` | OpenVibe.Billing | **v2 only**: paid listings, transfers, cashouts | not used in v1; no money |
++A small daily message allowance comes with an account (`EXCHANGE_CHAT_PER_DAY`), and AI's own free allowance
++applies per person because the run is `on_behalf_of` them. Paid use and seller payouts arrive when the owner turns
++money on; this site shows cost, never charges.
--No paid provider and no AI capability is used. No secret is stored in the database, rendered into a page or logged.
++## Exchange v1, retired
--## Tests
++v1 was a free and swap marketplace for agents, MCP tools, templates, themes and game assets. It is retired
++(design 2026-11-06): every v1 path answers **410** with a pointer (agents and MCP tools → Codes and Connect,
++templates → Space and Report, themes → Design), the v1 JSON API answers 410 `exchange.retired`, and its tables
++stay in place, read-only. v1's tests are kept, unrun, in `test/retired/`.
--`test/` boots the app on a temporary PGlite database with an in-process mock of OpenVibe.Network. The suite covers
--migration and constraints, the API and validation, sandbox/install/revoke, free/swap offers, server-rendered pages
--and cache headers, SSO/PKCE and readiness, the Search document, account export/deletion, noLooks and the safety list,
--and the first proof.
++## Run
```sh
--npm test # every file, each in its own process
--OV_ALLOW_SUITE=1 npm test
--npm run test:pg # the same files through PostgreSQL + PgBouncer containers
--VERBOSE=1 npm test # also print each file's output
++npm ci
++npm start # development: embedded PGlite in data/pglite
++npm test -- <file|word> # e.g. npm test -- pages chat retirement
```
++`DATABASE_URL` serves, `DATABASE_DIRECT_URL` migrates; `OV_OAUTH_CLIENT_SECRET` makes the sign-in and the AI
++client-credentials token work. See [.env.example](.env.example).
++
## License
AGPL-3.0-only. See [LICENSE](LICENSE).
ddiff --git a/STATUS.json b/STATUS.json
iindex dd0337c..a8ed4f1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"stage": "product",
"code": true,
"wave": "W21",
-- "plan": "OpenVibe.Exchange: trade what people make — free and swap listings with a permission passport, conformance tests for each version, a sandbox, install with the passport snapshot and one-tap revoke",
++ "plan": "OpenVibe.Exchange, remade (2026-11-06, round two per the design review): every AI model, one account — live prices, speed and uptime from OpenVibe.AI, model pages with every offer, a three-model chat on the shared conversation UI, one OpenAI/Anthropic-compatible endpoint, selling compute through Node, the uncensored tier with its floor, and AI in OpenVibe. Exchange v1 is retired: pages answer 410 with a pointer, tables read-only.",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "5150",
"domain": "openvibe.exchange",
@@@ -16,20 +16,15 @@
"openvibe-sdk v0.82.0",
"openvibe-shared v4.115.1"
],
-- "summary": "OpenVibe.Exchange is the OpenVibe marketplace for what people make: agents, MCP tools, templates, design themes and kits, game assets and creator services. v1 is free and swap only — there is no price, no Billing and no money — and every listing carries a permission passport (reads, writes, spends, contacts) taken from its public manifest. Submitting a listing runs four conformance tests for its version and a draft is only publishable when they pass. A sandbox trial is a manifest/passport dry run (v1 does not execute a listing's code); a passed trial is consumed by the install, which keeps the exact passport snapshot. A revoke is one tap and idempotent. A free listing takes claims and a swap listing takes offers with what the other person gives; accepting closes it as given or swapped. Every public listing is one Search Fabric document queued in the service's outbox, with a tombstone on close or hide.",
++ "summary": "OpenVibe.Exchange is the network's AI front. It reads OpenVibe.AI's public catalog and aggregate usage and renders: the home (popular now, cheapest per million, uncensored, community machines, live numbers), /models and /m/<org>/<model> (every offer with price in/out, measured p50/p95, uptime and data policy, a Try it box, curl and the SDK), /chat (one to three models side by side, system prompt, temperature, history in the account, on openvibe-shared/thread), /api, /sell (Node's offer to the network; what is not sold, said plainly), /uncensored (an 18+ confirmation on the account and the hard floor) and /ai (which products run which models, and the daily cost). Chat and Try it are ai.complete runs on the person's behalf (on_behalf_of) with the service's ai.run.create token. Exchange v1 (free/swap listings, passports, sandboxes, installs, offers) is retired: every v1 page answers 410 with a pointer, its JSON API answers 410 exchange.retired, and its tables stay read-only; its tests live in test/retired/.",
"works": [
-- "Listings with a unique slug, kind, free/swap mode, SPDX licence and URL, semantic version, manifest and permission passport; no price or money column",
-- "A passport with reads, writes, spends and contacts; spends must be empty (a table check, the form and the API agree)",
-- "Four conformance tests per version (manifest, passport, licence, no_money) stored in exchange_listing_tests; only the owner can publish and only when all pass",
-- "Sandbox trials as a manifest/passport dry run with four recorded checks, a TTL and a daily cap; a passed trial is consumed by install",
-- "Installs with the exact passport snapshot, one active per (listing, person), and one-tap idempotent revoke",
-- "Free claims and swap offers with a required give on swaps; the owner accepts/declines, the sender withdraws; accepting closes the listing and tombstones it",
-- "A fixed prohibited-items and no-money filter on every listing and offer",
-- "The API: public cursor-paged listings, detail, passport, tests, owner drafts, sandbox, install, installs, offers and staff hide",
-- "Search Fabric documents (owner exchange, type listing, /listings/<slug>) in exchange_outbox, with a tombstone when closed or hidden, delivered to Search's owner API by openvibe-publishing/search-relay",
-- "Sign-in with OpenVibe.Network (PKCE S256, exchange_at/exchange_rt/exchange_oauth), staff role global_mod+",
-- "Account export and deletion over listings, installs, sandbox trials and offers; a deleted listing is anonymised, closed and tombstoned",
-- "Server-rendered pages with stable ex- ids/classes, cache headers, discovery artifacts, deploy files and the first-proof seed"
++ "A live home in one centred column: popular models (mark, badges, price, speed, uptime, Try it), a three-line product strip, the endpoint and AI in OpenVibe; a side-by-side answer preview with live prices marked as an example",
++ "Model family marks drawn as inline SVG glyphs on theme tokens, with the family name as the label; products use their own app marks and full names (OpenVibe.Live)",
++ "/models and /m/<org>/<model>: privacy label per offer (Private, Community, Standard), measured refusal rate when one exists, uptime, data policy, presets and a Try it box",
++ "/chat on openvibe-shared/thread: two or three answers in columns at 1024 px, each with its model mark, price, latency and a folded receipt (offer, price, tokens, latency, why)",
++ "/sell: seller card, idle-only, kudos, the privacy sentence, the dashboard and what is not sold",
++ "/status: uptime per offer, the model changelog and incidents",
++ "Exchange v1 retired (410 with a pointer, tables read-only); account export/deletion covers chats and the 18+ confirmation"
],
"notYet": [
"The Network grant write/revoke: v1 keeps the install as an Exchange record; network.grants.write and network.grants.revoke are proposed and not in contracts v0.229.0",
ddiff --git a/migrations/0005_chat.sql b/migrations/0005_chat.sql
nnew file mode 100644
iindex 0000000..042798f
---- /dev/null
++++ b/migrations/0005_chat.sql
@@@ -0,0 +1,40 @@
++-- phase: expand
++-- The Exchange remake (2026-11-06): a person's conversations with the network's models, and their 18+
++-- confirmation for the uncensored tier. v1's tables stay as they are: read-only, never written again.
++
++CREATE TABLE IF NOT EXISTS exchange_chats (
++ id text COLLATE "C" PRIMARY KEY, -- exc_ + 26 Crockford base32 characters (ULID)
++ owner text COLLATE "C" NOT NULL, -- usr_…
++ title text NOT NULL DEFAULT 'New chat',
++ models text COLLATE "C" NOT NULL DEFAULT '[]', -- the model slugs this chat compares (JSON array, 1-3)
++ system_prompt text,
++ temperature double precision,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_chats_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT exchange_chats_title CHECK (length(title) BETWEEN 1 AND 120),
++ CONSTRAINT exchange_chats_temperature CHECK (temperature IS NULL OR (temperature >= 0 AND temperature <= 2))
++);
++CREATE INDEX IF NOT EXISTS exchange_chats_owner ON exchange_chats (owner, updated_at DESC);
++
++CREATE TABLE IF NOT EXISTS exchange_chat_messages (
++ id text COLLATE "C" PRIMARY KEY, -- exm_ + 26 Crockford base32 characters (ULID)
++ chat_id text COLLATE "C" NOT NULL REFERENCES exchange_chats (id) ON DELETE CASCADE,
++ owner text COLLATE "C" NOT NULL, -- denormalised for export and deletion
++ role text COLLATE "C" NOT NULL, -- user | assistant
++ model text COLLATE "C", -- the slug asked for (an assistant turn)
++ content text,
++ error text, -- why a model's turn did not answer
++ meta text COLLATE "C" NOT NULL DEFAULT '{}', -- run id, provider, cost, answered model
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_chat_messages_role CHECK (role IN ('user', 'assistant'))
++);
++CREATE INDEX IF NOT EXISTS exchange_chat_messages_chat ON exchange_chat_messages (chat_id, created_at);
++CREATE INDEX IF NOT EXISTS exchange_chat_messages_owner ON exchange_chat_messages (owner, created_at);
++
++CREATE TABLE IF NOT EXISTS exchange_adult_confirmations (
++ subject text COLLATE "C" PRIMARY KEY, -- usr_…
++ version text COLLATE "C" NOT NULL, -- the floor's version the person confirmed
++ confirmed_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_adult_confirmations_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')
++);
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex c2e07d9..b97dcc2 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -1,161 +1,265 @@
--/* OpenVibe.Exchange: the room's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's accent
-- (.ov-room: --room-accent, --room-strong, --room-fill, --room-on), so every theme, light or dark, wears it. Phone
-- first; nothing scrolls sideways. A listing always shows its passport: what it reads, writes and spends. */
--*, *::before, *::after { box-sizing: border-box; }
--html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
++/* OpenVibe.Exchange: every AI model, one account. The kit does the pieces; this is the site's one column, its
++ hero, marks, panels and the chat's answer columns. Theme tokens only, so it wears every theme, light and dark. */
++
++.ex {
++ --ex-max: 1120px;
++ --ex-gap: 18px;
++}
++.ex, .ex *, .ex *::before, .ex *::after { box-sizing: border-box; }
.skip { position: absolute; left: -9999px; }
--.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }
--.ex { display: block; }
--.ex a { color: var(--room-strong); }
--.ex .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
--.ex-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
--.ex-narrow { max-width: 620px; }
--.ex-page-pad { padding-top: clamp(28px, 6vw, 64px); }
--.ex h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }
--.ex-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
--.ex-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }
--.ex-page-h h1, .ex-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }
--.ex-lede, .ex-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
--.ex-fine, .ex-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
--.ex-section { margin-top: clamp(28px, 4vw, 44px); }
--.ex-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
--.ex-count { color: var(--text-muted, #7386a3); font-size: .86rem; }
--.ex-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }
--.ex-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
--.ex-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }
--.ex-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }
--.ex-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }
--.ex-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }
--.ex-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
--.ex-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }
--.ex-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }
--.ex-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }
--.ex-empty p { margin: 0; line-height: 1.5; }
--.ex-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }
--
--
--.ex-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
--.ex-form { display: grid; gap: 14px; }
--.ex-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
--.ex-field input, .ex-field select, .ex-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }
--.ex-field input:focus, .ex-field select:focus, .ex-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
--.ex-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
--.ex-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
--.ex-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
--.ex-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }
--.ex-eps { display: grid; gap: 10px; margin: 12px 0 0; }
--.ex-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
--.ex-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }
--.ex-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
--
--
--.ex-section-head, .ex-more-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
--.ex-prose { max-width: 720px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }
--.ex-prose-list { margin: 0; padding-left: 20px; display: grid; gap: 6px; line-height: 1.55; color: var(--text-secondary, #96a7c2); }
--.ex-fieldset { margin: 0; padding: 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; display: grid; gap: 10px; }
--.ex-field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; }
--.ex-filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
--.ex-filters a { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); font-size: .86rem; font-weight: 700; text-decoration: none; color: var(--text-secondary, #96a7c2); }
--.ex-filters a[aria-current] { background: var(--room-fill); color: var(--room-on); border-color: transparent; }
--.ex-inline { margin: 0; display: inline; }
--.ex-ok, .ex-status-active, .ex-verified, .ex-official { color: var(--success-text, #22c55e); font-weight: 800; }
--.ex-status-revoked, .ex-unverified { color: var(--text-muted, #7386a3); font-weight: 800; }
--.ex-status, .ex-kind, .ex-mode, .ex-license, .ex-version { display: inline-flex; align-items: center; padding: 1px 9px; border-radius: 999px; font-size: .75rem; font-weight: 800; background: color-mix(in srgb, var(--text-muted, #7386a3) 14%, transparent); color: var(--text-secondary, #96a7c2); }
--.ex-kind, .ex-mode { background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); }
--
--/* The motif: an item looping between two makers, a passport stamped "safe" */
--.ex-motif { position: relative; width: min(100%, 440px); aspect-ratio: 420 / 260; margin: 0 auto; color: var(--room-accent); }
--.ex-motif-arrows { position: absolute; inset: 0; width: 100%; height: 100%; }
--.ex-motif-arrows path { stroke: currentColor; stroke-width: 3; stroke-linecap: round; opacity: .7; }
--html.ov-motion .ex-motif-arrows path { stroke-dasharray: 500; animation: ex-loop 2s cubic-bezier(.2,.7,.2,1) both; }
--@keyframes ex-loop { from { stroke-dashoffset: 500; } }
--.ex-motif-item { position: absolute; padding: 9px 14px; border-radius: 12px; font-size: .86rem; font-weight: 800; background: var(--bg-card, #0f1729); border: 1px solid color-mix(in srgb, var(--room-accent) 45%, var(--border, #1f2d47)); color: var(--text-primary, #e6edf7); box-shadow: 0 8px 22px rgba(0, 0, 0, .22); }
--.ex-motif-item-a { left: 4%; top: 18%; } .ex-motif-item-b { right: 4%; top: 18%; }
--.ex-motif-passport { position: absolute; left: 50%; bottom: 8%; translate: -50% 0; display: grid; justify-items: center; padding: 10px 18px; border-radius: 14px; background: var(--room-fill); color: var(--room-on); rotate: -4deg; box-shadow: 0 10px 26px rgba(0, 0, 0, .28); }
--.ex-motif-passport small { font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; opacity: .95; }
--.ex-motif-passport b { font-size: 1.3rem; letter-spacing: .06em; text-transform: uppercase; }
--html.ov-motion .ex-motif-passport { animation: ex-stamp .5s cubic-bezier(.2,.9,.3,1.4) 1.2s both; }
--@keyframes ex-stamp { from { opacity: 0; scale: 1.6; } }
--
--/* Scenes' demos */
--.ex-demo { display: grid; gap: 8px; width: min(100%, 420px); margin: 0 auto; padding: 16px 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
--.ex-demo-head { font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
--.ex-demo-checks, .ex-demo-grants { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: .9rem; }
--.ex-demo-grants b { display: inline-block; min-width: 64px; color: var(--text-muted, #7386a3); font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; }
--.ex-demo-row { display: flex; justify-content: space-between; gap: 10px; font-size: .9rem; }
--.ex-demo-stamp { justify-self: start; padding: 3px 10px; border-radius: 8px; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; color: var(--room-on); background: var(--room-fill); rotate: -3deg; }
--.ex-demo-button { justify-self: start; padding: 7px 14px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--error, #ef4444) 50%, transparent); color: var(--error-text, #f87171); font-weight: 800; font-size: .86rem; }
--
--/* Listings */
--.ex-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
--.ex-listing-card { display: grid; }
--.ex-listing-link { display: grid; gap: 8px; height: 100%; padding: 16px; border-radius: 16px; text-decoration: none; color: var(--text-primary, #e6edf7) !important; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }
--.ex-listing-link:hover { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
--.ex-listing-top, .ex-listing-meta, .ex-listing-foot, .ex-byline { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: .82rem; color: var(--text-muted, #7386a3); }
--.ex-listing-title { font-size: 1.08rem; font-weight: 800; line-height: 1.3; overflow-wrap: anywhere; }
--.ex-listing-summary { margin: 0; color: var(--text-secondary, #96a7c2); font-size: .9rem; line-height: 1.5; }
--.ex-listing-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 2.8rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.08; overflow-wrap: anywhere; }
--.ex-creator, .ex-owner { font-weight: 700; color: var(--text-secondary, #96a7c2); }
--
--/* The passport: what a listing reads, writes and spends */
--.ex-passport { position: relative; display: grid; gap: 12px; padding: 18px; border-radius: 18px; background: color-mix(in srgb, var(--room-accent) 6%, var(--bg-card, #0f1729)); border: 2px solid color-mix(in srgb, var(--room-accent) 40%, var(--border, #1f2d47)); }
--.ex-passport-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
--.ex-passport-row { display: grid; gap: 4px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
--.ex-passport-row dt, .ex-passport-row b { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
--.ex-passport-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font: 600 .84rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
--.ex-passport-stamp { position: absolute; right: 16px; top: -12px; padding: 4px 12px; border-radius: 8px; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; color: var(--room-on); background: var(--room-fill); rotate: 4deg; }
--.ex-none { color: var(--text-muted, #7386a3); font-style: italic; }
--.ex-manifest { margin: 0; padding: 14px; border-radius: 12px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font: 500 .84rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 360px; overflow: auto; }
--
--.ex-file-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 8px; }
--.ex-file-list li { min-width: 0; }
--.ex-file-list a { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; padding: 11px 13px; border-radius: 12px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); text-decoration: none; }
--.ex-file-list b { font-weight: 700; overflow-wrap: anywhere; }
--.ex-file-list span { font: 600 .76rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-muted, #7386a3); }
--
--/* Checks and conformance tests */
--.ex-checks, .ex-tests { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
--.ex-check, .ex-test { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 9px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); font-size: .88rem; }
--.ex-check-detail, .ex-test-detail { flex-basis: 100%; color: var(--text-muted, #7386a3); font-size: .82rem; }
--.ex-test-name { font-weight: 700; } .ex-test-status { margin-left: auto; font-weight: 800; }
--.ex-check-pass, .ex-test-pass { border-left: 4px solid var(--success, #22c55e); }
--.ex-check-fail, .ex-test-fail { border-left: 4px solid var(--error, #ef4444); }
--
--/* Installs, offers, mine */
--.ex-install-list, .ex-offer-list, .ex-offers-list, .ex-mine-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
--.ex-install, .ex-offer, .ex-mine-item { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
--.ex-install-revoked { opacity: .7; }
--.ex-install-main, .ex-offer-body { display: grid; gap: 4px; min-width: 0; }
--.ex-install-title { font-weight: 800; overflow-wrap: anywhere; }
--.ex-install-meta, .ex-offer-head, .ex-offer-when, .ex-offer-from { font-size: .82rem; color: var(--text-muted, #7386a3); }
--.ex-offer-give, .ex-wants { font-weight: 700; color: var(--text-secondary, #96a7c2); }
--.ex-staff { color: var(--warning-text, #f59e0b); }
--
--@media (max-width: 759px) { .sc-room-pic .ex-motif-item, .sc-room-pic .ex-motif-passport { display: none; } }
--@media (prefers-reduced-motion: reduce) { .ex-motif-arrows path, .ex-motif-passport { animation: none !important; } }
--html[data-ov-motion=reduced] .ex-motif-arrows path, html[data-ov-motion=reduced] .ex-motif-passport { animation: none !important; }
--
--/* Be the first (10-31): an empty market is one short row after the scenes, never an empty box up top. */
--.ex-first-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 10px; }
--.ex-first-list li { display: grid; gap: 4px; padding: 14px 16px; border-radius: 16px; border: 1px dashed color-mix(in srgb, var(--room-accent) 45%, var(--border, #2c3d5c)); }
--.ex-first-list span { color: var(--text-secondary, #a8b3c4); font-size: 14px; line-height: 1.45; }
--.ex-first-list a { justify-self: start; margin-top: 4px; font-weight: 700; color: var(--room-strong, var(--room-accent)); }
--
--/* A theme's preview palette: up to six small swatches, plain <i> elements; the session restyles them. */
--.ex-palette { display: inline-flex; flex-wrap: wrap; gap: 4px; }
--.ex-palette i { display: inline-block; width: 16px; height: 16px; border-radius: 5px; border: 1px solid var(--border, #1f2d47); }
--
--/* Tables on a phone (phone sweep, 2026-11-01): a table of three columns or more whose cells name their column (data-label) shows each row as a card, the first
-- cell its title and every other cell named by its column (html.js gives each cell its data-label). Narrower tables
-- stay tables. Nothing is left out and nothing scrolls sideways. */
--@media (max-width: 640px) {
-- .table-wrap:has(> table thead th:nth-child(3)):has(> table td[data-label]) { overflow: visible; }
-- .table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) { display: block; }
-- .table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
-- .table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) tbody { display: grid; gap: 10px; }
-- .table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) tr { display: grid; gap: 8px; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, var(--bg-secondary, #101828)); }
-- .table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td { display: block; min-width: 0; padding: 0; border: 0; overflow-wrap: anywhere; }
-- .table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:first-child { font-weight: 700; color: var(--text-primary, #e6edf7); }
-- .table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
-- .table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
++.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--ovk-panel); color: inherit; }
++
++/* One container: the content, and the head's text, start on the same line at every width. */
++.ex-wrap { width: 100%; max-width: var(--ex-max); margin: 0 auto; padding: 0 16px 28px; }
++.ex-narrow { max-width: 760px; }
++.ex-page { padding-top: 10px; }
++.ex-muted { color: var(--ovk-muted); }
++.ex-prose a, .ex-list a, .ex-more a, .ex-empty a, .ovk-empty a { color: var(--ovk-accent); text-underline-offset: 2px; }
++
++/* A sticky head inside the column still spans the window; its title sits on the content line. */
++.ex-page > .ovk-head {
++ --ex-head-pad: max(16px, calc((100vw - var(--ex-max)) / 2 + 16px));
++ --ex-head-bleed: min(0px, calc((var(--ex-max) - 100vw) / 2 - 16px));
++ margin-left: var(--ex-head-bleed);
++ margin-right: var(--ex-head-bleed);
++ padding-left: var(--ex-head-pad);
++ padding-right: var(--ex-head-pad);
++}
++
++/* ── Hero ─────────────────────────────────────────────── */
++.ex-hero {
++ display: grid;
++ grid-template-columns: minmax(0, 1fr);
++ gap: 22px;
++ padding: 34px 0 22px;
++}
++@media (min-width: 1024px) {
++ .ex-hero { grid-template-columns: minmax(0, 1.12fr) minmax(0, 0.88fr); align-items: center; gap: 34px; padding: 46px 0 26px; }
++}
++.ex-kicker { margin: 0 0 8px; font-size: .75rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--ovk-accent); }
++.ex-hero h1 { margin: 0 0 12px; font-size: clamp(2rem, 6vw, 3.2rem); line-height: 1.05; letter-spacing: -.03em; }
++.ex-lead { max-width: 60ch; margin: 0 0 20px; font-size: 1.02rem; line-height: 1.6; color: var(--ovk-muted); }
++.ex-hero .ovk-find { margin-bottom: 14px; }
++.ex-hero-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0; }
++
++/* The preview is not a second product: example words, and the prices and speed are live. */
++.ex-preview { border: 1px solid var(--ovk-line); border-radius: 18px; background: var(--ovk-panel); padding: 14px; }
++.ex-preview-tag { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; margin: 0 0 8px; font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--ovk-muted); }
++.ex-preview-tag span { font-weight: 500; letter-spacing: 0; text-transform: none; }
++.ex-preview-q { margin: 0 0 10px; padding: 8px 12px; border-radius: 12px; background: var(--ovk-panel-2); font-size: .9rem; }
++.ex-preview-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
++@media (min-width: 520px) { .ex-preview-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
++.ex-preview-cell { border: 1px solid var(--ovk-line); border-radius: 12px; padding: 10px 12px; background: var(--ovk-bg); }
++.ex-preview-cell header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
++.ex-preview-cell header b { font-size: .86rem; }
++.ex-preview-cell p { margin: 0 0 8px; font-size: .84rem; line-height: 1.45; color: var(--ovk-text); }
++.ex-preview-cell footer { font-size: .74rem; color: var(--ovk-muted); font-variant-numeric: tabular-nums; }
++
++/* ── Numbers, panels and one-line cards ─────────────────── */
++.ex .ovk-stats { margin: 12px 0 18px; padding: 0; overflow: visible; flex-wrap: wrap; }
++.ex .ovk-stats > div { flex: 1 1 150px; }
++.ex .ovk-stats dt { font-size: .78rem; }
++@media (min-width: 600px) { .ex .ovk-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } }
++
++/* The section is the card. Its rows and tables sit directly in it; prose keeps the heading's left edge. */
++.ex-card { margin: 0 0 var(--ex-gap); }
++.ex-sec-body > p, .ex-sec-body > ul:not(.ex-mlist), .ex-sec-body > h3,
++.ex-sec-body > .ex-presets, .ex-sec-body > .ex-floor-box, .ex-sec-body > .ex-seller,
++.ex-sec-body > .ovk-receipt, .ex-sec-body > .ovk-notice, .ex-sec-body > .ovk-empty,
++.ex-sec-body > .ovk-snippet, .ex-sec-body > .ex-try, .ex-sec-body > .ex-signin,
++.ex-sec-body > .ovk-stats { margin-left: 14px; margin-right: 14px; }
++.ex-sec-body > p, .ex-sec-body > ul:not(.ex-mlist), .ex-sec-body > h3, .ex-sec-body > .ex-presets { margin-bottom: 12px; }
++.ex-sec-body > .ex-more { padding: 0 14px 12px; margin: 8px 0 0; }
++.ex-sec-body > .ovk-list, .ex-sec-body > .ovk-table-wrap, .ex-sec-body > .ex-mlist {
++ border: 0; border-radius: 0; background: transparent; margin: 0;
++}
++.ex-sec-body > .ex-mlist { border-top: 1px solid var(--ovk-line); }
++.ex-sec-body > .ovk-list > .ovk-row:first-child, .ex-sec-body > .ex-mlist > .ex-mrow:first-child { border-top: 0; }
++
++.ex-lines { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; margin: 0 0 var(--ex-gap); }
++@media (min-width: 860px) { .ex-lines { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
++.ex-line {
++ display: flex; align-items: center; gap: 12px;
++ padding: 14px; border: 1px solid var(--ovk-line); border-radius: 16px; background: var(--ovk-panel);
++ color: var(--ovk-text); text-decoration: none;
++}
++.ex-line:hover { border-color: var(--ovk-accent); }
++.ex-line > span:nth-child(2) { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
++.ex-line b { font-size: .95rem; }
++.ex-line small { color: var(--ovk-muted); font-size: .82rem; line-height: 1.4; }
++.ex-line-go { color: var(--ovk-accent); font-weight: 700; }
++
++.ex-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--ex-gap); }
++@media (min-width: 900px) { .ex-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
++
++.ex-more { margin: 10px 0 0; font-size: .9rem; }
++.ex-more a { text-decoration: none; }
++.ex-more a:hover { text-decoration: underline; }
++.ex-prose { max-width: 74ch; line-height: 1.65; }
++.ex-prose code, .ex-wrap code { font-size: .9em; overflow-wrap: anywhere; }
++.ex-h3 { margin: 22px 0 8px; font-size: 1rem; }
++.ex-list { margin: 0 0 12px; padding-left: 1.2em; line-height: 1.65; }
++.ex-list li { margin: 4px 0; }
++
++.ex-trail { display: inline-flex; flex-direction: column; align-items: flex-end; line-height: 1.1; }
++.ex-trail b { font-variant-numeric: tabular-nums; }
++.ex-trail small { color: var(--ovk-muted); font-size: .68rem; }
++
++.ex-row-name { color: inherit; text-decoration: none; }
++.ex-row-name:hover { text-decoration: underline; }
++.ex-try-link {
++ display: inline-flex; align-items: center; min-height: 34px; padding: 0 12px;
++ border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-text);
++ text-decoration: none; font-size: .82rem; white-space: nowrap;
++}
++.ex-try-link:hover { border-color: var(--ovk-accent); }
++
++/* ── Model rows ─────────────────────────────────────────── */
++.ex-mlist { list-style: none; margin: 0; padding: 0; }
++.ex-mrow { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px solid var(--ovk-line); }
++.ex-mrow:first-child { border-top: 0; }
++.ex-mrow-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; color: var(--ovk-text); text-decoration: none; }
++.ex-mrow-main:hover b { text-decoration: underline; }
++.ex-mrow-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
++.ex-mrow-title { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
++.ex-mrow-title b { font-size: .95rem; overflow-wrap: anywhere; }
++.ex-mrow-text small { color: var(--ovk-muted); font-size: .8rem; line-height: 1.35; overflow-wrap: anywhere; }
++
++/* ── Model family marks ─────────────────────────────────── */
++.ex-mk {
++ --ex-mk: var(--ovk-muted);
++ display: inline-grid; place-items: center; flex: none;
++ width: var(--ex-mk-size, 28px); height: var(--ex-mk-size, 28px);
++ border-radius: calc(var(--ex-mk-size, 28px) * .28);
++ background: color-mix(in srgb, var(--ex-mk) 16%, var(--ovk-panel));
++ color: var(--ex-mk);
++}
++.ex-mk svg { width: 62%; height: 62%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
++.ex-mk--accent { --ex-mk: var(--ovk-accent); }
++.ex-mk--ok { --ex-mk: var(--ovk-ok); }
++.ex-mk--warn { --ex-mk: var(--ovk-warn); }
++.ex-mk--bad { --ex-mk: var(--ovk-bad); }
++.ex-mk--live { --ex-mk: var(--ovk-live); }
++.ex-mk--muted, .ex-mk--other { --ex-mk: var(--ovk-muted); }
++.ex-mk--product { width: 22px; height: 22px; border-radius: 6px; object-fit: cover; background: transparent; }
++.ex-mk--machine { --ex-mk: var(--ovk-accent); }
++/* Family hues use the semantic tokens: accent, ok, warn, bad, live. */
++.ex-mk--deepseek { --ex-mk: var(--ovk-accent); }
++.ex-mk--qwen { --ex-mk: var(--ovk-ok); }
++.ex-mk--kimi { --ex-mk: var(--ovk-live); }
++.ex-mk--glm { --ex-mk: var(--ovk-warn); }
++.ex-mk--minimax { --ex-mk: var(--ovk-bad); }
++.ex-mk--llama { --ex-mk: var(--ovk-accent); }
++.ex-mk--mistral { --ex-mk: var(--ovk-warn); }
++.ex-mk--gemma { --ex-mk: var(--ovk-ok); }
++.ex-mk--openai { --ex-mk: var(--ovk-ok); }
++.ex-mk--anthropic { --ex-mk: var(--ovk-warn); }
++.ex-mk--google { --ex-mk: var(--ovk-accent); }
++.ex-mk--meta { --ex-mk: var(--ovk-accent); }
++.ex-mk--microsoft { --ex-mk: var(--ovk-accent); }
++
++/* ── Products (AI in OpenVibe) ──────────────────────────── */
++.ex-product-list { list-style: none; margin: 0; padding: 0; }
++.ex-product-row + .ex-product-row { border-top: 1px solid var(--ovk-line); }
++.ex-product-row a { display: flex; align-items: center; gap: 10px; padding: 10px 0; color: var(--ovk-text); text-decoration: none; }
++.ex-product-row a > span:nth-child(2) { flex: 1; min-width: 0; display: flex; flex-direction: column; }
++.ex-product-row small { color: var(--ovk-muted); font-size: .8rem; }
++.ex-product-row a:hover b { text-decoration: underline; }
++.ex-product-cell { display: inline-flex; align-items: center; gap: 8px; }
++
++/* ── One model ──────────────────────────────────────────── */
++.ex-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
++.ex-chip {
++ display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px;
++ border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-text);
++ text-decoration: none; font-size: .88rem; background: var(--ovk-panel);
++}
++.ex-chip:hover { border-color: var(--ovk-accent); }
++.ex-chip.is-on { border-color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 16%, transparent); font-weight: 600; }
++.ex-offer-name { font-weight: 600; }
++.ex-mini {
++ display: inline-flex; align-items: center; min-height: 34px; padding: 0 12px;
++ border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-text);
++ text-decoration: none; font-size: .82rem;
++}
++.ex-mini:hover { border-color: var(--ovk-accent); }
++
++.ex-presets { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; margin-bottom: 10px; }
++@media (min-width: 720px) { .ex-presets { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
++.ex-preset { border: 1px solid var(--ovk-line); border-radius: 14px; padding: 12px 14px; background: var(--ovk-bg); }
++.ex-preset header { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; margin-bottom: 4px; }
++.ex-preset code { font-size: .78rem; color: var(--ovk-muted); }
++.ex-preset p { margin: 0; font-size: .85rem; line-height: 1.45; color: var(--ovk-muted); }
++
++.ex-try { max-width: 780px; margin: 0; }
++.ex-try textarea, .ovt-settings-body textarea, .ovt-settings-body input[type="number"] {
++ width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 10px;
++ border: 1px solid var(--ovk-line); background: var(--ovk-input, var(--ovk-panel));
++ color: var(--ovk-text); font: inherit; line-height: 1.5;
++}
++.ex-try-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-top: 10px; }
++.ex-label { display: block; margin: 12px 0 6px; font-weight: 600; font-size: .9rem; }
++.ex-signin { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
++.ex-answer { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
++
++/* ── Sell ──────────────────────────────────────────────── */
++.ex-seller {
++ display: flex; align-items: center; gap: 12px; margin-bottom: 12px; padding: 14px;
++ border: 1px solid var(--ovk-line); border-radius: 16px; background: var(--ovk-bg);
++}
++.ex-seller.is-example { border-style: dashed; }
++.ex-seller b { display: block; font-size: .95rem; }
++.ex-seller p { margin: 2px 0 0; font-size: .84rem; color: var(--ovk-muted); line-height: 1.4; }
++.ex-seller > div { flex: 1; min-width: 0; }
++.ex-badge-note { font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--ovk-muted); border: 1px solid var(--ovk-line); border-radius: 999px; padding: 3px 8px; }
++.ex-steps ol { margin: 0 0 14px; padding-left: 1.2em; line-height: 1.8; }
++
++/* ── Status ────────────────────────────────────────────── */
++.ex-changelog { list-style: none; margin: 0; padding: 0; }
++.ex-changelog li { display: flex; gap: 10px; padding: 8px 0; border-top: 1px solid var(--ovk-line); font-size: .9rem; }
++.ex-changelog li:first-child { border-top: 0; }
++.ex-changelog time { flex: none; color: var(--ovk-muted); font-variant-numeric: tabular-nums; }
++
++/* ── Chat ──────────────────────────────────────────────── */
++body.ex-app .ovf { display: none; }
++.ex-chat .ovt-settings { margin: 0 0 8px; }
++.ex-chat .ovt-settings summary { cursor: pointer; font-size: .85rem; color: var(--ovk-muted); }
++.ex-chat .ovt-settings-body { padding: 8px 0 4px; }
++.ex-chat .ovt-composer { max-width: 100%; }
++
++/* One round: the answers side by side on a computer, stacked on a phone. */
++.ex-round { max-width: 1100px; margin: 0 auto; width: 100%; }
++.ex-answer-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
++@media (min-width: 1024px) {
++ .ex-answer-grid[data-count="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++ .ex-answer-grid[data-count="3"], .ex-answer-grid[data-count="4"], .ex-answer-grid[data-count="5"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
++}
++.ex-answer-col { min-width: 0; border: 1px solid var(--ovk-line); border-radius: 16px; background: var(--ovk-panel); padding: 12px 14px; }
++.ex-answer-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
++.ex-answer-head b { font-size: .9rem; }
++.ex-answer-cost { margin-left: auto; font-size: .74rem; color: var(--ovk-muted); font-variant-numeric: tabular-nums; text-align: right; }
++.ex-answer-col .ovt-answer { font-size: .94rem; }
++.ex-answer-col .ovt-meta { display: none; }
++.ex-receipt { margin-top: 8px; border-top: 1px solid var(--ovk-line); padding-top: 6px; }
++.ex-receipt summary { cursor: pointer; font-size: .78rem; color: var(--ovk-muted); }
++.ex-receipt dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; margin: 8px 0 0; font-size: .78rem; }
++.ex-receipt dt { color: var(--ovk-muted); }
++.ex-receipt dd { margin: 0; overflow-wrap: anywhere; }
++
++/* ── Uncensored ────────────────────────────────────────── */
++.ex-floor-box {
++ border: 1px solid color-mix(in srgb, var(--ovk-bad) 45%, var(--ovk-line));
++ border-radius: 14px; padding: 14px 16px;
++ background: color-mix(in srgb, var(--ovk-bad) 8%, transparent);
++}
++.ex-floor-box p:last-child { margin-bottom: 0; }
++.ex-confirm { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; margin-top: 12px; }
++.ex-check { display: inline-flex; gap: 8px; align-items: center; }
++
++/* ── Tables on a phone ─────────────────────────────────── */
++.ex .ovk-table td small { color: var(--ovk-muted); }
++
++@media (max-width: 600px) {
++ .ex-hero { padding-top: 22px; }
++ .ex-hero-actions .ovk-btn { flex: 1 1 auto; justify-content: center; }
}
ddiff --git a/scripts/seed-first-party.js b/scripts/seed-first-party.js
ddeleted file mode 100644
iindex c6ee47a..0000000
---- a/scripts/seed-first-party.js
++++ /dev/null
@@@ -1,44 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--
--/**
-- * Seed or refresh the first-party catalogue now (the service also runs it at boot and every
-- * EXCHANGE_FIRST_PARTY_INTERVAL_MS):
-- *
-- * node scripts/seed-first-party.js [design,obs,games]
-- *
-- * It uses the same store functions and the same sources as the job, prints counts only, and never creates a person's
-- * listing. Re-running is safe: source/source_id is the idempotency key.
-- */
--const { load } = require('../server/config');
--const { openStore } = require('../server/db');
--const { createStore } = require('../server/store');
--const { createSearchIndex } = require('../server/search');
--const { createFirstParty, SOURCES } = require('../server/jobs/first-party');
--
--(async () => {
-- const config = load(process.env);
-- if (config.drill) throw new Error('refusing to seed under EXCHANGE_DRILL');
-- const s = await openStore(config);
-- try {
-- const search = createSearchIndex({ config, s });
-- const entries = createStore(s, { search, config });
-- const job = createFirstParty({ config, s, entries, now: s.now });
-- const asked = process.argv.slice(2).flatMap((x) => x.split(',')).filter(Boolean);
-- const sources = asked.length ? asked : SOURCES;
-- const out = await job.runOnce({ sources });
-- const lines = [];
-- for (const [source, result] of Object.entries(out.sources || {})) {
-- if (result && result.themes) {
-- lines.push(`[seed] ${source}.themes: ${result.themes.ok ? 'ok' : 'failed'} imported=${result.themes.imported || 0} changed=${result.themes.changed || 0} retired=${result.themes.retired || 0} removed_token_listings=${result.removed_tokens || 0}`);
-- } else {
-- lines.push(`[seed] ${source}: ${result && result.ok ? 'ok' : 'failed'} imported=${(result && result.imported) || 0} changed=${(result && result.changed) || 0} retired=${(result && result.retired) || 0}${result && result.error ? ` error=${result.error}` : ''}`);
-- }
-- }
-- for (const line of lines) console.log(line);
-- const ok = Object.values(out.sources || {}).every((r) => !r || r.ok !== false) && Object.values(out.sources || {}).every((r) => !r || r.themes === undefined || r.themes.ok);
-- process.exitCode = ok ? 0 : 1;
-- } finally {
-- await s.close();
-- }
--})().catch((err) => { console.error(`[seed] ${err && err.message ? err.message : err}`); process.exitCode = 1; });
ddiff --git a/scripts/seed-proof.js b/scripts/seed-proof.js
ddeleted file mode 100644
iindex a26dc6b..0000000
---- a/scripts/seed-proof.js
++++ /dev/null
@@@ -1,92 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--
--/**
-- * The first proof (plan §5.11): a licensed template listed, tried in the sandbox, installed with its passport's
-- * grants, then revoked cleanly.
-- *
-- * node scripts/seed-proof.js
-- *
-- * It writes one CC0-1.0 Website template as a draft, runs the four conformance tests, lists it, takes it through a
-- * sandbox trial as a second person, installs it with the passport snapshot and revokes that install. Every step
-- * goes through the same store functions the API and the pages use; nothing is inserted behind their back. Re-running
-- * is safe: an existing `starter-site` listing is left as it is.
-- */
--const { ids } = require('openvibe-contracts');
--const { load } = require('../server/config');
--const { openStore } = require('../server/db');
--const { createStore, slugify } = require('../server/store');
--const { createSearchIndex } = require('../server/search');
--
--const TITLE = 'Starter site template';
--const SLUG = slugify(TITLE);
--const LICENSE = 'CC0-1.0';
--
--const BODY = Object.freeze({
-- title: TITLE,
-- kind: 'template',
-- mode: 'free',
-- summary: 'A licensed, static starter site: one page, one stylesheet, no tracking and no build step.',
-- description: 'A Website template for a maker\'s first page. Copy it into a Space, change the words, publish. The manifest names a static entry point and the passport asks only to read Search and write Space files; it declares no spend, because v1 has no money.',
-- license: LICENSE,
-- license_url: 'https://creativecommons.org/publicdomain/zero/1.0/',
-- version: '1.0.0',
-- manifest: { name: 'Starter site', entry: 'templates/starter/index.html', runtime: 'static' },
-- passport: { reads: ['search.read'], writes: ['space.files.write'], spends: [], contacts: ['https://openvibe.space'] },
--});
--
--async function runSeed({ config, s, log = console } = {}) {
-- if (!config) config = load();
-- if (!s) s = await openStore(config, { log });
-- const search = createSearchIndex({ config, s });
-- const entries = createStore(s, { search, config });
--
-- const existing = await entries.bySlug(SLUG);
-- if (existing) {
-- log.log(`[seed-proof] ${SLUG} already exists (${existing.id}, ${existing.status}) — left as it is`);
-- return { created: false, listing: entries.wireListing(existing), trial: null, install: null, revoked: null };
-- }
--
-- const maker = ids.newId('user');
-- const installer = ids.newId('user');
--
-- const draft = await entries.createListing({ owner: maker, creator: 'OpenVibe', body: BODY });
-- if (!draft.ok) throw new Error(`listing was refused: ${draft.message}`);
-- const listed = await entries.publish(draft.listing.slug, { owner: maker });
-- if (!listed.ok) throw new Error(`listing was not published: ${listed.message}`);
--
-- const sandbox = await entries.sandbox(listed.listing.slug, { subject: installer });
-- if (!sandbox.ok) throw new Error(`sandbox was refused: ${sandbox.message}`);
-- if (sandbox.trial.status !== 'passed') throw new Error(`sandbox trial did not pass: ${JSON.stringify(sandbox.checks)}`);
--
-- const installed = await entries.install(listed.listing.slug, { subject: installer });
-- if (!installed.ok) throw new Error(`install was refused: ${installed.message}`);
--
-- const revoked = await entries.revoke(installed.install.id, { subject: installer });
-- if (!revoked.ok) throw new Error(`revoke was refused: ${revoked.message}`);
--
-- log.log(`[seed-proof] listed ${listed.listing.id} /listings/${listed.listing.slug} (${LICENSE}, free, version ${listed.listing.version})`);
-- log.log(`[seed-proof] sandbox ${sandbox.trial.id}: ${sandbox.trial.status}; passport reads ${sandbox.trial.passport.reads.join(', ') || 'nothing'}, writes ${sandbox.trial.passport.writes.join(', ') || 'nothing'}, spends nothing`);
-- log.log(`[seed-proof] installed ${installed.install.id} and revoked it (${revoked.install.status})`);
-- return { created: true, listing: listed.listing, trial: sandbox.trial, install: installed.install, revoked: revoked.install };
--}
--
--async function main() {
-- const config = load();
-- const s = await openStore(config, { log: console });
-- try {
-- const out = await runSeed({ config, s });
-- process.exitCode = out && out.listing ? 0 : 1;
-- } finally {
-- await s.close();
-- }
--}
--
--if (require.main === module) {
-- main().catch((err) => {
-- console.error(`[seed-proof] ${err && err.message ? err.message : err}`);
-- process.exitCode = 1;
-- });
--}
--
--module.exports = { runSeed, SLUG, TITLE, LICENSE, BODY };
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
iindex 611fb26..184cb31 100644
---- a/server/agent-descriptor.js
++++ b/server/agent-descriptor.js
@@@ -1,18 +1,16 @@
'use strict';
// What openvibe.exchange tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
--// Every field is something this site really serves: the JSON API under /api/v1, the developers page that
--// documents it, and the app's own per-address limits. No OpenAPI document or MCP endpoint is published.
++// Every field is something this site really serves: the pages, the public API host, and its own limits.
module.exports = {
id: 'exchange',
name: 'OpenVibe.Exchange',
-- purpose: 'Trade what people make: free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.',
++ purpose: 'Every AI model, one account: live prices, speed and uptime for DeepSeek, Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds; chat with one model or three side by side; one OpenAI- and Anthropic-compatible endpoint.',
home: 'https://openvibe.exchange',
site: 'openvibe.exchange',
-- api: '/api/v1',
-- docs: '/developers',
-- openapi: '/openapi.json',
++ api: 'https://api.openvibe.exchange/v1',
++ docs: 'https://openvibe.exchange/api',
policy: {
auth: ['none', 'Network OIDC'],
-- rateLimit: '300 page requests a minute per address; 240 API requests a minute; sign-in limited to 60 attempts per 15 minutes',
++ rateLimit: '300 page requests a minute per address; sign-in limited to 60 attempts per 15 minutes',
},
};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex 215259d..6f1ba8e 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -1,63 +1,23 @@
'use strict';
--// The public pages the agent layer serves as Markdown twins (openvibe-shared/agent-layer). Each twin is the
--// page's own render through the same views and layout the HTML routes use, always signed out: a listing's
--// owner/staff actions, installs and offers never appear in a twin, and an unlisted listing has no twin at all.
--const seo = require('openvibe-shared/seo');
--const views = require('./views');
++// The public pages the agent layer serves as Markdown twins (openvibe-shared/agent-layer). Each twin is the page's
++// own render through the same views and layout the HTML routes use, always signed out: a chat, a try box and the
++// 18+ confirmation never appear in a twin.
++const views = require('./views/exchange');
const { raw } = require('./render/html');
const { renderPage } = require('./render/layout');
const { homeJsonLd } = require('./http/discovery');
--const HOME_DESCRIPTION = 'OpenVibe.Exchange: trade what people make. Free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.';
--const LISTINGS_DESCRIPTION = 'Every public free and swap listing on OpenVibe.Exchange: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport.';
++const HOME_DESCRIPTION = 'OpenVibe.Exchange: every AI model, one account. Live prices, speed and uptime for DeepSeek, Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds; chat with one model or three side by side; one OpenAI- and Anthropic-compatible endpoint.';
function createAgentPages(ctx) {
-- const { config, entries } = ctx;
-- const base = () => String(config.baseUrl).replace(/\/+$/, '');
++ const { config, ai } = ctx;
const page = (path, o) => ({ html: renderPage({ viewer: { kind: 'anonymous' }, config, path, agentLayer: true, ...o }), title: o.title });
async function home(path) {
-- const [list, counts] = await Promise.all([entries.list({ limit: 8 }), entries.counts()]);
++ const [catalog, usage] = await Promise.all([ai.catalog(), ai.usage(30)]);
return page(path, {
-- index: true, title: null, description: HOME_DESCRIPTION,
-- jsonLd: homeJsonLd(config),
-- body: raw(views.home({ listings: list.listings, counts })),
-- });
-- }
--
-- async function listings(path, query) {
-- const mode = typeof query.get('mode') === 'string' && ['free', 'swap'].includes(query.get('mode')) ? query.get('mode') : null;
-- const kind = entries.KINDS.includes(query.get('kind')) ? query.get('kind') : null;
-- const q = query.get('q') ? query.get('q').slice(0, 120) : '';
-- const cursor = query.get('cursor') ? query.get('cursor') : null;
-- const out = await entries.list({ limit: 24, mode, kind, q, cursor });
-- return page(path, {
-- index: true, title: 'All listings', description: LISTINGS_DESCRIPTION,
-- body: raw(views.listingsPage({ listings: out.listings || [], nextCursor: out.next_cursor || null, filters: { q, mode: mode || '', kind: kind || '' } })),
-- });
-- }
--
-- async function listing(path, slug) {
-- const detail = await entries.detail(String(slug || ''));
-- if (!detail || detail.row.status !== 'listed') return null;
-- const row = detail.row;
-- const listing = detail.listing;
-- const jsonLd = seo.jsonLd.webPage({
-- name: listing.title,
-- url: `${base()}/listings/${listing.slug}`,
-- description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),
-- siteUrl: base(),
-- });
-- return page(path, {
-- index: true,
-- title: listing.title,
-- description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),
-- jsonLd: [jsonLd],
-- body: raw(views.listingPage({
-- listing, tests: listing.tests && listing.tests.rows ? listing.tests.rows : [],
-- mine: false, staff: false, signedIn: false, activeInstall: null, trialPassed: false,
-- offers: [], received: false, problem: null, notice: null,
-- })),
++ index: true, title: null, description: HOME_DESCRIPTION, jsonLd: homeJsonLd(config),
++ body: raw(views.home({ catalog, usage, viewer: { kind: 'anonymous' } })),
});
}
@@@ -65,22 +25,36 @@ function createAgentPages(ctx) {
const p = String(rawPath || '/').replace(/\/+$/, '') || '/';
const q = query instanceof URLSearchParams ? query : new URLSearchParams();
if (p === '/') return home('/');
-- if (p === '/listings') return listings('/listings', q);
-- const m = p.match(/^\/listings\/([A-Za-z0-9._~-]{1,80})$/);
-- if (m) return listing(p, m[1]);
-- if (p === '/safety') {
-- return page(p, {
-- index: true, title: 'Free and swap safety',
-- description: 'OpenVibe.Exchange v1 has no money: free and swap only. Read the passport, meet safely, and revoke any time.',
-- body: raw(views.safetyPage()),
-- });
++ if (p === '/models') {
++ const catalog = await ai.catalog();
++ return page(p, { index: true, title: 'Models', description: 'Every model with its live offers: price, speed, uptime, context and data policy.', body: raw(views.modelsPage({ catalog, q: q.get('q') || '', family: q.get('family') || '', sort: q.get('sort') || '' })) });
++ }
++ const m = p.match(/^\/m\/([A-Za-z0-9._~-]{1,80})\/([A-Za-z0-9._~:-]{1,120})$/);
++ if (m) {
++ const catalog = await ai.catalog();
++ const model = (catalog.models || []).find((x) => x.slug === `${m[1].toLowerCase()}/${m[2].toLowerCase()}`);
++ if (!model) return null;
++ return page(p, { index: true, title: model.name, description: `${model.name}: price, speed, uptime and data policy of every offer.`, body: raw(views.modelPage({ model, viewer: { kind: 'anonymous' }, adult: false })) });
++ }
++ if (p === '/api') {
++ const [catalog, usage] = await Promise.all([ai.catalog(), ai.usage(30)]);
++ return page(p, { index: true, title: 'The API', description: 'One OpenAI- and Anthropic-compatible endpoint for every model.', body: raw(views.apiPage({ catalog, usage })) });
++ }
++ if (p === '/sell') {
++ const [catalog, usage] = await Promise.all([ai.catalog(), ai.usage(30)]);
++ return page(p, { index: true, title: 'Sell your compute', description: 'Turn a GPU or a spare machine into credits with OpenVibe Node.', body: raw(views.sellPage({ catalog, usage })) });
++ }
++ if (p === '/uncensored') {
++ const catalog = await ai.catalog();
++ return page(p, { index: true, title: 'Uncensored', description: 'Abliterated models for adults, with the floor.', body: raw(views.uncensoredPage({ catalog, adult: false, signedIn: false })) });
++ }
++ if (p === '/ai') {
++ const [catalog, usage] = await Promise.all([ai.catalog(), ai.usage(30)]);
++ return page(p, { index: true, title: 'AI in OpenVibe', description: 'Which OpenVibe products use which models, and what each costs a day.', body: raw(views.aiPage({ catalog, usage })) });
}
-- if (p === '/developers') {
-- return page(p, {
-- index: true, title: 'Build on OpenVibe.Exchange',
-- description: 'The OpenVibe.Exchange JSON API, the permission passport, the sandbox, installs and the Search document.',
-- body: raw(views.developersPage()),
-- });
++ if (p === '/status') {
++ const [catalog, usage] = await Promise.all([ai.catalog(), ai.usage(30)]);
++ return page(p, { index: true, title: 'Status', description: 'Uptime per offer, the model changelog, and incidents.', body: raw(views.statusPage({ catalog, usage })) });
}
return null;
}
ddiff --git a/server/ai.js b/server/ai.js
nnew file mode 100644
iindex 0000000..8ca6449
---- /dev/null
++++ b/server/ai.js
@@@ -0,0 +1,149 @@
++'use strict';
++/**
++ * OpenVibe.AI, read from OpenVibe.Exchange.
++ *
++ * catalog() GET /api/v1/exchange/catalog the models, offers, prices, speed and uptime (public)
++ * usage(days) GET /api/v1/exchange/usage requests, tokens and spend, by day, model and product (public)
++ * chat({...}) POST /api/v1/runs one ai.complete run, on behalf of the person, with this
++ * service's token (ai.run.create, ns ai.complete)
++ *
++ * The catalog and usage reads are cached in this process, so a page view does not cost AI a request. When
++ * AI cannot be reached the reads answer an empty catalog carrying `unavailable: true`: the pages say the
++ * numbers are not available instead of showing stale ones as live.
++ */
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++const CATALOG_MS = 30_000;
++const USAGE_MS = 60_000;
++
++async function jsonOf(res) {
++ const text = await res.text();
++ try { return text ? JSON.parse(text) : null; } catch (e) { return null; }
++}
++
++function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
++ const tokens = config.oauth.clientSecret ? createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: config.ai.audience, scope: config.ai.scope, fetch: fetchImpl,
++ }) : null;
++
++ const empty = (what) => ({ generated_at: new Date(now()).toISOString(), unavailable: true, count: 0, models: [], families: [], by_day: [], by_model: [], by_product: [], today: null, totals: null, what });
++ let catalogHit = { at: 0, value: null };
++ let usageHit = new Map();
++ let warnedAt = 0;
++
++ const warned = (what, err) => {
++ if (now() - warnedAt < 60_000) return;
++ warnedAt = now();
++ log.warn(`[exchange] AI ${what} unavailable: ${String((err && err.message) || err).slice(0, 200)}`);
++ };
++
++ async function get(p, what) {
++ const res = await fetchImpl(`${config.ai.url}${p}`, {
++ headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(config.ai.timeoutMs),
++ });
++ const body = await jsonOf(res);
++ if (!res.ok || !body) throw new Error(`${what}: HTTP ${res.status}`);
++ return body;
++ }
++
++ async function catalog() {
++ if (catalogHit.value && now() - catalogHit.at < CATALOG_MS) return catalogHit.value;
++ try {
++ const body = await get('/api/v1/exchange/catalog', 'catalog');
++ catalogHit = { at: now(), value: body };
++ return body;
++ } catch (err) {
++ warned('catalog', err);
++ // Keep the last good catalog for a while rather than turning every page empty on one failed poll.
++ if (catalogHit.value && now() - catalogHit.at < 5 * 60_000) return { ...catalogHit.value, stale: true };
++ return empty('catalog');
++ }
++ }
++
++ async function usage(days = 30) {
++ const key = String(days);
++ const hit = usageHit.get(key);
++ if (hit && now() - hit.at < USAGE_MS) return hit.value;
++ try {
++ const body = await get(`/api/v1/exchange/usage?days=${encodeURIComponent(days)}`, 'usage');
++ usageHit.set(key, { at: now(), value: body });
++ if (usageHit.size > 8) usageHit.delete(usageHit.keys().next().value);
++ return body;
++ } catch (err) {
++ warned('usage', err);
++ if (hit && now() - hit.at < 5 * 60_000) return { ...hit.value, stale: true };
++ return empty('usage');
++ }
++ }
++
++ /** One completion, as the person: the run's quota and free allowance are theirs (on_behalf_of). */
++ async function chat({ model, messages, temperature = null, maxTokens = 1024, subject, signal = null }) {
++ if (!tokens) throw Object.assign(new Error('AI is not configured for this service'), { code: 'ai.not_configured', status: 503 });
++ const token = await tokens.getToken();
++ const body = {
++ workflow: 'ai.complete',
++ input: {
++ messages,
++ ...(model ? { model: String(model).slice(0, 256) } : {}),
++ ...(temperature != null ? { temperature: Number(temperature) } : {}),
++ ...(maxTokens ? { max_tokens: Math.max(1, Math.min(16000, Number(maxTokens) || 1024)) } : {}),
++ },
++ ...(subject ? { on_behalf_of: { type: 'user', id: String(subject) } } : {}),
++ options: { cache: 'none' },
++ };
++ const res = await fetchImpl(`${config.ai.url}/api/v1/runs?wait=${config.ai.waitMs}`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` },
++ body: JSON.stringify(body),
++ signal: signal || AbortSignal.timeout(config.ai.timeoutMs),
++ });
++ const out = await jsonOf(res);
++ const run = out && out.run ? out.run : null;
++ if (!res.ok || !run) {
++ const err = new Error((out && (out.detail || out.title)) || `AI answered ${res.status}`);
++ err.code = (out && out.code) || 'ai.failed';
++ err.status = res.status;
++ throw err;
++ }
++ if (run.status === 'failed' || run.status === 'cancelled') {
++ const err = new Error((run.error && (run.error.detail || run.error.code)) || `the run ${run.status}`);
++ err.code = (run.error && run.error.code) || 'run.failed';
++ err.status = 502;
++ throw err;
++ }
++ if (!['succeeded', 'cached'].includes(run.status)) {
++ const err = new Error('the model is still answering; open the chat again in a moment');
++ err.code = 'run.pending';
++ err.status = 504;
++ throw err;
++ }
++ const choice = run.output && Array.isArray(run.output.choices) ? run.output.choices[0] : null;
++ const content = choice && choice.message && typeof choice.message.content === 'string' ? choice.message.content : '';
++ const usage = run.usage || null;
++ const started = run.started_at ? Date.parse(run.started_at) : NaN;
++ const finished = run.finished_at ? Date.parse(run.finished_at) : NaN;
++ const explain = run.explain || null;
++ const why = explain && typeof explain === 'object'
++ ? (Array.isArray(explain.reasons) && explain.reasons.length ? explain.reasons.slice(0, 4).map(String)
++ : (explain.selected || explain.policy || explain.objective || null))
++ : null;
++ return {
++ content,
++ model: (run.output && run.output.model) || (run.provenance && run.provenance.model) || null,
++ provider: (run.provenance && run.provenance.provider) || null,
++ run_id: run.id,
++ cost_usd: usage ? usage.cost_usd : null,
++ usage,
++ tokens_in: usage ? usage.tokens_in || 0 : 0,
++ tokens_out: usage ? usage.tokens_out || 0 : 0,
++ latency_ms: Number.isFinite(started) && Number.isFinite(finished) && finished >= started ? finished - started : null,
++ why: why == null ? null : (Array.isArray(why) ? why : [String(why)]),
++ };
++ }
++
++ return { catalog, usage, chat, configured: Boolean(tokens) };
++}
++
++module.exports = { createAi };
ddiff --git a/server/app.js b/server/app.js
iindex bac4841..38a1ca1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -4,16 +4,15 @@
* OpenVibe.Exchange — Express app factory; server/index.js listens, tests build their own instance with a temp
* database and a mock Network.
*
-- * /, /listings, /listings/:slug, /submit, /sandbox/:id, /installs, /offers, /safety, /developers
-- * the pages (http/pages.js)
-- * /api/v1/* the API (http/api.js)
-- * /auth/* Network SSO with PKCE (auth/sso.js)
-- * /api/health, /api/ready, /release.json, /metrics (loopback only)
-- * /internal/events account export/deletion (loopback only)
++ * /, /models, /m/:org/:model, /chat, /api, /sell, /uncensored, /ai the pages (http/pages.js)
++ * /api/v1/* 410: Exchange v1 is retired
++ * /auth/* Network SSO with PKCE (auth/sso.js)
++ * /api/health, /api/ready, /release.json, /metrics (loopback only)
++ * /internal/events account export/deletion (loopback only)
*
-- * Sandbox trials run on request and expire by timestamp; the Search relay is not wired. The only background job is
-- * the first-party catalogue refresh (server/jobs/first-party.js), started by server/index.js and off under
-- * EXCHANGE_DRILL.
++ * The site is a read of OpenVibe.AI (server/ai.js): the catalog, the usage, and the chat runs it makes for a
++ * person. Exchange keeps two things of its own: a person's chats and their 18+ confirmation. Nothing is
++ * written to Exchange v1's tables any more: they stay as they are, read-only.
*/
const path = require('path');
const express = require('express');
@@@ -28,17 +27,15 @@ const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
--const { createApi } = require('./http/api');
++const { createAi } = require('./ai');
++const { createOpenApi } = require('./openapi');
++const { createChatStore, createAdultStore } = require('./chat/store');
const { createPageRoutes } = require('./http/pages');
const { createServiceReadiness } = require('./observability');
const { assetVersion, send } = require('./render/layout');
const { raw } = require('./render/html');
const accountDataLib = require('./identity/account-data');
--const { createStore } = require('./store');
--const { createSearchIndex } = require('./search');
--const { createSearchRelay } = require('openvibe-publishing/search-relay');
--const { createFirstParty } = require('./jobs/first-party');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@@ -47,8 +44,8 @@ const { createHttpTelemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');
/**
-- * opts: config, store, now (clock), fetchImpl (Network), log, valkey, accountSend (a stand-in for Network's internal
-- * routes; tests only), telemetrySink.
++ * opts: config, store, now (clock), fetchImpl (Network), ai (a stand-in client) / aiFetchImpl, log, valkey,
++ * accountSend (a stand-in for Network's internal routes; tests only), telemetrySink.
*/
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
@@@ -60,18 +57,9 @@ async function createApp(opts = {}) {
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
-- // The queued Search documents (exchange_outbox) go to OpenVibe.Search's owner API with this service's token;
-- // off without EXCHANGE_SEARCH_URL or the client secret. Started by server/index.js, never under EXCHANGE_DRILL.
-- const searchRelay = createSearchRelay({
-- db: s.db, table: 'exchange_outbox', owner: 'exchange', searchUrl: config.search.url, fetchImpl, now: s.now, log,
-- auth: config.search.url && config.oauth.clientSecret ? contracts.serviceAuth.createTokenClient({
-- tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
-- audience: 'openvibe.search', scope: 'search.document.write', fetchImpl,
-- }) : null,
-- });
-- const search = createSearchIndex({ config, s, onQueued: () => searchRelay.kick() });
-- const entries = createStore(s, { search, config });
-- const firstParty = createFirstParty({ config, s, entries, fetchImpl, log, now: s.now });
++ const ai = opts.ai || createAi({ config, fetchImpl: opts.aiFetchImpl || globalThis.fetch, log, now: s.now });
++ const chats = createChatStore(s);
++ const adult = createAdultStore(s);
// Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
// client-credentials token; a test injects a stand-in through opts.accountSend.
@@@ -80,7 +68,8 @@ async function createApp(opts = {}) {
? 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: Exchange cannot answer account events'); });
-- const ctx = { config, s, keys, sso, principal, search, searchRelay, entries, firstParty, accountData, accountSend, log };
++ const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });
++ const ctx = { config, s, keys, sso, principal, ai, openapi, chats, adult, accountData, accountSend, log };
const app = express();
app.disable('x-powered-by');
@@@ -105,7 +94,7 @@ async function createApp(opts = {}) {
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
-- connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://ai.openvibe.services'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
@@@ -123,7 +112,7 @@ async function createApp(opts = {}) {
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-exchange', name: 'OpenVibe.Exchange', version: VERSION }));
release.mount(app, { registry: metrics.registry });
-- const readiness = createServiceReadiness({ s, config, release: release.release, valkey, sso});
++ const readiness = createServiceReadiness({ s, config, release: release.release, valkey, sso });
app.get('/api/ready', readiness.handler);
// ── Who is asking (verified offline; refreshed when expired) ──
@@@ -155,22 +144,19 @@ async function createApp(opts = {}) {
},
}));
-- // ── API ─────────────────────────────────────────────────
-- app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.EXCHANGE_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
-- app.use('/api/v1', createApi(ctx));
-- // GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from
-- // the routes this app mounts under /api/, with the summaries the API files' header comments give them
-- // (server/http/openapi-routes.js).
-- {
-- const d = require('./agent-descriptor');
-- const site = String(config.baseUrl).replace(/\/+$/, '');
-- app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {
-- title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,
-- docs: `${site}/developers`,
-- files: ['http/api.js'].map((f) => require('path').join(__dirname, f)),
-- }));
-- }
-- app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
++ // ── Exchange v1's JSON API: gone ────────────────────────
++ app.use('/api/v1', (req, res) => {
++ res.set('Cache-Control', 'public, max-age=86400');
++ return contracts.http.sendProblem(res, 410, 'exchange.retired', {
++ detail: 'Exchange v1 is retired. Its API was free and swap listings; the new Exchange is the network\'s AI: every model, one account. The public API is https://api.openvibe.exchange/v1 and the site is /api.',
++ ctx: req.ov,
++ });
++ });
++ app.use('/api', (req, res, next) => {
++ // GET /api is the site's API page (http/pages.js), not a route of this catch-all.
++ if (req.path === '/' && (req.method === 'GET' || req.method === 'HEAD')) return next();
++ return contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov });
++ });
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.EXCHANGE_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
@@@ -191,10 +177,6 @@ async function createApp(opts = {}) {
app.use(agent);
}
app.use(createPageRoutes(ctx));
-- app.use((req, res) => send(res, 404, {
-- viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
-- body: raw(require('./views').notFound()),
-- }));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
ddiff --git a/server/chat/store.js b/server/chat/store.js
nnew file mode 100644
iindex 0000000..e0b34b0
---- /dev/null
++++ b/server/chat/store.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * A person's chats and their 18+ confirmation.
++ *
++ * Every read and write is scoped by the owner in the WHERE clause, not checked after the fact: a chat
++ * id from another account finds nothing. Messages hang off their chat (ON DELETE CASCADE) and carry the
++ * owner too, so an export or a deletion is one column and never a join.
++ */
++function createChatStore(s) {
++ const db = s.db;
++
++ async function create({ owner, models = [], title = 'New chat' }) {
++ const id = s.newId('exc');
++ await db.prepare(`INSERT INTO exchange_chats (id, owner, title, models) VALUES (?, ?, ?, ?)`)
++ .run(id, owner, String(title).slice(0, 120) || 'New chat', JSON.stringify(models.slice(0, 3)));
++ return get(owner, id);
++ }
++
++ async function get(owner, id) {
++ const row = await db.prepare('SELECT * FROM exchange_chats WHERE id = ? AND owner = ?').get(String(id || ''), owner);
++ return row ? { ...row, models: parseModels(row.models) } : null;
++ }
++
++ async function list(owner, limit = 60) {
++ const rows = await db.prepare('SELECT id, title, models, updated_at FROM exchange_chats WHERE owner = ? ORDER BY updated_at DESC LIMIT ?')
++ .all(owner, Math.max(1, Math.min(200, limit)));
++ return rows.map((r) => ({ ...r, models: parseModels(r.models) }));
++ }
++
++ async function messages(owner, chatId) {
++ const rows = await db.prepare('SELECT * FROM exchange_chat_messages WHERE chat_id = ? AND owner = ? ORDER BY created_at, id')
++ .all(String(chatId || ''), owner);
++ return rows.map((r) => ({ ...r, meta: parseJson(r.meta) }));
++ }
++
++ async function append(owner, chatId, { role, model = null, content = null, error = null, meta = {} }) {
++ const id = s.newId('exm');
++ await db.prepare(`INSERT INTO exchange_chat_messages (id, chat_id, owner, role, model, content, error, meta)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(id, chatId, owner, role, model, content == null ? null : String(content), error == null ? null : String(error).slice(0, 2000), JSON.stringify(meta || {}));
++ await db.prepare('UPDATE exchange_chats SET updated_at = now() WHERE id = ? AND owner = ?').run(chatId, owner);
++ return id;
++ }
++
++ async function rename(owner, chatId, title) {
++ await db.prepare('UPDATE exchange_chats SET title = ?, updated_at = now() WHERE id = ? AND owner = ?')
++ .run(String(title).slice(0, 120), chatId, owner);
++ }
++
++ async function settings(owner, chatId, { models, systemPrompt, temperature }) {
++ await db.prepare(`UPDATE exchange_chats SET models = ?, system_prompt = ?, temperature = ?, updated_at = now()
++ WHERE id = ? AND owner = ?`)
++ .run(JSON.stringify((models || []).slice(0, 3)), systemPrompt == null ? null : String(systemPrompt).slice(0, 4000),
++ temperature == null ? null : Number(temperature), chatId, owner);
++ }
++
++ async function clear(owner, chatId) {
++ await db.prepare('DELETE FROM exchange_chat_messages WHERE chat_id = ? AND owner = ?').run(chatId, owner);
++ await db.prepare('UPDATE exchange_chats SET updated_at = now() WHERE id = ? AND owner = ?').run(chatId, owner);
++ }
++
++ async function remove(owner, chatId) {
++ await db.prepare('DELETE FROM exchange_chats WHERE id = ? AND owner = ?').run(chatId, owner);
++ }
++
++ /** Assistant turns answered today by this person: the daily allowance's counter. */
++ async function countToday(owner) {
++ const row = await db.prepare(`SELECT COUNT(*) AS n FROM exchange_chat_messages
++ WHERE owner = ? AND role = 'assistant' AND created_at >= date_trunc('day', now())`).get(owner);
++ return Number(row && row.n) || 0;
++ }
++
++ return { create, get, list, messages, append, rename, settings, clear, remove, countToday };
++}
++
++function createAdultStore(s) {
++ const db = s.db;
++ async function confirmed(subject) {
++ if (!subject) return false;
++ return Boolean(await db.prepare('SELECT subject FROM exchange_adult_confirmations WHERE subject = ?').get(subject));
++ }
++ async function confirm(subject, version) {
++ await db.prepare(`INSERT INTO exchange_adult_confirmations (subject, version) VALUES (?, ?)
++ ON CONFLICT (subject) DO UPDATE SET version = excluded.version, confirmed_at = now()`).run(subject, version);
++ }
++ async function forget(subject) {
++ await db.prepare('DELETE FROM exchange_adult_confirmations WHERE subject = ?').run(subject);
++ }
++ return { confirmed, confirm, forget };
++}
++
++const parseJson = (v) => { try { return v ? JSON.parse(v) : {}; } catch (e) { return {}; } };
++const parseModels = (v) => { const a = parseJson(v); return Array.isArray(a) ? a.filter((x) => typeof x === 'string') : []; };
++
++module.exports = { createChatStore, createAdultStore };
ddiff --git a/server/config.js b/server/config.js
iindex 9a456d0..8f76787 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -61,26 +61,29 @@ function load(env = process.env) {
scope: env.EXCHANGE_EVENTS_SCOPE || 'events.event.publish',
},
-- // OpenVibe.Search's owner API: the documents queued in exchange_outbox are delivered there (server/app.js,
-- // openvibe-publishing/search-relay) with this service's token (search.document.write). Unset: they wait.
-- search: { url: trim(env.EXCHANGE_SEARCH_URL || '') },
++ // OpenVibe.AI (server/ai.js): the public catalog and usage reads, and the chat runs this service makes
++ // for a person. The token is the service's own client-credentials grant (ai.run.create, ns ai.complete);
++ // the person's Network subject rides on the run as on_behalf_of, so their own allowance and limits apply.
++ ai: {
++ url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),
++ audience: env.OV_AI_AUDIENCE || 'openvibe.ai',
++ scope: env.OV_AI_SCOPE || 'ai.run.create',
++ timeoutMs: Math.max(2000, int(env.EXCHANGE_AI_TIMEOUT_MS, 30_000)),
++ waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
++ },
-- // The sandbox: a manifest dry run, not a code execution. A passed trial is good for this many minutes and
-- // is required before an install; a person gets at most this many trials a day.
-- sandbox: {
-- ttlMinutes: Math.min(240, Math.max(5, int(env.EXCHANGE_SANDBOX_TTL_MINUTES, 60))),
-- perDay: Math.min(100, Math.max(1, int(env.EXCHANGE_SANDBOX_PER_DAY, 20))),
++ api: {
++ publicUrl: trim(env.OV_EXCHANGE_API_URL || 'https://api.openvibe.exchange'),
++ openapiUrl: trim(env.OV_EXCHANGE_OPENAPI_URL || 'https://api.openvibe.exchange/openapi.json'),
++ openapiCacheMs: Math.max(60_000, int(env.EXCHANGE_OPENAPI_CACHE_MS, 600_000)),
++ timeoutMs: Math.max(2000, int(env.EXCHANGE_OPENAPI_TIMEOUT_MS, 15_000)),
},
-- // The first-party catalogue (server/jobs/first-party.js): Design's built-in themes and token files, Live's OBS
-- // scenes and alerts, and Games' public worlds. Off under EXCHANGE_DRILL and when EXCHANGE_FIRST_PARTY=0.
-- firstParty: {
-- enabled: env.EXCHANGE_FIRST_PARTY !== '0' && env.EXCHANGE_FIRST_PARTY !== 'false',
-- intervalMs: Math.max(60_000, int(env.EXCHANGE_FIRST_PARTY_INTERVAL_MS, 6 * 60 * 60_000)),
-- timeoutMs: Math.max(500, int(env.EXCHANGE_FIRST_PARTY_TIMEOUT_MS, 8000)),
-- designUrl: trim(env.OV_DESIGN_URL || 'https://openvibe.design'),
-- liveUrl: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
-- gamesUrl: trim(env.OV_GAMES_URL || 'https://openvibe.games'),
++ // /chat: how many messages a signed-in person may send a day (each one may run up to three models).
++ chat: {
++ perDay: Math.max(1, int(env.EXCHANGE_CHAT_PER_DAY, 40)),
++ models: Math.max(1, Math.min(3, int(env.EXCHANGE_CHAT_MODELS, 3))),
++ maxChars: Math.max(200, int(env.EXCHANGE_CHAT_MAX_CHARS, 8000)),
},
};
}
ddiff --git a/server/http/api.js b/server/http/api.js
ddeleted file mode 100644
iindex 4467557..0000000
---- a/server/http/api.js
++++ /dev/null
@@@ -1,254 +0,0 @@
--'use strict';
--
--/**
-- * /api/v1 — OpenVibe.Exchange's JSON API.
-- *
-- * GET /listings?limit=&cursor=&mode=&kind=&q= public listings, interleaved by kind, newest first inside each kind; cursor-paged
-- * GET /listings/:slug one listing with its passport, tests and licence
-- * GET /listings/:slug/passport the exact passport and manifest an installer accepts
-- * GET /listings/:slug/tests the conformance tests for this version
-- * GET /listings/:slug/offers the open free claims / swap offers (owner or staff)
-- * GET /me/listings the caller's listings, all statuses
-- * POST /listings create a draft (signed in, same-origin)
-- * POST /listings/:slug/list publish after the conformance tests passed (owner)
-- * POST /listings/:slug/close close (given / swapped / withdrawn) (owner)
-- * POST /listings/:slug/sandbox run a manifest dry-run trial (signed in)
-- * GET /sandbox/:id one trial of yours
-- * POST /listings/:slug/install install with the passport snapshot (signed in; trial required)
-- * GET /installs the caller's installations
-- * POST /installs/:id/revoke revoke one installation (signed in)
-- * POST /listings/:slug/offers a free claim or a swap offer (signed in)
-- * GET /offers offers the caller sent and received
-- * POST /offers/:id/accept|decline|withdraw decide an offer
-- * POST /listings/:slug/hide staff: hide a listing
-- *
-- * Errors are application/problem+json with a `code`. A person is the exchange_at session cookie or a Network user
-- * token as a Bearer; a session write must come from openvibe.exchange itself (403 exchange.cross_site). There is no
-- * money: a listing is free or a swap, and a passport may not declare a spend.
-- */
--const express = require('express');
--const { http } = require('openvibe-contracts');
--const { asyncRouter } = require('./router');
--const { sameOrigin } = require('./principal');
--const { isStaff } = require('../identity/staff');
--const { KINDS, MODES } = require('../store');
--
--function createApi(ctx) {
-- const { config, s, principal, entries } = ctx;
-- const r = asyncRouter();
--
-- r.use(express.json({ limit: '64kb' }));
-- r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
-- r.use(principal.middleware);
--
-- // Per-actor limits, on top of the per-address limit in server/app.js: a signed-in person gets the minute and
-- // hour budget from config, and an app or anonymous caller is left to the address limiter.
-- const buckets = new Map();
-- function windowCount(key, windowMs, limit) {
-- const now = Date.now();
-- let b = buckets.get(key);
-- if (!b || now - b.start >= windowMs) { b = { start: now, n: 0 }; buckets.set(key, b); }
-- b.n += 1;
-- return b.n <= limit;
-- }
-- r.use((req, res, next) => {
-- if (req.principal && req.principal.kind === 'user' && req.principal.subject) {
-- const who = `user:${req.principal.subject}`;
-- const okMinute = windowCount(`${who}:m`, 60_000, config.limits.minute);
-- const okHour = windowCount(`${who}:h`, 60 * 60_000, config.limits.hour);
-- if (!okMinute || !okHour) return problem(res, 429, 'exchange.rate_limited', 'too many requests from this account; retry shortly');
-- if (buckets.size > 20_000) {
-- for (const [k, b] of buckets) if (Date.now() - b.start > 2 * 60 * 60_000) buckets.delete(k);
-- }
-- }
-- return next();
-- });
--
-- const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });
-- const invalid = (res, detail) => problem(res, 400, 'exchange.invalid', detail);
--
-- function person(req, res) {
-- const p = req.principal;
-- if (p.kind === 'anonymous') { problem(res, 401, 'exchange.sign_in_required', 'Sign in at openvibe.exchange, or send a person\'s Network token as a Bearer.'); return null; }
-- if (p.kind !== 'user') { problem(res, 403, 'exchange.person_required', 'This route is for a person, not an app.'); return null; }
-- if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {
-- problem(res, 403, 'exchange.cross_site', 'A signed-in request that changes something must come from openvibe.exchange itself.');
-- return null;
-- }
-- return p;
-- }
-- const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);
-- const requireStaff = (req, res, next) => {
-- if (!person(req, res)) return undefined;
-- if (!isStaff(req.principal)) return problem(res, 403, 'exchange.staff_required', 'This route is for OpenVibe staff (Network role global_mod or above).');
-- return next();
-- };
--
-- const creatorOf = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.displayName) || req.principal.username || 'a maker';
--
-- /** A listing the caller may see: public, or their own, or staff. */
-- async function visible(req, res, slug) {
-- const row = await entries.bySlug(String(slug || ''));
-- if (!row) { problem(res, 404, 'exchange.not_found', 'No such listing.'); return null; }
-- const mine = req.principal.kind === 'user' && row.owner === req.principal.subject;
-- const staff = isStaff(req.principal);
-- if (row.status !== 'listed' && !mine && !staff) { problem(res, 404, 'exchange.not_found', 'No such listing.'); return null; }
-- return { row, mine, staff };
-- }
--
-- r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));
--
-- // ── Public reads ────────────────────────────────────────
-- r.get('/listings', async (req, res) => {
-- const limit = req.query.limit == null || req.query.limit === '' ? 20 : Number.parseInt(req.query.limit, 10);
-- if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit must be from 1 to 50');
-- const mode = typeof req.query.mode === 'string' && req.query.mode ? req.query.mode : null;
-- if (mode && !MODES.includes(mode)) return invalid(res, `mode must be one of ${MODES.join(', ')}`);
-- const kind = typeof req.query.kind === 'string' && req.query.kind ? req.query.kind : null;
-- if (kind && !KINDS.includes(kind)) return invalid(res, `kind must be one of ${KINDS.join(', ')}`);
-- const out = await entries.list({
-- limit, mode, kind,
-- q: typeof req.query.q === 'string' ? req.query.q : null,
-- cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,
-- });
-- if (out.error === 'cursor') return invalid(res, 'cursor is not a cursor this API handed out');
-- res.set('Cache-Control', 'public, max-age=60');
-- return res.json({ count: out.listings.length, listings: out.listings, next_cursor: out.next_cursor });
-- });
--
-- r.get('/listings/:slug', async (req, res) => {
-- const found = await visible(req, res, req.params.slug);
-- if (!found) return undefined;
-- const detail = await entries.detail(found.row.slug);
-- if (!detail) return problem(res, 404, 'exchange.not_found', 'No such listing.');
-- const out = { ...detail.listing, mine: found.mine, staff: found.staff };
-- if (!found.mine && !found.staff) delete out.owner;
-- res.set('Cache-Control', found.row.status === 'listed' ? 'public, max-age=60' : 'private, no-store');
-- return res.json(out);
-- });
--
-- r.get('/listings/:slug/passport', async (req, res) => {
-- const found = await visible(req, res, req.params.slug);
-- if (!found) return undefined;
-- const out = {
-- slug: found.row.slug,
-- version: found.row.version,
-- mode: found.row.mode,
-- license: found.row.license,
-- creator: found.row.creator,
-- manifest: entries.wireListing(found.row).manifest,
-- passport: entries.wirePassport(found.row.passport),
-- tests: await entries.testsFor(found.row.id, found.row.version),
-- };
-- res.set('Cache-Control', found.row.status === 'listed' ? 'public, max-age=60' : 'private, no-store');
-- return res.json(out);
-- });
--
-- r.get('/listings/:slug/tests', async (req, res) => {
-- const found = await visible(req, res, req.params.slug);
-- if (!found) return undefined;
-- const rows = await entries.testsFor(found.row.id, found.row.version);
-- res.set('Cache-Control', found.row.status === 'listed' ? 'public, max-age=60' : 'private, no-store');
-- return res.json({ version: found.row.version, count: rows.length, verified: rows.length > 0 && rows.every((t) => t.status === 'passed'), tests: rows });
-- });
--
-- r.get('/listings/:slug/offers', requirePerson, async (req, res) => {
-- const found = await visible(req, res, req.params.slug);
-- if (!found) return undefined;
-- if (!found.mine && !found.staff) return problem(res, 403, 'exchange.owner_only', 'Only the listing owner or staff can read its offers.');
-- return res.json({ count: (await entries.offersForListing(found.row.id)).length, offers: await entries.offersForListing(found.row.id) });
-- });
--
-- r.get('/me/listings', requirePerson, async (req, res) => {
-- const listings = await entries.listMine(req.principal.subject);
-- return res.json({ count: listings.length, listings });
-- });
--
-- // ── Listings (a person) ─────────────────────────────────
-- r.post('/listings', requirePerson, async (req, res) => {
-- const out = await entries.createListing({ owner: req.principal.subject, creator: creatorOf(req), body: req.body || {} });
-- if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'invalid listing');
-- res.set('Location', `/api/v1/listings/${out.listing.slug}`);
-- return res.status(201).json(out.listing);
-- });
--
-- r.post('/listings/:slug/list', requirePerson, async (req, res) => {
-- const out = await entries.publish(String(req.params.slug || ''), { owner: req.principal.subject });
-- if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot list');
-- return res.json(out.listing);
-- });
--
-- r.post('/listings/:slug/close', requirePerson, async (req, res) => {
-- const b = req.body && typeof req.body === 'object' ? req.body : {};
-- const out = await entries.close(String(req.params.slug || ''), { owner: req.principal.subject, reason: b.reason });
-- if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot close');
-- return res.json(out.listing);
-- });
--
-- r.post('/listings/:slug/hide', requireStaff, async (req, res) => {
-- const out = await entries.hide(String(req.params.slug || ''), { changedBy: req.principal.subject });
-- if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot hide');
-- return res.json(out.listing);
-- });
--
-- // ── Sandbox ─────────────────────────────────────────────
-- r.post('/listings/:slug/sandbox', requirePerson, async (req, res) => {
-- const out = await entries.sandbox(String(req.params.slug || ''), { subject: req.principal.subject });
-- if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot start the sandbox');
-- res.set('Location', `/api/v1/sandbox/${out.trial.id}`);
-- return res.status(201).json({ trial: out.trial, checks: out.checks });
-- });
--
-- r.get('/sandbox/:id', requirePerson, async (req, res) => {
-- const out = await entries.trialById(String(req.params.id || ''), { subject: req.principal.subject });
-- if (!out) return problem(res, 404, 'exchange.trial_not_found', 'No such sandbox trial of yours.');
-- return res.json(out.trial);
-- });
--
-- // ── Installs ────────────────────────────────────────────
-- r.post('/listings/:slug/install', requirePerson, async (req, res) => {
-- const out = await entries.install(String(req.params.slug || ''), { subject: req.principal.subject });
-- if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot install');
-- if (!out.unchanged) res.set('Location', `/api/v1/installs`);
-- return res.status(out.unchanged ? 200 : 201).json(out.install);
-- });
--
-- r.get('/installs', requirePerson, async (req, res) => {
-- const installs = await entries.installsFor(req.principal.subject);
-- return res.json({ count: installs.length, installs });
-- });
--
-- r.post('/installs/:id/revoke', requirePerson, async (req, res) => {
-- const out = await entries.revoke(String(req.params.id || ''), { subject: req.principal.subject });
-- if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot revoke');
-- return res.json(out.install);
-- });
--
-- // ── Free claims and swap offers ─────────────────────────
-- r.post('/listings/:slug/offers', requirePerson, async (req, res) => {
-- const b = req.body && typeof req.body === 'object' ? req.body : {};
-- const out = await entries.offer(String(req.params.slug || ''), {
-- subject: req.principal.subject, fromName: creatorOf(req), body: b.body, give: b.give,
-- });
-- if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot send the offer');
-- res.set('Location', `/api/v1/offers`);
-- return res.status(201).json(out.offer);
-- });
--
-- r.get('/offers', requirePerson, async (req, res) => {
-- const out = await entries.offersFor(req.principal.subject);
-- return res.json({ sent: out.sent.length, received: out.received.length, ...out });
-- });
--
-- for (const action of ['accept', 'decline', 'withdraw']) {
-- r.post(`/offers/:id/${action}`, requirePerson, async (req, res) => {
-- const out = await entries.decideOffer(String(req.params.id || ''), { subject: req.principal.subject, action });
-- if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || `cannot ${action} the offer`);
-- return res.json({ offer: out.offer, ...(out.closed ? { listing: out.closed } : {}) });
-- });
-- }
--
-- return r;
--}
--
--module.exports = { createApi };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex b803f86..df18494 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -1,9 +1,9 @@
'use strict';
/**
-- * Crawl artifacts for openvibe.exchange: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Public listings are
-- * for search engines and AI crawlers; sign-in, the API, the internal route and a non-listed listing are not. A
-- * sitemap also lists every public listing page (/listings/<slug>).
++ * Crawl artifacts for openvibe.exchange: robots.txt, sitemap.xml, llms.txt and llms-full.txt. The public pages and
++ * every model page are for search engines and AI crawlers; a chat, the sign-in, the internal route and the retired
++ * v1 paths are not.
*/
const fs = require('fs');
const path = require('path');
@@@ -12,8 +12,18 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Exchange';
--const DESCRIPTION = 'OpenVibe.Exchange is the OpenVibe marketplace for what people make: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation. Free and swap only in v1.';
--const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/sandbox/', '/installs', '/offers', '/submit'];
++const DESCRIPTION = 'Every AI model, one account: live prices, speed and uptime for DeepSeek, Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds; chat with one model or three side by side; one OpenAI- and Anthropic-compatible endpoint.';
++const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/chat', '/listings', '/submit', '/sandbox', '/installs', '/offers'];
++
++const PUBLIC_PAGES = [
++ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Exchange home', text: 'Every AI model, one account: live prices, speed and uptime, chat, and one endpoint.' },
++ { path: '/models', changefreq: 'daily', priority: 0.9, title: 'Models', text: 'Every model with every offer that serves it: price in and out per million tokens, speed, uptime and data policy.' },
++ { path: '/api', changefreq: 'weekly', priority: 0.7, title: 'The API', text: 'One OpenAI- and Anthropic-compatible endpoint, model ids, keys from Network and aggregate usage.' },
++ { path: '/sell', changefreq: 'weekly', priority: 0.6, title: 'Sell your compute', text: 'OpenVibe Node offers a GPU or a spare machine to the network for credits; what is not sold here, said plainly.' },
++ { path: '/uncensored', changefreq: 'weekly', priority: 0.6, title: 'Uncensored', text: 'Abliterated models for adults, with the floor: no minors, no non-consensual content, no mass harm.' },
++ { path: '/ai', changefreq: 'daily', priority: 0.6, title: 'AI in OpenVibe', text: 'Which OpenVibe products run which models through the same router, and what each costs a day.' },
++ { path: '/status', changefreq: 'hourly', priority: 0.5, title: 'Status', text: 'Uptime per offer, the model changelog, and incidents.' },
++];
function dayOf(ts) {
const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
@@@ -27,84 +37,90 @@ function homeJsonLd(config) {
const site = String(config.baseUrl).replace(/\/+$/, '');
return [
seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
-- seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'ShoppingApplication', keywords: 'openvibe exchange, marketplace, free and swap, permission passport, sandbox, install, revoke, templates, themes, game assets' }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe exchange, ai models, deepseek, qwen, kimi, glm, minimax, openrouter, api, chat, uncensored' }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
];
}
--function publicPages() {
-- return [
-- { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Exchange home', text: 'Trade what people make: free and swap listings, each with a permission passport, a sandbox and one-tap revocation.' },
-- { path: '/listings', changefreq: 'daily', priority: 0.9, title: 'All listings', text: 'Every public free and swap listing: agents, MCP tools, templates, themes, game assets and creator services.' },
-- { path: '/safety', changefreq: 'monthly', priority: 0.6, title: 'Free and swap safety rules', text: 'No money, no prohibited items, read the passport, meet safely and revoke any time.' },
-- { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Exchange API', text: 'The JSON API, the passport, the sandbox, installs and the Search document.' },
-- ];
--}
++function publicPages() { return PUBLIC_PAGES; }
function createDiscoveryRoutes(ctx) {
-- const { config, s } = ctx;
++ const { config, ai } = ctx;
const r = asyncRouter();
const site = String(config.baseUrl).replace(/\/+$/, '');
const abs = (p) => `${site}${p}`;
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
const pages = publicPages();
++ async function modelPages() {
++ const catalog = await ai.catalog();
++ return (catalog.models || []).slice(0, 5000).map((m) => ({
++ path: `/m/${m.slug.split('/').map(encodeURIComponent).join('/')}`,
++ title: m.name,
++ text: `${m.family} · ${m.offers.length} offers${m.uncensored ? ' · 18+' : ''}`,
++ changefreq: 'daily', priority: 0.7,
++ }));
++ }
++
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
-- '# openvibe.exchange: public listings are for search and AI crawlers; sign-in, the API and private routes are not.\n'
++ '# openvibe.exchange: the models and their prices are for search and AI crawlers; a chat, sign-in and the retired v1 paths are not.\n'
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })
+ `# Agent manifest: ${abs('/.well-known/agent.json')}\n`);
});
-- r.get('/llms.txt', (_req, res) => {
++ r.get('/llms.txt', async (_req, res) => {
++ let models = [];
++ try { models = (await ai.catalog()).models || []; } catch (e) { models = []; }
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
-- summary: 'OpenVibe.Exchange is the OpenVibe marketplace for what people make: agents, MCP tools, templates (Website, Report, Space), design themes and kits, game assets and creator services. Every public listing shows a permission passport (exactly what it can read, write, spend or contact), its licence, the conformance tests for that version, and a sandbox. A passed sandbox trial is required before an install; a revoke removes the installation in one tap. v1 is free and swap only: there is no money and a passport may not declare a spend.',
-- details: 'GET /api/v1/listings lists public listings; GET /api/v1/listings/:slug adds the passport, manifest, licence, tests and counters; GET /api/v1/listings/:slug/passport is the exact passport an installer accepts. The sandbox is a manifest dry run in v1: Exchange does not execute a listing\'s code. Every public listing is one search.index-document@1 (owner exchange, type listing) queued in exchange_outbox and delivered to OpenVibe.Search within a minute.',
++ summary: DESCRIPTION,
++ details: 'Prices are read live from OpenVibe.AI and are the offer\'s own, per million tokens. The API is OpenAI-compatible at https://api.openvibe.exchange/v1/chat/completions and Anthropic-compatible at /v1/messages; model ids are deepseek/deepseek-chat and their families, or openvibe/auto to let the router pick by floor, ceiling and policy. A small free daily allowance comes with an account.',
markdown: true,
base: site,
sections: [
{ title: 'Start here', links: [
-- { title: 'OpenVibe.Exchange', url: abs('/'), note: 'free and swap listings' },
-- { title: 'All listings', url: abs('/listings') },
-- { title: 'Safety rules', url: abs('/safety') },
-- { title: 'The API', url: abs('/developers') },
-- { title: 'Public listings (JSON)', url: abs('/api/v1/listings') },
++ { title: 'OpenVibe.Exchange', url: abs('/'), note: 'every model, one account' },
++ { title: 'Models', url: abs('/models') },
++ { title: 'The API', url: abs('/api') },
++ { title: 'Sell your compute', url: abs('/sell') },
++ { title: 'Uncensored (18+)', url: abs('/uncensored') },
++ { title: 'AI in OpenVibe', url: abs('/ai') },
] },
++ { title: 'Models', links: models.slice(0, 60).map((m) => ({
++ title: m.name, url: abs(`/m/${m.slug.split('/').map(encodeURIComponent).join('/')}`),
++ note: [m.family, m.uncensored ? '18+' : null, m.offers.length ? `${m.offers.length} offers` : null].filter(Boolean).join(' · '),
++ })) },
],
optional: [
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
-- { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, staff roles and the grants a passport names' },
-- { title: 'OpenVibe.Run', url: 'https://openvibe.run', note: 'the sandbox that will execute a listing\'s tests' },
-- { title: 'OpenVibe.Search', url: 'https://search.openvibe.network', note: 'where public listings are indexed' },
-- { title: 'OpenVibe.Rent', url: 'https://openvibe.rent', note: 'the listings engine and local safety rules Exchange borrows' },
++ { title: 'OpenVibe.AI', url: 'https://ai.openvibe.services', note: 'the router and the run API every product uses' },
++ { title: 'OpenVibe.Node', url: 'https://openvibe.node', note: 'offer a machine to the network' },
],
}));
});
r.get('/llms-full.txt', async (_req, res) => {
-- const rows = await s.db.many("SELECT slug, title, summary, kind, mode, license FROM exchange_listings WHERE status = 'listed' ORDER BY listed_at DESC LIMIT 2000");
-- const listings = rows.map((e) => ({ url: `/listings/${e.slug}`, title: e.title, text: [e.kind, e.mode, e.license, e.summary].filter(Boolean).join(' · ') }));
++ let modelPageRows = [];
++ try { modelPageRows = await modelPages(); } catch (e) { modelPageRows = []; }
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
-- summary: 'Every public page of OpenVibe.Exchange: the home, all listings, the safety rules, the API and every public listing.',
++ summary: 'Every public page of OpenVibe.Exchange: the home, the models, the API, selling compute, the uncensored tier and AI in OpenVibe.',
base: site,
maxBytes: 512 * 1024,
-- sections: [
-- { title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
-- { title: 'Listings', pages: listings },
-- ],
++ sections: [{ title: 'Pages', pages: [...pages, ...modelPageRows].map((p) => ({ url: p.path, title: p.title, text: p.text })) }],
}));
});
r.get('/sitemap.xml', async (_req, res) => {
const lastmod = siteUpdated();
-- const rows = await s.db.many("SELECT slug, updated_at FROM exchange_listings WHERE status = 'listed' ORDER BY slug LIMIT 5000");
++ let rows = [];
++ try { rows = await modelPages(); } catch (e) { rows = []; }
const urls = [
...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
-- ...rows.map((e) => ({ loc: abs(`/listings/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.7 })),
++ ...rows.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
];
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
@@@ -112,4 +128,4 @@ function createDiscoveryRoutes(ctx) {
return r;
}
--module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME, PUBLIC_PAGES };
ddiff --git a/server/http/openapi-routes.js b/server/http/openapi-routes.js
ddeleted file mode 100644
iindex be8b243..0000000
---- a/server/http/openapi-routes.js
++++ /dev/null
@@@ -1,93 +0,0 @@
--'use strict';
--// GET /openapi.json (SEO-3): this service's API as OpenAPI 3.1, read from the routes the app really mounts.
--//
--// The API is not in openvibe-contracts, so there is no generated document to serve. This one cannot drift from the
--// code: its paths and methods are the app's own routes under /api/, read from Express's router when first asked
--// (every route is mounted by then), with each path's parameters. A route's summary is its line in the header comment
--// of the API files ("GET /x what it does"); the request and response bodies are on the developer page the
--// document names.
--const fs = require('fs');
--const path = require('path');
--
--const METHODS = ['get', 'put', 'post', 'delete', 'patch'];
--// Not the API: health and readiness probes, and this document.
--const SKIP = /^\/api\/(?:health|ready|metrics)(?:\/|$)|\/openapi\.json$/;
--
--/** The mount path of a nested router (Express 4 keeps it only as a regexp): '/x', '/x/:id', or null when not a plain path. */
--function mountPathOf(layer) {
-- if (layer.regexp && layer.regexp.fast_slash) return '';
-- let src = layer.regexp && layer.regexp.source;
-- if (!src) return null;
-- src = src.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\(\?=\\\/\|\$\)$/i, '');
-- let k = 0;
-- // A parameter: (?:\/([^/]+?)) in a mount path, (?:([^\/]+?)) after a slash.
-- src = src.replace(/\(\?:(\\\/)?\(\[\^\\?\/\]\+\?\)\)/g, (_, slash) => `${slash ? '\\/' : ''}:${(layer.keys[k++] || {}).name || 'param'}`);
-- src = src.replace(/\\\//g, '/');
-- return /^[A-Za-z0-9_\-/:.]*$/.test(src) ? src : null;
--}
--
--/** Every route of a router (or app), as { method, path }. */
--function routesOf(router, prefix = '', out = []) {
-- for (const layer of (router && router.stack) || []) {
-- if (layer.route && typeof layer.route.path === 'string') {
-- for (const m of METHODS) if (layer.route.methods[m]) out.push({ method: m, path: prefix + layer.route.path });
-- } else if (layer.handle && Array.isArray(layer.handle.stack)) {
-- const at = mountPathOf(layer);
-- if (at != null) routesOf(layer.handle, prefix + at, out);
-- }
-- }
-- return out;
--}
--
--/** "GET /x/:id what it does" lines from the header comments of the API files: { 'get /api/v1/x/{id}': 'what it does' }. */
--function summariesFrom(files, apiBase) {
-- const out = {};
-- for (const file of files) {
-- let src = '';
-- try { src = fs.readFileSync(file, 'utf8'); } catch { continue; }
-- for (const m of src.matchAll(/^\s*\*\s+(GET|PUT|POST|DELETE|PATCH)\s+(\/[^\s?]*)(?:\?\S*)?\s+(.+?)\s*$/gm)) {
-- const p = m[2].startsWith('/api/') ? m[2] : `${apiBase}${m[2] === '/' ? '' : m[2]}`;
-- out[`${m[1].toLowerCase()} ${p.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`] = m[3].replace(/\s{2,}/g, ' ').slice(0, 200);
-- }
-- }
-- return out;
--}
--
--/**
-- * The route handler. app: the Express app; o: { title, description, version, baseUrl, docs, apiBase = '/api/v1',
-- * files: the API source files whose header comments name the routes }.
-- */
--function openapiRoute(app, o) {
-- let doc = null;
-- const build = () => {
-- const apiBase = o.apiBase || '/api/v1';
-- const said = summariesFrom(o.files || [], apiBase);
-- const paths = {};
-- for (const r of routesOf(app._router)) {
-- if (!r.path.startsWith('/api/') || SKIP.test(r.path)) continue;
-- const p = r.path.replace(/:([A-Za-z0-9_]+)\??/g, '{$1}').replace(/\/+$/, '') || '/';
-- const params = [...p.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((x) => ({ name: x[1], in: 'path', required: true, schema: { type: 'string' } }));
-- const op = {
-- operationId: `${r.method}${p.replace(/\{([^}]+)\}/g, 'By_$1').replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ''))}`,
-- ...(said[`${r.method} ${p}`] ? { summary: said[`${r.method} ${p}`] } : {}),
-- ...(params.length ? { parameters: params } : {}),
-- responses: { default: { description: o.docs ? `See ${o.docs}` : 'See the developer page' } },
-- };
-- paths[p] = paths[p] || {};
-- if (!paths[p][r.method]) paths[p][r.method] = op;
-- }
-- return {
-- openapi: '3.1.0',
-- info: { title: o.title, version: String(o.version || '1'), description: `${o.description || ''} The paths and methods are the routes this service mounts${o.docs ? `; the request and response bodies are on ${o.docs}` : ''}.`.trim() },
-- servers: [{ url: String(o.baseUrl).replace(/\/+$/, '') }],
-- ...(o.docs ? { externalDocs: { url: o.docs } } : {}),
-- paths: Object.fromEntries(Object.keys(paths).sort().map((k) => [k, paths[k]])),
-- };
-- };
-- return (_req, res) => {
-- if (!doc) doc = build();
-- res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json(doc);
-- };
--}
--
--module.exports = { openapiRoute, routesOf, summariesFrom, mountPathOf };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 4706fca..a413c1e 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -1,278 +1,451 @@
'use strict';
/**
-- * Exchange's server-rendered pages. They work without JavaScript: forms are plain POSTs and every link is a real
-- * link. Public pages are served `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in person is
-- * `private, no-store` and a page that needs an account is not cached.
++ * Exchange's server-rendered pages. They work without JavaScript: every form is a plain POST, every link a
++ * real link, and a chat is one page each.
*
-- * / the hero, the three scenes, fresh listings
-- * /listings browse and filter (free/swap, kind, q)
-- * /listings/:slug passport, tests, sandbox, install/revoke, free claim or swap offer
-- * /submit create a listing (draft; POST /submit)
-- * /sandbox/:id one sandbox trial and its checks
-- * /installs the caller's installations and one-tap revoke
-- * /offers free claims and swap offers, sent and received
-- * /safety the free and swap rules
-- * /developers the API
-- * 404 the not-found page
++ * / live numbers, popular, cheapest, uncensored, community, the endpoint
++ * /models browse and filter; /m/:org/:model one model, its offers, Try it, curl and SDK
++ * /chat the shared conversation UI (openvibe-shared/thread), 1-3 models side by side
++ * /api the endpoint, keys, model ids, usage
++ * /sell Node's "Offer to the network", the dashboard, what is not sold
++ * /uncensored the 18+ gate, the floor, the abliterated models
++ * /ai which products run which models, and what each costs a day
++ *
++ * Exchange v1 (listings, sandboxes, installs, offers) answers 410 with a pointer. Its tables stay read-only.
*/
const express = require('express');
const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
++const kit = require('openvibe-shared/kit');
++const thread = require('openvibe-shared/thread');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
--const { html, raw } = require('../render/html');
++const { esc, raw } = require('../render/html');
++const marks = require('../views/marks');
const { send } = require('../render/layout');
--const { isStaff } = require('../identity/staff');
--const views = require('../views');
++const views = require('../views/exchange');
++
++const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
++const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sandbox/*', '/installs', '/offers', '/safety', '/developers'];
--const PUBLIC_CACHE = 'public, max-age=60';
++const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');
function createPageRoutes(ctx) {
-- const { config, entries } = ctx;
++ const { config, ai, openapi, chats, adult } = ctx;
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
-- const base = () => String(config.baseUrl).replace(/\/+$/, '');
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
-- const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
-- const staffViewer = (req) => isStaff(req.viewer);
-- const creatorOf = (req) => (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'a maker';
-- const notFromHere = (req, res) => page(req, res, { title: 'Not from this site', body: raw(html`<div class="ex-wrap ex-page-pad ex-narrow"><div class="ex-empty"><h1>That came from somewhere else</h1><p class="ex-muted">Use the form on <a href="/">OpenVibe.Exchange</a>.</p></div></div>`) }, 403);
-- const problemOf = (req) => (typeof req.query.problem === 'string' && req.query.problem ? req.query.problem.slice(0, 300) : null);
++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
++ const owner = (req) => req.viewer.subject;
++ const notFromHere = (req, res) => page(req, res, { title: 'Not from this site', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'shield', title: 'That came from somewhere else', text: 'Use the form on OpenVibe.Exchange.' })}</div>`) }, 403);
++ const signInPage = (req, res, { title, text, next, status = 401 }) => page(req, res, {
++ title, index: false,
++ body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'person', title, text, action: { href: `/auth/login?next=${encodeURIComponent(next || req.originalUrl)}`, label: 'Sign in with OpenVibe' } })}</div>`),
++ }, status);
++ const catalogOf = () => ai.catalog();
++
++ // ── Exchange v1, retired ──────────────────────────────────
++ function retiredHandler(req, res) {
++ res.set('Cache-Control', 'public, max-age=86400');
++ res.set('X-Robots-Tag', 'noindex');
++ return page(req, res, {
++ title: 'Retired', index: false,
++ description: 'Exchange v1 is retired. Every AI model, one account.',
++ body: raw(views.retired(req.path)),
++ }, 410);
++ }
++ for (const p of RETIRED_PATHS) { r.get(p, retiredHandler); r.post(p, retiredHandler); }
++
++ // ── /openapi.json: the API host's document, as every OpenVibe site serves one ──
++ r.get('/openapi.json', async (req, res) => {
++ const { doc, stale } = await openapi();
++ res.status(200)
++ .type('application/json')
++ .set('Cache-Control', `public, max-age=${stale ? 60 : Math.floor(config.api.openapiCacheMs / 1000)}`)
++ .send(JSON.stringify(doc));
++ });
// ── Home ─────────────────────────────────────────────────
r.get('/', async (req, res) => {
-- const [list, counts] = await Promise.all([entries.list({ limit: 8 }), entries.counts()]);
++ const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
++ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: null,
-- description: 'OpenVibe.Exchange: trade what people make. Free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.',
++ description: 'Every AI model, one account: live prices, speed and uptime for DeepSeek, Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds. Chat, or call one OpenAI-compatible endpoint.',
jsonLd: homeJsonLd(config),
-- body: raw(views.home({ listings: list.listings, counts })),
++ body: raw(views.home({ catalog, usage, viewer: req.viewer, q, config })),
});
});
-- // ── Browse ───────────────────────────────────────────────
-- r.get('/listings', async (req, res) => {
-- const mode = typeof req.query.mode === 'string' && ['free', 'swap'].includes(req.query.mode) ? req.query.mode : null;
-- const kind = typeof req.query.kind === 'string' && entries.KINDS.includes(req.query.kind) ? req.query.kind : null;
++ // ── Models ───────────────────────────────────────────────
++ r.get('/models', async (req, res) => {
++ const catalog = await catalogOf();
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
-- const out = await entries.list({
-- limit: 24, mode, kind, q,
-- cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,
-- });
++ const family = typeof req.query.family === 'string' ? req.query.family.slice(0, 60) : '';
++ const sort = ['cheap', 'fast'].includes(req.query.sort) ? req.query.sort : '';
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
-- title: 'All listings',
-- description: 'Every public free and swap listing on OpenVibe.Exchange: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport.',
-- body: raw(views.listingsPage({ listings: out.listings || [], nextCursor: out.next_cursor || null, filters: { q, mode: mode || '', kind: kind || '' } })),
++ title: q ? `Models: ${q}` : 'Models',
++ description: 'Every model on OpenVibe.Exchange with its live offers: price in and out per million tokens, measured speed and uptime, context length and data policy.',
++ body: raw(views.modelsPage({ catalog, q, family, sort })),
});
});
-- // ── One listing ──────────────────────────────────────────
-- async function renderListing(req, res, { slug, status = 200, problem = null } = {}) {
-- const detail = await entries.detail(String(slug || ''));
-- if (!detail) return notFound(req, res);
-- const row = detail.row;
-- const mine = Boolean(signedIn(req) && row.owner === req.viewer.subject);
-- const staff = staffViewer(req);
-- if (row.status !== 'listed' && !mine && !staff) return notFound(req, res);
-- const listing = detail.listing;
-- const subject = signedIn(req) ? req.viewer.subject : null;
-- const [activeInstall, trialPassed, offers] = await Promise.all([
-- subject ? entries.activeInstall(row.id, subject) : null,
-- subject ? entries.hasPassedTrial(row.id, subject, row.version) : false,
-- (mine || staff) ? entries.offersForListing(row.id) : [],
-- ]);
-- const notice = req.query.submitted === '1' ? 'Draft saved. Run the conformance tests below; when they pass you can list it publicly.'
-- : req.query.offered === '1' ? 'Your offer is sent. The maker decides.'
-- : req.query.closed === '1' ? 'The listing is closed.' : null;
++ async function modelFrom(catalog, org, model) {
++ const slug = `${String(org || '').toLowerCase()}/${String(model || '').toLowerCase()}`;
++ return (catalog.models || []).find((m) => m.slug === slug) || null;
++ }
++ const modelPath = (m) => `/m/${m.slug.split('/').map(encodeURIComponent).join('/')}`;
++
++ async function renderModel(req, res, { result = null, problem = null, text = '', status = 200 } = {}) {
++ const catalog = await catalogOf();
++ const model = await modelFrom(catalog, req.params.org, req.params.model);
++ if (!model) {
++ if (catalog.unavailable) {
++ return page(req, res, { title: 'Models are not available', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.notice({ kind: 'warn', text: 'The live catalog is not reachable right now. Reload in a moment.' })}<p><a href="/models">All models</a></p></div>`) }, 503);
++ }
++ return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such model', text: `Nothing here serves ${esc(`${req.params.org}/${req.params.model}`)}. <a href="/models">Browse the models</a>.` })}</div>`) }, 404);
++ }
++ const isAdult = signedIn(req) ? await adult.confirmed(owner(req)) : false;
const jsonLd = seo.jsonLd.webPage({
-- name: listing.title,
-- url: `${base()}/listings/${listing.slug}`,
-- description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),
-- siteUrl: base(),
++ name: `${model.name} on OpenVibe.Exchange`, url: `${config.baseUrl}${modelPath(model)}`,
++ description: `${model.name}: ${model.offers.length} live offers with price, speed and uptime.`.slice(0, 160), siteUrl: config.baseUrl,
});
return page(req, res, {
-- index: row.status === 'listed',
-- agentLayer: row.status === 'listed',
-- cache: row.status === 'listed' ? PUBLIC_CACHE : null,
-- title: listing.title,
-- description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),
-- jsonLd: [jsonLd],
-- body: raw(views.listingPage({
-- listing, tests: detail.listing.tests && detail.listing.tests.rows ? detail.listing.tests.rows : [],
-- mine, staff, signedIn: Boolean(subject), activeInstall, trialPassed,
-- offers, received: mine,
-- problem: problem || problemOf(req), notice: req.query.revoked === '1' ? 'Revoked.' : notice,
-- })),
++ index: true, cache: req.method === 'GET' ? PUBLIC_CACHE : null, agentLayer: true,
++ title: model.name, description: `${model.name}: price, speed, uptime and data policy of every offer.`,
++ jsonLd: [jsonLd], head: '',
++ body: raw(views.modelPage({ model, viewer: req.viewer, result, problem, text, adult: isAdult })),
}, status);
}
-- r.get('/listings/:slug', (req, res) => renderListing(req, res, { slug: req.params.slug }));
--
-- // ── Submit (create a draft) ──────────────────────────────
-- function formValues(b = {}) {
-- return {
-- title: b.title || '', kind: b.kind || 'template', mode: b.mode || 'free', summary: b.summary || '',
-- description: b.description || '', wants: b.wants || '', license: b.license || 'CC0-1.0',
-- license_url: b.license_url || '', version: b.version || '1.0.0',
-- manifest_name: b.manifest_name || '', manifest_entry: b.manifest_entry || '', manifest_runtime: b.manifest_runtime || '',
-- passport_reads: b.passport_reads || '', passport_writes: b.passport_writes || '', passport_contacts: b.passport_contacts || '',
-- };
++ r.get('/m/:org/:model', (req, res) => renderModel(req, res));
++ r.post('/m/:org/:model/try', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to try it', text: 'A try is one run on your own free allowance.', next: `/m/${req.params.org}/${req.params.model}` });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const text = String((req.body && req.body.text) || '').slice(0, config.chat.maxChars);
++ if (!text.trim()) return renderModel(req, res, { problem: 'Write something to send.', status: 422 });
++ const catalog = await catalogOf();
++ const model = await modelFrom(catalog, req.params.org, req.params.model);
++ if (!model) return renderModel(req, res);
++ if (model.uncensored && !(await adult.confirmed(owner(req)))) {
++ return page(req, res, { title: model.name, index: false, body: raw(views.modelPage({ model, viewer: req.viewer, problem: 'Confirm you are 18 or over first.', adult: false })) }, 403);
++ }
++ const best = model.offers.find((o) => o.id) || model.offers[0];
++ const modelId = (best && best.id) || 'openvibe/auto';
++ try {
++ const out = await ai.chat({ model: modelId, messages: [{ role: 'user', content: text }], subject: owner(req) });
++ return renderModel(req, res, { result: out, text });
++ } catch (err) {
++ return renderModel(req, res, { problem: err.message || 'The model did not answer.', text, status: err.status === 429 ? 429 : 502 });
++ }
++ });
++
++ // ── Chat ─────────────────────────────────────────────────
++ async function chatCatalog() {
++ const catalog = await catalogOf();
++ const notChat = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);
++ return { catalog, models: (catalog.models || []).filter((m) => !notChat.has(String(m.kind || m.type || 'chat').toLowerCase())) };
++ }
++ const modelOptions = (models, selected) => models.slice(0, 40).map((m) => [m.slug, m.name + (m.uncensored ? ' · 18+' : '') + ((m.offers || []).some((o) => o.free_tier === true) ? ' · Free to try' : ''), (selected || []).includes(m.slug)]);
++
++ function chatSidebar(chats, current) {
++ const items = chats.map((c) => ({ href: `/chat/${c.id}`, title: c.title, current: c.id === current, at: c.updated_at }));
++ return thread.sidebar({
++ newHref: '/chat/new', newLabel: 'New chat', findAction: '/chat', findPlaceholder: 'Find a chat',
++ groups: thread.group(items.sort((a, b) => String(b.at).localeCompare(String(a.at))), (c) => c.at || Date.now()),
++ nav: [
++ { href: '/models', label: 'Models', icon: 'spark' },
++ { href: '/api', label: 'API', icon: 'code' },
++ { href: '/ai', label: 'AI in OpenVibe', icon: 'gear' },
++ ],
++ });
}
-- const splitList = (v) => String(v == null ? '' : v).split(/[\n,]+/).map((x) => x.trim()).filter(Boolean);
-- r.get('/submit', async (req, res) => {
-- const mine = signedIn(req) ? await entries.listMine(req.viewer.subject) : [];
++ function composerFor({ chat = null, models, problem = null, action = '/chat/send' }) {
++ const selected = chat && chat.models.length ? chat.models : models.slice(0, 1).map((m) => m.slug);
++ const settings = `<details class="ovt-settings"><summary>Models, system prompt and temperature</summary>
++<div class="ovt-settings-body">
++${kit.chips({ name: 'models', label: 'Compare up to three models', options: modelOptions(models, selected), single: false })}
++<label class="ex-label" for="sys">System prompt (optional)</label>
++<textarea id="sys" name="system" rows="2" maxlength="4000">${esc((chat && chat.system_prompt) || '')}</textarea>
++<label class="ex-label" for="temp">Temperature <span class="ex-muted">0 to 2</span></label>
++<input id="temp" name="temperature" type="number" min="0" max="2" step="0.1" value="${chat && chat.temperature != null ? esc(chat.temperature) : ''}">
++</div></details>${problem ? kit.notice({ kind: 'error', text: problem }) : ''}`;
++ return thread.composer({
++ action, name: 'text', placeholder: 'Ask anything…', top: settings, maxlength: config.chat.maxChars,
++ hidden: { chat: chat ? chat.id : '' },
++ note: `Your free daily allowance. Models marked Free to try run on it; other models need your own balance or key. Each message may run up to ${config.chat.models} models.`,
++ starters: chat ? [] : ['Explain the difference between a mutex and a semaphore', 'Write a Python function that reads a CSV and sums a column', 'Which of you is cheaper for this?'],
++ });
++ }
++
++ /** One turn's answers side by side: each column is the model, its price and latency, its answer and a folded receipt. */
++ function answerRound(list, bySlug) {
++ const cells = list.map((m) => {
++ const model = bySlug.get(m.model);
++ const cost = m.meta && m.meta.cost_usd;
++ const latency = m.meta && m.meta.latency_ms;
++ const asked = model ? model.name : (m.meta && m.meta.answered_model) || m.model || 'OpenVibe';
++ const head = `<header class="ex-answer-head">${marks.mark(model ? model.family : 'Other', { size: 22, label: false })}<b>${esc(asked)}</b><span class="ex-answer-cost">${cost == null ? '—' : Number(cost) === 0 ? 'free' : `$${Number(cost).toFixed(6)}`}${latency == null ? '' : ` · ${latency < 1000 ? `${Math.round(latency)} ms` : `${(latency / 1000).toFixed(1)} s`}`}</span></header>`;
++ const body = m.error
++ ? thread.card({ tone: 'bad', title: 'Did not answer', text: m.error })
++ : thread.answer({ html: textOf(m.content), meta: [] });
++ const receipt = `<details class="ex-receipt"><summary>Receipt</summary><dl>
++<dt>Offer</dt><dd>${esc(m.meta && m.meta.provider ? views.providerLabel({ provider: m.meta.provider }) : '—')}${m.meta && m.meta.answered_model ? ` · ${esc(m.meta.answered_model)}` : ''}</dd>
++<dt>Price</dt><dd>${cost == null ? '—' : Number(cost) === 0 ? 'free' : `$${Number(cost).toFixed(6)}`}</dd>
++<dt>Tokens</dt><dd>${m.meta && (m.meta.tokens_in != null || m.meta.tokens_out != null) ? `${Number(m.meta.tokens_in || 0).toLocaleString('en-US')} in · ${Number(m.meta.tokens_out || 0).toLocaleString('en-US')} out` : '—'}</dd>
++<dt>Latency</dt><dd>${latency == null ? '—' : `${Number(latency).toLocaleString('en-US')} ms`}</dd>
++<dt>Why this offer</dt><dd>${m.meta && Array.isArray(m.meta.why) && m.meta.why.length ? esc(m.meta.why.join(' · ')) : 'The router picked the healthiest offer for your floor, ceiling and policy.'}</dd>
++</dl></details>`;
++ return `<article class="ex-answer-col">${head}${body}${receipt}</article>`;
++ }).join('');
++ return `<li class="ovt-turn ex-round"><div class="ex-answer-grid" data-count="${list.length}">${cells}</div></li>`;
++ }
++
++ function chatPage({ req, res, chat = null, chats = [], messages = [], models, problem = null, status = 200 }) {
++ const bySlug = new Map((models || []).map((m) => [m.slug, m]));
++ const turns = [];
++ if (chat) {
++ let i = 0;
++ while (i < messages.length) {
++ const m = messages[i];
++ if (m.role === 'user') {
++ turns.push(thread.turn({ id: m.id, you: { text: m.content }, reply: null }));
++ i += 1;
++ const answers = [];
++ while (i < messages.length && messages[i].role === 'assistant') { answers.push(messages[i]); i += 1; }
++ if (answers.length) turns.push(answerRound(answers, bySlug));
++ } else {
++ turns.push(answerRound([m], bySlug));
++ i += 1;
++ }
++ }
++ }
++ const empty = `<div class="ovt-welcome"><h2>Every model, one chat</h2><p>Pick one model, or up to three to compare side by side. The floor applies always: no sexual content involving minors, no non-consensual sexual content of real people, no help with mass harm.</p></div>`;
return page(req, res, {
-- index: false,
-- title: 'List something',
-- description: 'List an agent, MCP tool, template, theme, game asset or creator service on OpenVibe.Exchange. Free or swap only in v1.',
-- body: raw(views.submitForm({ values: {}, mine })),
++ title: chat ? chat.title : 'Chat', index: false,
++ class: 'ex-chat', app: true,
++ head: `<link rel="stylesheet" href="${require('openvibe-shared/serve').url('thread.css')}"><script src="${require('openvibe-shared/serve').url('ov-thread.js')}" defer></script>`,
++ body: raw(thread.page({
++ sidebar: chatSidebar(chats, chat ? chat.id : null),
++ head: thread.head({ title: chat ? chat.title : 'Chat', newHref: '/chat' }),
++ turns,
++ empty,
++ center: !chat,
++ composer: composerFor({ chat, models, problem, action: chat ? `/chat/${chat.id}/send` : '/chat/send' }),
++ })),
++ }, status);
++ }
++
++ async function loadChats(req) {
++ if (!signedIn(req)) return [];
++ return chats.list(owner(req));
++ }
++
++ r.get('/chat', async (req, res) => {
++ const list = await loadChats(req);
++ if (!list.length) return signInIfGuest(req, res, async () => {
++ const { models } = await chatCatalog();
++ return chatPage({ req, res, chats: [], models });
});
++ return res.redirect(303, `/chat/${list[0].id}`);
});
-- r.post('/submit', async (req, res) => {
-- if (!signedIn(req)) return page(req, res, { title: 'List something', body: raw(views.signIn({ title: 'List something', text: 'A listing belongs to your OpenVibe account, so you can manage it later.', next: '/submit' })) }, 401);
++ async function signInIfGuest(req, res, fn) {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to chat', text: 'Chat with one model or three side by side. Your history stays in your account.', next: '/chat' });
++ return fn();
++ }
++
++ r.get('/chat/new', async (req, res) => signInIfGuest(req, res, async () => {
++ const [list, cat] = await Promise.all([chats.list(owner(req)), chatCatalog()]);
++ return chatPage({ req, res, chats: list, models: cat.models });
++ }));
++
++ r.get('/chat/:id', async (req, res) => signInIfGuest(req, res, async () => {
++ const chat = await chats.get(owner(req), req.params.id);
++ if (!chat) return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'chat', title: 'No such chat', text: 'It may have been deleted.', action: { href: '/chat', label: 'Your chats' } })}</div>`) }, 404);
++ const [messages, list, cat] = await Promise.all([chats.messages(owner(req), chat.id), chats.list(owner(req)), chatCatalog()]);
++ return chatPage({ req, res, chat, chats: list, messages, models: cat.models });
++ }));
++
++ async function sendMessage(req, res, chatId = null) {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to chat', text: 'Your history stays in your account.', next: '/chat' });
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
-- const b = req.body || {};
-- const body = {
-- title: b.title, kind: b.kind, mode: b.mode, summary: b.summary, description: b.description, wants: b.wants,
-- license: b.license, license_url: b.license_url, version: b.version,
-- manifest: { name: b.manifest_name, entry: b.manifest_entry, runtime: b.manifest_runtime },
-- passport: {
-- reads: splitList(b.passport_reads), writes: splitList(b.passport_writes), spends: [], contacts: splitList(b.passport_contacts),
-- },
-- };
-- const out = await entries.createListing({ owner: req.viewer.subject, creator: creatorOf(req), body });
-- if (!out.ok) {
-- const mine = await entries.listMine(req.viewer.subject);
-- return page(req, res, {
-- title: 'List something', body: raw(views.submitForm({ values: formValues(b), mine, problem: out.message || 'That listing could not be saved.' })),
-- }, out.status || 400);
++ const body = req.body || {};
++ const text = String(body.text || '').slice(0, config.chat.maxChars).trim();
++ const { models } = await chatCatalog();
++ const picked = Object.keys(body).filter((k) => k.startsWith('models_')).map((k) => k.slice('models_'.length))
++ .concat(Array.isArray(body.models) ? body.models : (body.models ? [body.models] : []));
++ const wanted = [...new Set(picked.map(String))].filter((s) => models.some((m) => m.slug === s)).slice(0, config.chat.models);
++ const chosen = wanted.length ? wanted : models.slice(0, 1).map((m) => m.slug);
++ const system = body.system ? String(body.system).slice(0, 4000) : null;
++ const temperature = body.temperature === '' || body.temperature == null ? null : Math.max(0, Math.min(2, Number(body.temperature)));
++
++ let chat = chatId ? await chats.get(owner(req), chatId) : null;
++ if (chatId && !chat) return page(req, res, { title: 'Not found', index: false, body: raw(kit.empty({ icon: 'chat', title: 'No such chat', text: 'It may have been deleted.', action: { href: '/chat', label: 'Your chats' } })) }, 404);
++ const uncensored = chosen.map((s) => models.find((m) => m.slug === s)).filter((m) => m && m.uncensored);
++ if (uncensored.length && !(await adult.confirmed(owner(req)))) {
++ const list = await loadChats(req);
++ return chatPage({ req, res, chat, chats: list, messages: chat ? await chats.messages(owner(req), chat.id) : [], models, problem: 'That model is uncensored, and adults only. Confirm you are 18 or over on the Uncensored page first.', status: 403 });
++ }
++ if (!text) {
++ const list = await loadChats(req);
++ return chatPage({ req, res, chat, chats: list, messages: chat ? await chats.messages(owner(req), chat.id) : [], models, problem: 'Write something to send.', status: 422 });
++ }
++ if (!chosen.length) {
++ const list = await loadChats(req);
++ return chatPage({ req, res, chat, chats: list, messages: chat ? await chats.messages(owner(req), chat.id) : [], models, problem: 'Pick at least one model.', status: 422 });
++ }
++ const used = await chats.countToday(owner(req));
++ if (used >= config.chat.perDay) {
++ const list = await loadChats(req);
++ return chatPage({ req, res, chat, chats: list, messages: chat ? await chats.messages(owner(req), chat.id) : [], models, problem: `That is today's ${config.chat.perDay} answers. The allowance resets at midnight UTC.`, status: 429 });
}
-- return res.redirect(303, `/listings/${encodeURIComponent(out.listing.slug)}?submitted=1`);
-- });
-- // ── Owner actions ────────────────────────────────────────
-- r.post('/listings/:slug/list', async (req, res) => {
-- if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);
-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
-- const out = await entries.publish(String(req.params.slug || ''), { owner: req.viewer.subject });
-- return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}${out.ok ? '?listed=1' : `?problem=${encodeURIComponent(out.message || 'Cannot list it')}`}`);
++ const isNew = !chat;
++ if (!chat) chat = await chats.create({ owner: owner(req), models: chosen, title: text.replace(/\s+/g, ' ').slice(0, 60) });
++ else await chats.settings(owner(req), chat.id, { models: chosen, systemPrompt: system, temperature });
++ if (!isNew && (system !== chat.system_prompt || temperature !== chat.temperature)) {
++ // settings stored above
++ }
++
++ const history = await chats.messages(owner(req), chat.id);
++ await chats.append(owner(req), chat.id, { role: 'user', content: text });
++ const conversation = history.filter((m) => m.role === 'user').map((m) => ({ role: 'user', content: m.content }));
++
++ for (const slug of chosen) {
++ const model = models.find((m) => m.slug === slug);
++ const best = model && (model.offers.find((o) => o.id) || model.offers[0]);
++ const modelId = (best && best.id) || 'openvibe/auto';
++ const prior = history.filter((m) => m.role === 'assistant' && m.model === slug && m.content)
++ .map((m) => ({ role: 'assistant', content: m.content }));
++ const messages = [
++ ...(system ? [{ role: 'system', content: system }] : []),
++ ...interleave(conversation, prior),
++ { role: 'user', content: text },
++ ];
++ try {
++ const out = await ai.chat({ model: modelId, messages, temperature, subject: owner(req) });
++ await chats.append(owner(req), chat.id, {
++ role: 'assistant', model: slug, content: out.content || '(no text)',
++ meta: {
++ run_id: out.run_id, provider: out.provider, answered_model: out.model, cost_usd: out.cost_usd,
++ tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms, why: out.why,
++ },
++ });
++ } catch (err) {
++ await chats.append(owner(req), chat.id, { role: 'assistant', model: slug, error: err.message || 'The model did not answer.', meta: { code: err.code || null } });
++ }
++ }
++ return res.redirect(303, `/chat/${chat.id}`);
++ }
++
++ /** The person's turns, each followed by this model's own earlier answers (so a comparison never bleeds across models). */
++ function interleave(userTurns, answers) {
++ const out = [];
++ const n = Math.min(userTurns.length, answers.length);
++ for (let i = 0; i < userTurns.length; i++) {
++ out.push(userTurns[i]);
++ if (i < n) out.push(answers[i]);
++ }
++ return out;
++ }
++
++ r.post('/chat/send', (req, res) => sendMessage(req, res, null));
++ r.post('/chat/:id/send', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to chat', text: 'Your history stays in your account.', next: `/chat/${req.params.id}` });
++ return sendMessage(req, res, req.params.id);
});
-- r.post('/listings/:slug/close', async (req, res) => {
-- if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);
++ r.post('/chat/:id/delete', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fchat');
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
-- const out = await entries.close(String(req.params.slug || ''), { owner: req.viewer.subject, reason: req.body && req.body.reason });
-- return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}${out.ok ? '?closed=1' : `?problem=${encodeURIComponent(out.message || 'Cannot close it')}`}`);
++ await chats.remove(owner(req), req.params.id);
++ return res.redirect(303, '/chat');
});
-- // ── Sandbox ──────────────────────────────────────────────
-- r.post('/listings/:slug/sandbox', async (req, res) => {
-- if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);
++ r.post('/chat/:id/clear', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fchat');
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
-- const out = await entries.sandbox(String(req.params.slug || ''), { subject: req.viewer.subject });
-- if (!out.ok) return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}?problem=${encodeURIComponent(out.message || 'Cannot start the sandbox')}`);
-- return res.redirect(303, `/sandbox/${encodeURIComponent(out.trial.id)}`);
++ const chat = await chats.get(owner(req), req.params.id);
++ if (chat) await chats.clear(owner(req), chat.id);
++ return res.redirect(303, `/chat/${req.params.id}`);
});
-- r.get('/sandbox/:id', async (req, res) => {
-- if (!signedIn(req)) return page(req, res, { title: 'Sandbox trial', body: raw(views.signIn({ title: 'Sandbox trial', text: 'A trial belongs to your account; sign in to see it.', next: `/sandbox/${req.params.id}` })) }, 401);
-- const out = await entries.trialById(String(req.params.id || ''), { subject: req.viewer.subject });
-- if (!out) return notFound(req, res);
++ // ── API ──────────────────────────────────────────────────
++ r.get('/api', async (req, res) => {
++ const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
return page(req, res, {
-- index: false,
-- title: 'Sandbox trial',
-- description: 'A manifest and passport dry run on OpenVibe.Exchange.',
-- body: raw(views.sandboxPage({ trial: out.trial, listing: out.listing })),
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
++ title: 'The API',
++ description: 'One OpenAI- and Anthropic-compatible endpoint for every model on OpenVibe.Exchange: keys, model ids, usage.',
++ body: raw(views.apiPage({ catalog, usage })),
});
});
-- // ── Installs ─────────────────────────────────────────────
-- r.post('/listings/:slug/install', async (req, res) => {
-- if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);
-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
-- const out = await entries.install(String(req.params.slug || ''), { subject: req.viewer.subject });
-- if (!out.ok) return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}?problem=${encodeURIComponent(out.message || 'Cannot install')}`);
-- return res.redirect(303, `/installs?installed=1`);
++ // ── Sell ─────────────────────────────────────────────────
++ r.get('/sell', async (req, res) => {
++ const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
++ title: 'Sell your compute',
++ description: 'Turn a GPU or a spare machine into credits with OpenVibe Node: offer models, hours, concurrency and a price. What is not sold here, said plainly.',
++ body: raw(views.sellPage({ catalog, usage, viewer: req.viewer })),
++ });
});
-- r.get('/installs', async (req, res) => {
-- if (!signedIn(req)) return page(req, res, { title: 'Your installations', body: raw(views.signIn({ title: 'Your installations', text: 'An installation belongs to your account; sign in to manage it.', next: '/installs' })) }, 401);
-- const installs = await entries.installsFor(req.viewer.subject);
++ // ── Uncensored ───────────────────────────────────────────
++ r.get('/uncensored', async (req, res) => {
++ const catalog = await catalogOf();
++ const isAdult = signedIn(req) ? await adult.confirmed(owner(req)) : false;
return page(req, res, {
-- index: false,
-- title: 'Your installations',
-- description: 'Everything you have installed on OpenVibe.Exchange, with its passport and a one-tap revoke.',
-- body: raw(views.installsPage({ installs })),
++ index: true, cache: signedIn(req) ? null : PUBLIC_CACHE, agentLayer: true,
++ title: 'Uncensored', description: 'Abliterated models for adults, with the floor: no minors, no non-consensual content, no mass harm.',
++ body: raw(views.uncensoredPage({ catalog, adult: isAdult, signedIn: signedIn(req) })),
});
});
--
-- r.post('/installs/:id/revoke', async (req, res) => {
-- if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Finstalls');
++ r.post('/uncensored/confirm', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Funcensored');
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
-- const out = await entries.revoke(String(req.params.id || ''), { subject: req.viewer.subject });
-- return res.redirect(303, `/installs?${out.ok ? 'revoked=1' : `problem=${encodeURIComponent(out.message || 'Cannot revoke it')}`}`);
++ if (!req.body || req.body.adult !== 'yes') return res.redirect(303, '/uncensored');
++ await adult.confirm(owner(req), '1');
++ return res.redirect(303, '/uncensored');
});
-- // ── Free claims and swap offers ──────────────────────────
-- r.get('/offers', async (req, res) => {
-- if (!signedIn(req)) return page(req, res, { title: 'Your offers', body: raw(views.signIn({ title: 'Your offers', text: 'Offers belong to your account; sign in to see them.', next: '/offers' })) }, 401);
-- const out = await entries.offersFor(req.viewer.subject);
++ // ── AI in OpenVibe ───────────────────────────────────────
++ r.get('/ai', async (req, res) => {
++ const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
return page(req, res, {
-- index: false,
-- title: 'Your offers',
-- description: 'The free claims and swap offers you sent and received on OpenVibe.Exchange.',
-- body: raw(views.offersPage(out)),
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
++ title: 'AI in OpenVibe',
++ description: 'Which OpenVibe products use which models through the same router, and what each costs a day.',
++ body: raw(views.aiPage({ catalog, usage })),
});
});
-- r.post('/listings/:slug/offers', async (req, res) => {
-- if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);
-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
-- const out = await entries.offer(String(req.params.slug || ''), {
-- subject: req.viewer.subject, fromName: creatorOf(req),
-- body: req.body && req.body.body, give: req.body && req.body.give,
++ // ── Status ───────────────────────────────────────────────
++ r.get('/status', async (req, res) => {
++ const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
++ title: 'Status', description: 'Uptime per offer, the model changelog and incidents on OpenVibe.Exchange.',
++ body: raw(views.statusPage({ catalog, usage })),
});
-- return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}?${out.ok ? 'offered=1' : `problem=${encodeURIComponent(out.message || 'Cannot send the offer')}`}`);
});
-- for (const action of ['accept', 'decline', 'withdraw']) {
-- r.post(`/offers/:id/${action}`, async (req, res) => {
-- if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Foffers');
-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
-- const out = await entries.decideOffer(String(req.params.id || ''), { subject: req.viewer.subject, action });
-- return res.redirect(303, `/offers?${out.ok ? `${action}ed=1` : `problem=${encodeURIComponent(out.message || `Cannot ${action} the offer`)}`}`);
-- });
-- }
--
-- // ── Static pages ─────────────────────────────────────────
-- r.get('/safety', (req, res) => page(req, res, {
-- index: true, cache: PUBLIC_CACHE, agentLayer: true,
-- title: 'Free and swap safety',
-- description: 'OpenVibe.Exchange v1 has no money: free and swap only. Read the passport, meet safely, and revoke any time.',
-- body: raw(views.safetyPage()),
-- }));
--
-- r.get('/developers', (req, res) => page(req, res, {
-- index: true, cache: PUBLIC_CACHE, agentLayer: true,
-- title: 'The OpenVibe.Exchange API',
-- description: 'The JSON API, the permission passport, the sandbox, installs and the Search document.',
-- jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Exchange API', url: `${base()}/developers`, description: 'The JSON API, the passport, the sandbox, installs and the Search document.', siteUrl: base() })],
-- body: raw(views.developersPage()),
-- }));
--
-- // ── Discovery ───────────────────────────────────────────
++ // ── Discovery ────────────────────────────────────────────
r.use(createDiscoveryRoutes(ctx));
-- function notFound(req, res) {
-- return page(req, res, { title: 'Not found', body: raw(views.notFound()) }, 404);
-- }
++ r.use((req, res) => page(req, res, {
++ title: 'Not found', index: false,
++ body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'Not found', text: `Nothing lives at ${esc(req.path)}. <a href="/">The home</a> has every model.` })}</div>`),
++ }, 404));
return r;
}
--module.exports = { createPageRoutes };
++module.exports = { createPageRoutes, RETIRED_PATHS };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex ce1122d..9bdfe05 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -1,16 +1,13 @@
'use strict';
/**
-- * Account export and deletion → Exchange (ADR-033; openvibe-sdk/account-data). The rows a person owns are their
-- * listings, their installations, their sandbox trials and the offers they made. A listing is anonymised, then
-- * closed (or deleted if it was still a draft) in extraErase, and its Search document is tombstoned in the same
-- * deletion transaction; nobody else's offers or installs are touched.
++ * Account export and deletion → Exchange (ADR-033; openvibe-sdk/account-data).
*
-- * The database stores bare usr_ ids in the subject columns. A listing's public creator name is the maker's own
-- * display name; the passport, manifest, licence and tests are public metadata, never a credential.
++ * v1 rows (kept read-only since the remake) are still exported and their listings anonymised and closed, as they
++ * were promised when they were written. The new rows are the person's chats, their messages and their 18+
++ * confirmation; those are erased outright.
*/
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
--const { ids } = require('openvibe-contracts');
const LISTING_COLUMNS = [
'id', 'slug', 'title', 'kind', 'mode', 'summary', 'description', 'wants', 'license', 'license_url', 'version',
@@@ -22,13 +19,16 @@ const TABLES = [
{ table: 'exchange_installs', subject: 'subject', value: (usr) => usr, file: 'installs.json', erase: 'delete' },
{ table: 'exchange_sandbox_trials', subject: 'subject', value: (usr) => usr, file: 'sandbox-trials.json', erase: 'delete' },
{ table: 'exchange_offers', subject: 'from_subject', value: (usr) => usr, file: 'offers.json', erase: 'delete' },
++ { table: 'exchange_chats', subject: 'owner', value: (usr) => usr, file: 'chats.json', erase: 'delete' },
++ { table: 'exchange_chat_messages', subject: 'owner', value: (usr) => usr, file: 'chat-messages.json', erase: 'delete' },
++ { table: 'exchange_adult_confirmations', subject: 'subject', value: (usr) => usr, file: 'adult-confirmation.json', erase: 'delete' },
];
--function create({ db, log = console, now = () => Date.now() } = {}) {
++function create({ db, log = console } = {}) {
async function extraErase(t, subjects, { erased, retained, add }) {
-- // The table map anonymised every listing whose owner is one of the subjects; close the public ones and
-- // tombstone their Search documents, delete the drafts outright.
-- const rows = await t.many('SELECT id, slug, status, mode, revision FROM exchange_listings WHERE owner IS NULL');
++ // The table map anonymised every v1 listing whose owner is one of the subjects; close the public ones and
++ // delete the drafts outright. Nothing is published anywhere any more (the Search relay left with v1).
++ const rows = await t.many('SELECT id, status, revision FROM exchange_listings WHERE owner IS NULL');
let closed = 0;
let deleted = 0;
for (const row of rows) {
@@@ -37,8 +37,6 @@ function create({ db, log = console, now = () => Date.now() } = {}) {
deleted++;
continue;
}
-- // Everything a person wrote is replaced, not just the owner link. A swap still needs a wants value
-- // (the table's mode rule), so it gets a neutral one rather than the maker's words.
const revision = Math.max(1, Number(row.revision) || 1) + (row.status === 'closed' ? 0 : 1);
await t.exec(
`UPDATE exchange_listings
@@@ -52,12 +50,6 @@ function create({ db, log = console, now = () => Date.now() } = {}) {
if (row.status === 'closed') continue;
await t.exec("UPDATE exchange_offers SET status = 'declined', decided_at = now() WHERE listing_id = $1 AND status = 'open'", [row.id]);
await t.exec("INSERT INTO exchange_listing_events (listing_id, from_status, to_status, reason, changed_by) VALUES ($1, $2, 'closed', 'account deletion', 'account-deletion')", [row.id, row.status]);
-- const eventId = ids.newId('event', now());
-- await t.exec(
-- `INSERT INTO exchange_outbox (event_id, topic, payload, created_at, next_attempt_at)
-- VALUES ($1, 'exchange.index_document.deleted', $2::jsonb, $3::timestamptz, $3::timestamptz)`,
-- [eventId, JSON.stringify({ owner: 'exchange', type: 'listing', id: row.id, revision, deleted: true }), new Date(now()).toISOString()],
-- );
closed++;
}
if (closed) add(retained, 'exchange_listings_closed', closed);
@@@ -69,7 +61,7 @@ function create({ db, log = console, now = () => Date.now() } = {}) {
service: 'exchange',
tables: TABLES,
extraErase,
-- note: 'Listings are anonymised and closed (or deleted when still drafts); installations, sandbox trials and offers are erased.',
++ note: 'Chats, messages and the 18+ confirmation are erased; v1 listings are anonymised and closed (or deleted when still drafts).',
log,
});
}
ddiff --git a/server/index.js b/server/index.js
iindex fbdbb55..239cdc7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -19,8 +19,6 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
close: [
() => { for (const t of timers) clearInterval(t); },
() => ctx.keys.client.stop(),
-- () => ctx.firstParty.stop(),
-- () => ctx.searchRelay.stop(),
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
() => ctx.s.close(),
...extra,
@@@ -46,9 +44,6 @@ async function start() {
ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
ctx.telemetryTick.unref();
ctx.keys.client.start();
-- ctx.firstParty.start();
-- // Queued Search documents → OpenVibe.Search (a tick a few seconds after boot, then every minute).
-- if (!config.drill) ctx.searchRelay.start();
// The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
// delivery secret and the client secret. The consumer itself is mounted in server/app.js.
ddiff --git a/server/jobs/first-party.js b/server/jobs/first-party.js
ddeleted file mode 100644
iindex 949d5aa..0000000
---- a/server/jobs/first-party.js
++++ /dev/null
@@@ -1,331 +0,0 @@
--'use strict';
--
--/**
-- * OpenVibe.Exchange's first-party catalogue. The network makes real, free things and this job lists them where people
-- * can find them: Design's built-in themes and their token files, the OBS scenes and alerts overlay on Live, and the
-- * public creator worlds on Games. Each listing keeps a home_url to where the thing really lives, is owned by the
-- * network (owner NULL) and is marked official, with the real maker named when the source says who made it.
-- *
-- * There are no invented listings and no invented people. A source that stops answering is not allowed to empty the
-- * catalogue; a source that answers with items and no longer lists one retires that item. source/source_id is the
-- * idempotency key, so a restart or an overlapping run updates the same row.
-- *
-- * Sources that were checked and do not exist yet (so nothing is listed):
-- * - Design icon kits: Design publishes themes and token exports; it has no icon-kit API.
-- * - Codes agent templates: Codes publishes a harness/agent catalog, not templates.
-- * When either gains one, add a reader here and a source name to SOURCES.
-- */
--const SOURCES = Object.freeze(['design', 'obs', 'games']);
--const DESIGN_LICENSE = 'AGPL-3.0-only';
--const DESIGN_LICENSE_URL = 'https://github.com/OpenVibers/OpenVibe.Design/blob/main/LICENSE';
--const LIVE_LICENSE_URL = 'https://github.com/OpenVibers/OpenVibe.Live/blob/main/LICENSE';
--const MAX_TITLE = 120;
--const MAX_SUMMARY = 600;
--const MAX_DESCRIPTION = 4000;
--
--const plain = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
--const base = (url) => String(url || '').replace(/\/+$/, '');
--
--/** A theme's preview palette: background, surface, text, accent, then up to two more the theme names. */
--const HEX = /^#[0-9a-f]{6}$/i;
--function paletteOf(theme) {
-- const vars = theme && theme.variables && typeof theme.variables === 'object' && !Array.isArray(theme.variables) ? theme.variables : {};
-- const preview = theme && theme.preview_colors;
-- const list = Array.isArray(preview) ? preview : [];
-- const named = preview && typeof preview === 'object' && !Array.isArray(preview) ? preview : {};
-- const pick = (...values) => values.find((v) => typeof v === 'string' && HEX.test(v.trim()));
-- const extras = Array.isArray(preview)
-- ? list.slice(3)
-- : Object.entries(named).filter(([key]) => !['bg', 'surface', 'text', 'accent'].includes(key)).map(([, value]) => value);
-- const candidates = [
-- pick(named.bg, list[0], vars['--bg-primary']),
-- pick(named.surface, vars['--bg-card'], vars['--bg-secondary']),
-- pick(named.text, list[2], vars['--text-primary']),
-- pick(named.accent, list[1], vars['--accent']),
-- ...extras.map((value) => pick(value)),
-- pick(vars['--text-secondary']),
-- pick(vars['--border']),
-- ];
-- const out = [];
-- for (const candidate of candidates) {
-- if (!candidate) continue;
-- const colour = candidate.trim().toLowerCase();
-- if (out.includes(colour)) continue;
-- out.push(colour);
-- if (out.length === 6) break;
-- }
-- return out;
--}
--
--function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, log = console, now = s.now }) {
-- const stats = { last: null, running: false, started: false };
-- let timer = null;
-- let timeout = null;
--
-- async function getJson(url, { fetch = fetchImpl } = {}) {
-- const res = await fetch(url, {
-- headers: { Accept: 'application/json', 'User-Agent': 'OpenVibe.Exchange (+https://openvibe.exchange)' },
-- signal: AbortSignal.timeout(config.firstParty.timeoutMs),
-- });
-- if (!res.ok) {
-- const err = new Error(`${new URL(url).host} answered ${res.status}`);
-- err.status = res.status;
-- throw err;
-- }
-- const body = await res.json().catch(() => null);
-- if (!body || typeof body !== 'object') throw new Error(`${new URL(url).host} answered without JSON`);
-- return body;
-- }
--
-- function contact(origin) {
-- return { reads: [], writes: [], spends: [], contacts: [origin] };
-- }
--
-- function record({ source, sourceId, creator, body, homeUrl, runtime, palette = [] }) {
-- const title = plain(body.title, MAX_TITLE);
-- const summary = plain(body.summary, MAX_SUMMARY);
-- if (!source || !sourceId || title.length < 3 || summary.length < 3 || !/^https:\/\//.test(homeUrl || '')) return null;
-- return {
-- source,
-- sourceId,
-- creator: plain(creator, 80) || 'OpenVibe',
-- palette,
-- body: {
-- title,
-- kind: body.kind,
-- mode: 'free',
-- summary,
-- description: plain(body.description, MAX_DESCRIPTION) || null,
-- license: body.license,
-- license_url: body.license_url || null,
-- home_url: homeUrl,
-- version: '1.0.0',
-- manifest: { name: title, entry: homeUrl.slice(0, 200), runtime: runtime || null },
-- passport: body.passport || contact(new URL(homeUrl).origin),
-- files: Array.isArray(body.files) ? body.files : [],
-- },
-- };
-- }
--
-- async function designAll(fetch) {
-- const themesBody = await getJson(`${base(config.firstParty.designUrl)}/api/themes`, { fetch });
-- const all = Array.isArray(themesBody.themes) ? themesBody.themes : [];
-- // First-party means the built-in themes. A community theme belongs to the person who made it.
-- const themes = all.filter((t) => t && t.slug && (t.is_builtin === 1 || t.is_builtin === true));
--
-- // One export list for every theme: the theme's own listing carries the files, not one listing per file.
-- let targets = [];
-- let targetsOk = false;
-- try {
-- const targetsBody = await getJson(`${base(config.firstParty.designUrl)}/api/v1/tokens`, { fetch });
-- targets = (Array.isArray(targetsBody.targets) ? targetsBody.targets : [])
-- .filter((t) => t && typeof t.id === 'string' && t.id)
-- .map((t) => ({ id: plain(t.id, 40), label: plain(t.label || t.id, 80), lang: plain(t.lang, 40) }));
-- targetsOk = true;
-- } catch (err) {
-- log.warn(`[OpenVibe.Exchange] Design token exports: ${(err && err.message) || err}`);
-- }
-- // A token endpoint that is briefly down must not wipe the downloads already on a theme listing.
-- const priorFiles = new Map();
-- if (!targetsOk) {
-- const rows = await s.db.many("SELECT source_id, files FROM exchange_listings WHERE source = 'design.theme'");
-- for (const row of rows) priorFiles.set(row.source_id, entries.wireListing(row).files);
-- }
--
-- const themeRecords = [];
-- for (const theme of themes) {
-- const slug = plain(theme.slug, 80);
-- const name = plain(theme.name || theme.slug, 90);
-- const home = `${base(config.firstParty.designUrl)}/studio?theme=${encodeURIComponent(slug)}`;
-- const files = targetsOk ? targets.map((target) => ({
-- format: target.id,
-- label: target.label,
-- url: `${base(config.firstParty.designUrl)}/api/v1/tokens/${encodeURIComponent(slug)}?format=${encodeURIComponent(target.id)}`,
-- })) : (priorFiles.get(slug) || []);
-- const rec = record({
-- source: 'design.theme',
-- sourceId: slug,
-- creator: 'OpenVibe',
-- homeUrl: home,
-- runtime: 'web',
-- palette: paletteOf(theme),
-- body: {
-- title: `${name} theme`,
-- kind: 'theme',
-- summary: plain(theme.description, MAX_SUMMARY) || `A free ${theme.mode === 'light' ? 'light' : 'dark'} theme from OpenVibe.Design.`,
-- description: [plain(theme.description, 1000), `Open it in the studio to wear it on every OpenVibe site: ${home}`].filter(Boolean).join('\n\n'),
-- license: DESIGN_LICENSE,
-- license_url: DESIGN_LICENSE_URL,
-- passport: contact('https://openvibe.design'),
-- files,
-- },
-- });
-- if (rec) themeRecords.push(rec);
-- }
-- return { themeRecords };
-- }
--
-- function obsRecords() {
-- const live = base(config.firstParty.liveUrl);
-- const scenes = [
-- ['starting', 'Starting soon scene for OBS', 'A full-screen Starting soon scene in your theme, with the mark\'s intro and an optional countdown.'],
-- ['brb', 'Be right back scene for OBS', 'A Be right back scene in your theme, with the mark breathing while you are away.'],
-- ['ending', 'Thanks for watching scene for OBS', 'A Thanks for watching scene in your theme, with an optional next-stream line.'],
-- ];
-- const out = [];
-- for (const [kind, title, description] of scenes) {
-- const home = `${live}/obs/scene/${kind}/openvibe`;
-- const rec = record({
-- source: 'live.obs',
-- sourceId: `scene:${kind}`,
-- creator: 'OpenVibe',
-- homeUrl: home,
-- runtime: 'html',
-- body: {
-- title, kind: 'template',
-- summary: 'A free OBS browser-source scene from OpenVibe.Live: add it as a source, size it 1920 × 1080, and it wears your theme.',
-- description: `${description}\n\nAdd the URL as an OBS browser source at 1920 × 1080. Replace the channel in the URL with your own Live username.\n\n${home}`,
-- license: DESIGN_LICENSE,
-- license_url: LIVE_LICENSE_URL,
-- passport: contact(live),
-- },
-- });
-- if (rec) out.push(rec);
-- }
-- const alerts = `${live}/obs/alerts/openvibe?demo=1`;
-- const alert = record({
-- source: 'live.obs',
-- sourceId: 'alerts',
-- creator: 'OpenVibe',
-- homeUrl: alerts,
-- runtime: 'html',
-- body: {
-- title: 'Alerts overlay for OBS',
-- kind: 'template',
-- summary: 'A free alerts overlay from OpenVibe.Live: follows, tips, subs, raids and goals spring in, glint and leave, in your theme.',
-- description: `Add the URL as an OBS browser source. The demo query lines the alerts up so you can style the source; drop it for the real feed, and replace the channel with your own Live username.\n\n${alerts}`,
-- license: DESIGN_LICENSE,
-- license_url: LIVE_LICENSE_URL,
-- passport: contact(live),
-- },
-- });
-- if (alert) out.push(alert);
-- return out;
-- }
--
-- async function gamesRecords(fetch) {
-- const out = [];
-- const seen = new Set();
-- for (let page = 1; page <= 10; page++) {
-- const body = await getJson(`${base(config.firstParty.gamesUrl)}/api/worlds?page=${page}`, { fetch });
-- const worlds = Array.isArray(body.worlds) ? body.worlds : [];
-- for (const world of worlds) {
-- if (!world || !world.id || seen.has(String(world.id))) continue;
-- seen.add(String(world.id));
-- const home = `${base(config.firstParty.gamesUrl)}${String(world.play_url || `/play?world=${encodeURIComponent(world.id)}`)}`;
-- const creator = plain(world.owner, 80) || 'a maker';
-- const rec = record({
-- source: 'games.world',
-- sourceId: plain(world.id, 64),
-- creator,
-- homeUrl: home,
-- runtime: 'game',
-- body: {
-- title: plain(world.title, MAX_TITLE) || 'A creator world on OpenVibe.Games',
-- kind: 'game_asset',
-- summary: plain(world.summary, MAX_SUMMARY) || 'A free creator world published on OpenVibe.Games.',
-- description: `A creator world on OpenVibe.Games${world.owner ? ` by ${plain(world.owner, 80)}` : ''}. Free to play in the browser.\n\n${home}`,
-- // The world is free to play; the map and its assets stay the maker's. This is a custom SPDX
-- // licence reference, not a claim that the maker released them under AGPL.
-- license: 'LicenseRef-OpenVibe-Games',
-- license_url: null,
-- passport: contact('https://openvibe.games'),
-- },
-- });
-- if (rec) out.push(rec);
-- }
-- if (!body.more) break;
-- }
-- return out;
-- }
--
-- async function apply(source, records) {
-- const seen = [];
-- let changed = 0;
-- await s.tx(async (t) => {
-- for (const rec of records) {
-- const out = await entries.upsertFirstParty(rec, t);
-- if (!out.ok) {
-- log.warn(`[OpenVibe.Exchange] ${source} ${rec.sourceId}: ${out.message || 'refused'}`);
-- continue;
-- }
-- seen.push(rec.sourceId);
-- if (!out.unchanged) changed++;
-- }
-- });
-- // An empty answer is treated as a quiet source, not as "delete the catalogue". Only a read that listed
-- // something may retire what it no longer lists.
-- const retired = seen.length ? (await entries.retireFirstParty({ source, seen, now: now() })).retired : 0;
-- return { ok: true, imported: seen.length, changed, retired };
-- }
--
-- async function runOnce({ sources = SOURCES, fetchImpl: fetch = fetchImpl } = {}) {
-- if (stats.running) return { busy: true, at: new Date(now()).toISOString(), sources: {} };
-- stats.running = true;
-- const out = {};
-- try {
-- for (const source of sources) {
-- try {
-- if (source === 'design') {
-- const { themeRecords } = await designAll(fetch);
-- // The old job made one listing per token file. They were never meant to be separate
-- // listings, so remove exactly those official rows (a person's row is never touched).
-- const removed = await entries.removeFirstPartySource('design.tokens');
-- out.design = {
-- themes: await apply('design.theme', themeRecords),
-- removed_tokens: removed.removed,
-- };
-- } else if (source === 'obs') {
-- out.obs = await apply('live.obs', obsRecords());
-- } else if (source === 'games') {
-- out.games = await apply('games.world', await gamesRecords(fetch));
-- }
-- } catch (err) {
-- out[source] = { ok: false, imported: 0, changed: 0, retired: 0, error: (err && err.message) || String(err) };
-- log.warn(`[OpenVibe.Exchange] first-party ${source}: ${(err && err.message) || err}`);
-- }
-- }
-- stats.last = { at: new Date(now()).toISOString(), sources: out };
-- return stats.last;
-- } finally {
-- stats.running = false;
-- }
-- }
--
-- function start() {
-- if (!config.firstParty.enabled || config.drill) return false;
-- if (stats.started) return true;
-- stats.started = true;
-- const tick = async () => {
-- try { await runOnce(); } catch (err) { log.warn('[OpenVibe.Exchange] first-party refresh:', (err && err.message) || err); }
-- };
-- timeout = setTimeout(tick, 15_000);
-- if (timeout.unref) timeout.unref();
-- timer = setInterval(tick, config.firstParty.intervalMs);
-- if (timer.unref) timer.unref();
-- log.log(`[OpenVibe.Exchange] first-party refresh every ${Math.round(config.firstParty.intervalMs / 60_000)} min: ${SOURCES.join(', ')}`);
-- return true;
-- }
--
-- function stop() {
-- if (timeout) clearTimeout(timeout);
-- if (timer) clearInterval(timer);
-- timeout = null;
-- timer = null;
-- stats.started = false;
-- }
--
-- return { runOnce, start, stop, stats, sources: SOURCES, _designAll: designAll, _obsRecords: obsRecords, _gamesRecords: gamesRecords };
--}
--
--module.exports = { createFirstParty, SOURCES };
ddiff --git a/server/openapi.js b/server/openapi.js
nnew file mode 100644
iindex 0000000..1176310
---- /dev/null
++++ b/server/openapi.js
@@@ -0,0 +1,51 @@
++'use strict';
++
++/**
++ * /openapi.json: every OpenVibe site serves its API document (SEO-3). Exchange's API is AI's, reached at
++ * api.openvibe.exchange, so this reads that host's document server-side, points servers[0] at the public API
++ * host and keeps it for ten minutes. When the host cannot be reached the last copy is served, marked stale,
++ * and the page still answers: a crawler must never see a 404 for it.
++ */
++const FALLBACK = (config) => ({
++ openapi: '3.1.0',
++ info: {
++ title: 'OpenVibe Exchange API',
++ version: '1',
++ description: 'One OpenAI- and Anthropic-compatible endpoint over every model OpenVibe routes.',
++ },
++ servers: [{ url: config.api.publicUrl }],
++ paths: {},
++});
++
++function createOpenApi({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
++ const ttl = config.api.openapiCacheMs;
++ let at = 0;
++ let cached = null;
++
++ async function refresh() {
++ const res = await fetchImpl(config.api.openapiUrl, {
++ headers: { accept: 'application/json' },
++ signal: AbortSignal.timeout(config.api.timeoutMs),
++ });
++ if (!res.ok) throw new Error(`openapi answered ${res.status}`);
++ const doc = await res.json();
++ if (!doc || typeof doc !== 'object' || typeof doc.openapi !== 'string') throw new Error('openapi document is not OpenAPI');
++ doc.servers = [{ url: config.api.publicUrl }];
++ return doc;
++ }
++
++ return async function document() {
++ if (cached && now() - at < ttl) return { doc: cached, stale: false };
++ try {
++ cached = await refresh();
++ at = now();
++ return { doc: cached, stale: false };
++ } catch (err) {
++ if (log && typeof log.warn === 'function') log.warn('openapi: serving the last copy', { message: err.message });
++ if (cached) return { doc: cached, stale: true };
++ return { doc: FALLBACK(config), stale: true };
++ }
++ };
++}
++
++module.exports = { createOpenApi };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 71fe995..fceffe3 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -1,8 +1,8 @@
'use strict';
/**
-- * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
-- * This is Meme's layout with Exchange's name, navigation, room accent and assets.
++ * The page shell: openvibe-shared/shell (the Frame, the theme and the footer) around body markup the pages build
++ * with openvibe-shared/kit. Every page is complete without JavaScript.
*/
const crypto = require('crypto');
const fs = require('fs');
@@@ -16,14 +16,17 @@ const { render, esc } = require('./html');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Exchange';
--const TAGLINE = 'Trade what people make.';
++const TAGLINE = 'Every AI model, one account.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
{ label: 'Home', href: '/' },
-- { label: 'Listings', href: '/listings' },
-- { label: 'Installations', href: '/installs' },
-- { label: 'Sell or swap', href: '/submit' },
-- { label: 'Developers', href: '/developers' },
++ { label: 'Models', href: '/models' },
++ { label: 'Chat', href: '/chat' },
++ { label: 'API', href: '/api' },
++ { label: 'Sell compute', href: '/sell' },
++ { label: 'Uncensored', href: '/uncensored' },
++ { label: 'AI in OpenVibe', href: '/ai' },
++ { label: 'Status', href: '/status' },
];
const hashes = new Map();
@@@ -39,8 +42,10 @@ const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
let RELEASE = 'dev';
function setRelease(id) { if (id) RELEASE = String(id); }
++const active = (pathNow, href) => pathNow === href || (href !== '/' && pathNow.startsWith(`${href}/`));
++
/**
-- * o: title, description, body (html), viewer, config, path, index, cache, crumbs, jsonLd, styles
++ * o: title, description, body (html), viewer, config, path, index, cache, jsonLd, head, class
*/
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
@@@ -51,7 +56,7 @@ function renderPage(o) {
const nav = {
service: 'exchange',
apiBase: NETWORK_URL,
-- links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: active(pathNow, l.href) })),
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
@@@ -63,41 +68,40 @@ function renderPage(o) {
const account = signedIn
? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
: `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
-- const crumbs = (o.crumbs || []).length
-- ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${esc(c.label)}</a>` : esc(c.label)}`).join('')}</nav>`
-- : '';
-- const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);
return shell.page({
name: SITE_NAME,
lang: 'en',
title: o.title || `${SITE_NAME}: ${TAGLINE}`,
titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
siteName: SITE_NAME,
-- description: o.description || `${SITE_NAME} — ${TAGLINE}`,
++ description: o.description || `${SITE_NAME} — ${TAGLINE} Every model's price, speed and uptime, live; one OpenAI-compatible endpoint; chat with one model or three side by side.`,
canonical,
agentLayer: Boolean(o.agentLayer),
-- robots: o.index ? 'index, follow' : 'noindex, nofollow',
++ robots: o.index === false ? 'noindex, nofollow' : 'index, follow',
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
navLinks: NAV,
++ bodyClass: o.app ? 'ex-app' : undefined,
navbar: nav,
footer,
head: `<meta name="referrer" content="strict-origin-when-cross-origin">
${appIcon.headTags({ site: 'exchange' })}
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
++<link rel="stylesheet" href="${ovServe.url('kit-data.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${asset('css/exchange.css')}">
<script src="${ovServe.url('motion.js')}" defer></script>
++<script src="${ovServe.url('kit-web.js')}" defer></script>
<script src="${asset('js/exchange.js')}" defer></script>
--${render(scripts)}
--<meta name="ov-boost" content="exchange@${esc(RELEASE)}">
++${o.head || ''}
++<meta property="og:site_name" content="${SITE_NAME}">
++<meta name="theme-color" content="#0b0f17">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
body: `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
--<main id="main" class="ex ov-room" style="${rooms.style('exchange')}">
--${crumbs}
++<main id="main" class="ex ov-room ${o.class || ''}" style="${rooms.style('exchange')}">
${render(o.body)}
</main>
<script>
ddiff --git a/server/safety.js b/server/safety.js
ddeleted file mode 100644
iindex b1a90f5..0000000
---- a/server/safety.js
++++ /dev/null
@@@ -1,56 +0,0 @@
--'use strict';
--
--/**
-- * Free and swap safety rules, shared by the form and the API so a thing cannot be listed through one and refused
-- * by the other.
-- *
-- * v1 has no money anywhere: a listing is free or a swap, the passport's `spends` is empty, and a title, body or
-- * offer that asks for payment is refused. A short prohibited-items list covers what a marketplace must never
-- * carry: weapons, illegal drugs, malware and stolen credentials, sexual services, forged documents, live animals.
-- * This is a fixed list, not a classifier: it catches the plain cases and staff can hide anything else.
-- *
-- * `SAFETY` is the note every listing page carries word for word (the plan's "Rent's listing engine and safety
-- * rules"): see the person first where the swap is physical, never send money, keep the passport visible.
-- */
--
--const SAFETY = 'OpenVibe.Exchange v1 has no payments: a listing is free or a swap, and nobody should send money. Meet in a public place for a local swap, keep the conversation on the listing, and check the permission passport before you install anything.';
--
--const PROHIBITED = Object.freeze([
-- { code: 'weapons', label: 'weapons or ammunition', re: /\b(?:firearms?|guns?|shotguns?|rifles?|pistols?|ammunition|explosives?|silencers?|untraceable weapons?)\b/i },
-- { code: 'drugs', label: 'illegal drugs', re: /\b(?:cocaine|heroin|fentanyl|methamphetamine|mdma|ecstasy|illegal drugs?)\b/i },
-- { code: 'malware', label: 'malware or stolen access', re: /\b(?:malware|ransomware|keyloggers?|spyware|botnets?|credential dumps?|stolen (?:accounts?|credentials?|data|access))\b/i },
-- { code: 'sexual', label: 'sexual services or content', re: /\b(?:csam|child sexual|sexual services?|escort services?|pornography)\b/i },
-- { code: 'forged', label: 'forged or counterfeit documents', re: /\b(?:fake ids?|forged (?:ids?|passports?|documents?)|counterfeit (?:money|currency|documents?))\b/i },
-- { code: 'animals', label: 'live animals', re: /\b(?:live animals?|pupp(?:y|ies)|kittens?)\b/i },
-- { code: 'money', label: 'money or a payment', re: /\b(?:send cash|pay in cash|cash only|wire transfer|bank transfer|western union|moneygram|venmo|paypal|zelle|gift cards?|bitcoin|cryptocurrency|crypto payment|usd|eur|gbp)\b/i },
--]);
--
--const MONEY_AMOUNT = /(?:[£$€¥]\s?\d|\b\d+(?:[.,]\d{1,2})?\s?(?:usd|eur|gbp|vibes?|dollars?|euros?|pounds?)\b)/i;
--
--/** Plain text as it will be stored: CRLF normalised, no control characters (keeping tab and newline), trimmed. */
--function cleanText(value, max) {
-- const s = String(value == null ? '' : value)
-- .replace(/\r\n?/g, '\n')
-- // eslint-disable-next-line no-control-regex
-- .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
-- .trim();
-- return max != null ? s.slice(0, max) : s;
--}
--
--/** The first prohibited rule a set of public texts trips, or null. */
--function findProhibited(...texts) {
-- const haystack = texts.filter(Boolean).join('\n');
-- if (!haystack) return null;
-- for (const rule of PROHIBITED) if (rule.re.test(haystack)) return rule;
-- if (MONEY_AMOUNT.test(haystack)) return PROHIBITED.find((r) => r.code === 'money');
-- return null;
--}
--
--/** A refusal shape the store returns and the API/page layers turn into a problem or a form message. */
--function refusals(...texts) {
-- const hit = findProhibited(...texts);
-- if (!hit) return null;
-- return { status: 400, code: 'exchange.prohibited', message: `A listing or offer cannot carry ${hit.label} on OpenVibe.Exchange.`, field: 'text' };
--}
--
--module.exports = { SAFETY, PROHIBITED, findProhibited, refusals, cleanText, MONEY_AMOUNT };
ddiff --git a/server/search.js b/server/search.js
ddeleted file mode 100644
iindex ca47a17..0000000
---- a/server/search.js
++++ /dev/null
@@@ -1,103 +0,0 @@
--'use strict';
--
--/**
-- * OpenVibe.Exchange's Search Fabric feed. Every public listing is one `search.index-document@1` (owner `exchange`,
-- * type `listing`, page `/listings/<slug>`), written to `exchange_outbox` in the same transaction as the listing
-- * change; a listing that is not `listed` (draft, hidden, closed) writes a tombstone, which wins over any older
-- * revision. The document carries the maker's copy, the passport's declared permissions and the licence, so Search
-- * can filter free/swap and kinds without reading the listing page.
-- *
-- * No service grant is needed to queue the rows. openvibe-publishing/search-relay (server/app.js) delivers them to
-- * Search's owner API with this service's token (search.document.write), a few seconds after each change
-- * (onQueued) and every minute; without EXCHANGE_SEARCH_URL they wait.
-- */
--const { ids } = require('openvibe-contracts');
--
--const OWNER = 'exchange';
--const TYPE = 'listing';
--const EVENT_UPSERTED = 'exchange.index_document.upserted';
--const EVENT_DELETED = 'exchange.index_document.deleted';
--
--const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
--const pagePath = (listing) => `/listings/${listing.slug}`;
--
--function documentOf(listing, { baseUrl = 'https://openvibe.exchange' } = {}) {
-- const revision = Math.max(1, Number(listing && listing.revision) || 1);
-- if (!listing || listing.status !== 'listed') return { owner: OWNER, type: TYPE, id: listing.id, revision, deleted: true };
-- const p = listing.passport || {};
-- const reads = Array.isArray(p.reads) ? p.reads : [];
-- const writes = Array.isArray(p.writes) ? p.writes : [];
-- const contacts = Array.isArray(p.contacts) ? p.contacts : [];
-- // Search's snippet reads the summary and the body together: the body leaves out the title and summary it would
-- // repeat ("hot pink neon on …" twice in one result, 2026-11-02).
-- const body = [
-- listing.description || '',
-- listing.wants ? `Wanted in return: ${listing.wants}` : '',
-- reads.length ? `Reads: ${reads.join(', ')}` : '',
-- writes.length ? `Writes: ${writes.join(', ')}` : '',
-- contacts.length ? `Contacts: ${contacts.join(', ')}` : '',
-- `Licence: ${listing.license} · version ${listing.version} · ${listing.mode}`,
-- listing.home_url ? `Home: ${listing.home_url}` : '',
-- ].filter(Boolean).join('\n').slice(0, 48_000);
-- return {
-- owner: OWNER,
-- type: TYPE,
-- id: listing.id,
-- revision,
-- deleted: false,
-- visibility: 'public',
-- canonical_url: `${String(baseUrl).replace(/\/+$/, '')}${pagePath(listing)}`,
-- title: listing.title,
-- ...(listing.summary ? { summary: listing.summary } : {}),
-- body,
-- facets: {
-- kind: listing.kind,
-- mode: listing.mode,
-- license: listing.license,
-- creator: listing.creator,
-- official: Boolean(listing.official),
-- can_read: reads,
-- can_write: writes,
-- can_contact: contacts,
-- },
-- authorship: 'human',
-- publication_state: 'published',
-- published_at: iso(listing.listed_at || listing.created_at),
-- updated_at: iso(listing.updated_at),
-- indexability: { decision: 'index' },
-- };
--}
--
--function createSearchIndex({ config, s, baseUrl = config.baseUrl, onQueued = null }) {
-- const nowIso = () => new Date(s.now()).toISOString();
--
-- async function enqueue(topic, payload, t = null) {
-- const db = t || s.db;
-- const eventId = ids.newId('event', s.now());
-- await db.exec(
-- `INSERT INTO exchange_outbox (event_id, topic, payload, created_at, next_attempt_at)
-- VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,
-- [eventId, topic, JSON.stringify(payload), nowIso()],
-- );
-- if (onQueued) onQueued();
-- return eventId;
-- }
--
-- /** Queue the current document on the caller's transaction (upsert when listed, tombstone otherwise). */
-- async function sync(t, listing) {
-- const doc = documentOf(listing, { baseUrl });
-- return await enqueue(doc.deleted ? EVENT_DELETED : EVENT_UPSERTED, doc, t);
-- }
--
-- async function remove(t, id, revision) {
-- return await enqueue(EVENT_DELETED, { owner: OWNER, type: TYPE, id, revision: Math.max(1, Number(revision) || 1), deleted: true }, t);
-- }
--
-- async function pending() {
-- return Number(await s.db.value('SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE published_at IS NULL'));
-- }
--
-- return { sync, remove, enqueue, documentOf, pending, baseUrl: String(baseUrl).replace(/\/+$/, '') };
--}
--
--module.exports = { createSearchIndex, documentOf, pagePath, OWNER, TYPE, EVENT_UPSERTED, EVENT_DELETED };
ddiff --git a/server/store.js b/server/store.js
ddeleted file mode 100644
iindex d5508a4..0000000
---- a/server/store.js
++++ /dev/null
@@@ -1,859 +0,0 @@
--'use strict';
--
--/**
-- * Exchange's data: listings, their permission passports and conformance tests, sandbox trials, installations and
-- * free/swap offers.
-- *
-- * A listing is born `draft` and only its owner can list it, after every conformance test for the current version
-- * passed. A write that changes a public listing writes its Search document (or tombstone) in the same transaction
-- * (server/search.js). An installation is private: it never becomes a Search document. A sandbox trial is a
-- * manifest dry run, not a code execution — Exchange v1 never runs a listing's code (Run has no sandbox today).
-- *
-- * There is no money in v1: a listing's mode is `free` or `swap`, and its passport's `spends` must be empty.
-- * The passport is the listing's public declaration of what it may read, write, spend and contact; installs keep
-- * the exact passport they were installed with, so a later edit to the listing cannot widen an old install.
-- */
--const { cleanText, refusals, SAFETY } = require('./safety');
--
--const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
--const KINDS = Object.freeze(['agent', 'mcp_tool', 'template', 'theme', 'game_asset', 'service']);
--const MODES = Object.freeze(['free', 'swap']);
--const STATUSES = Object.freeze(['draft', 'listed', 'hidden', 'closed']);
--const CLOSED_REASONS = Object.freeze(['given', 'swapped', 'withdrawn']);
--const OFFER_KINDS = Object.freeze(['claim', 'swap']);
--const OFFER_STATUSES = Object.freeze(['open', 'accepted', 'declined', 'withdrawn']);
--const VERSION_RE = /^\d+\.\d+\.\d+$/;
--const LICENSE_RE = /^[A-Za-z0-9][A-Za-z0-9.+-]{1,63}$/;
--const SCOPE_RE = /^[a-z][a-z0-9_.:-]{1,63}$/;
--const CONTACT_RE = /^https:\/\/[^\s"'<>]{1,200}$/;
--const SECRET_VALUE_RE = /(?:secret|password|passwd|token|api[_-]?key|private[_-]?key|bearer)\s*[:=]|-----BEGIN [A-Z ]*PRIVATE KEY-----/i;
--/** A theme's preview palette: at most six plain hex colours, the only form a page may paint. */
--const HEX_RE = /^#[0-9a-f]{6}$/i;
--const MAX_PALETTE = 6;
--
--const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
--const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });
--
--function json(v, fallback) {
-- if (v == null) return fallback;
-- if (typeof v === 'object') return v;
-- try { return JSON.parse(v); } catch { return fallback; }
--}
--
--function stringArray(v) {
-- return Array.isArray(v) ? v.map(String) : [];
--}
--
--function wirePassport(v) {
-- const p = json(v, {});
-- return {
-- reads: stringArray(p.reads),
-- writes: stringArray(p.writes),
-- spends: stringArray(p.spends),
-- contacts: stringArray(p.contacts),
-- };
--}
--
--function wireManifest(v) {
-- const m = json(v, {});
-- return { name: m.name || '', entry: m.entry || '', runtime: m.runtime || null };
--}
--
--/** The downloads a listing carries: { format, label, url } only, in the order the maker listed them. */
--function wireFiles(v) {
-- const list = json(v, []);
-- if (!Array.isArray(list)) return [];
-- return list
-- .filter((f) => f && typeof f === 'object' && typeof f.format === 'string' && typeof f.url === 'string')
-- .map((f) => ({ format: String(f.format).slice(0, 40), label: String(f.label || f.format).slice(0, 80), url: String(f.url).slice(0, 300) }));
--}
--
--/**
-- * The preview palette a listing paints: up to six #rrggbb values, in order, duplicates dropped. Anything the
-- * source said that is not a plain hex colour (a name, a gradient, a url) never leaves this function.
-- */
--function wirePalette(v) {
-- const list = json(v, []);
-- if (!Array.isArray(list)) return [];
-- const out = [];
-- for (const entry of list) {
-- if (typeof entry !== 'string') continue;
-- const hex = entry.trim();
-- if (!HEX_RE.test(hex)) continue;
-- const colour = hex.toLowerCase();
-- if (out.includes(colour)) continue;
-- out.push(colour);
-- if (out.length === MAX_PALETTE) break;
-- }
-- return out;
--}
--
--function wireTest(row) {
-- if (!row) return null;
-- return { id: Number(row.id), name: row.name, status: row.status, detail: row.detail == null ? null : row.detail, ran_at: iso(row.ran_at) };
--}
--
--function wireListing(row, { tests = null, includeOwner = false, summaryTests = false } = {}) {
-- if (!row) return null;
-- const out = {
-- id: row.id,
-- slug: row.slug,
-- title: row.title,
-- kind: row.kind,
-- mode: row.mode,
-- summary: row.summary == null ? null : row.summary,
-- description: row.description == null ? null : row.description,
-- wants: row.wants == null ? null : row.wants,
-- license: row.license,
-- license_url: row.license_url == null ? null : row.license_url,
-- version: row.version,
-- creator: row.creator,
-- status: row.status,
-- revision: Number(row.revision) || 1,
-- passport: wirePassport(row.passport),
-- manifest: wireManifest(row.manifest),
-- files: wireFiles(row.files),
-- palette: wirePalette(row.palette),
-- home_url: row.home_url == null ? null : row.home_url,
-- official: Boolean(row.official),
-- listed_at: iso(row.listed_at),
-- closed_at: iso(row.closed_at),
-- closed_reason: row.closed_reason == null ? null : row.closed_reason,
-- created_at: iso(row.created_at),
-- updated_at: iso(row.updated_at),
-- };
-- if (includeOwner) out.owner = row.owner == null ? null : row.owner;
-- if (summaryTests) {
-- const count = Number(row.test_count) || 0;
-- const failed = Number(row.test_failed) || 0;
-- out.tests = { count, failed, verified: count > 0 && failed === 0 };
-- }
-- if (tests) out.tests = { count: tests.length, failed: tests.filter((t) => t.status === 'failed').length, verified: tests.length > 0 && tests.every((t) => t.status === 'passed'), rows: tests };
-- if (row.install_count != null) out.installs = Number(row.install_count) || 0;
-- if (row.offer_count != null) out.open_offers = Number(row.offer_count) || 0;
-- return out;
--}
--
--function wireTrial(row, listing = null) {
-- if (!row) return null;
-- return {
-- id: row.id,
-- listing_id: row.listing_id,
-- version: row.version,
-- status: row.status,
-- passport: wirePassport(row.passport),
-- checks: json(row.checks, []),
-- created_at: iso(row.created_at),
-- expires_at: iso(row.expires_at),
-- consumed_at: iso(row.consumed_at),
-- ...(listing ? { listing: { slug: listing.slug, title: listing.title, mode: listing.mode, version: listing.version } } : {}),
-- };
--}
--
--function wireInstall(row) {
-- if (!row) return null;
-- return {
-- id: row.id,
-- listing_id: row.listing_id,
-- version: row.version,
-- status: row.status,
-- passport: wirePassport(row.passport),
-- installed_at: iso(row.installed_at),
-- revoked_at: iso(row.revoked_at),
-- revision: Number(row.revision) || 1,
-- ...(row.slug ? { listing: { slug: row.slug, title: row.title, mode: row.mode, kind: row.kind, license: row.license } } : {}),
-- };
--}
--
--function wireOffer(row) {
-- if (!row) return null;
-- return {
-- id: row.id,
-- listing_id: row.listing_id,
-- kind: row.kind,
-- body: row.body,
-- give: row.give == null ? null : row.give,
-- from_name: row.from_name,
-- status: row.status,
-- created_at: iso(row.created_at),
-- decided_at: iso(row.decided_at),
-- ...(row.slug ? { listing: { slug: row.slug, title: row.title, mode: row.mode, owner: row.listing_owner || null } } : {}),
-- };
--}
--
--function normalizePassport(input) {
-- const p = input && typeof input === 'object' ? input : {};
-- const allowed = ['reads', 'writes', 'spends', 'contacts'];
-- const unknown = Object.keys(p).filter((k) => !allowed.includes(k));
-- if (unknown.length) return refusal(400, 'exchange.invalid', `Unknown passport field: ${unknown[0]}`, 'passport');
-- const out = {};
-- for (const key of ['reads', 'writes']) {
-- const list = p[key] == null ? [] : p[key];
-- if (!Array.isArray(list)) return refusal(400, 'exchange.invalid', `passport.${key} must be an array of permission names`, 'passport');
-- if (list.length > 20) return refusal(400, 'exchange.invalid', `passport.${key} may name at most 20 permissions`, 'passport');
-- const seen = new Set();
-- const cleaned = [];
-- for (const value of list) {
-- const s = cleanText(value, 64);
-- if (!SCOPE_RE.test(s)) return refusal(400, 'exchange.invalid', `passport.${key} must be lower-case permission names such as search.read`, 'passport');
-- if (!seen.has(s)) { seen.add(s); cleaned.push(s); }
-- }
-- out[key] = cleaned;
-- }
-- const spends = p.spends == null ? [] : p.spends;
-- if (!Array.isArray(spends)) return refusal(400, 'exchange.invalid', 'passport.spends must be an array', 'passport');
-- if (spends.length) return refusal(400, 'exchange.no_money', 'v1 is free and swap only: a passport may not declare a spend', 'passport');
-- out.spends = [];
-- const contacts = p.contacts == null ? [] : p.contacts;
-- if (!Array.isArray(contacts)) return refusal(400, 'exchange.invalid', 'passport.contacts must be an array of https origins', 'passport');
-- if (contacts.length > 20) return refusal(400, 'exchange.invalid', 'passport.contacts may name at most 20 origins', 'passport');
-- out.contacts = [];
-- const seenContacts = new Set();
-- for (const value of contacts) {
-- const s = cleanText(value, 200);
-- if (!CONTACT_RE.test(s)) return refusal(400, 'exchange.invalid', 'passport.contacts must be https origins, one per entry', 'passport');
-- try {
-- const u = new URL(s);
-- if (u.username || u.password || !u.hostname) throw new Error('credentials');
-- const normalized = `${u.origin}${u.pathname === '/' ? '' : u.pathname}`;
-- if (!seenContacts.has(normalized)) { seenContacts.add(normalized); out.contacts.push(normalized); }
-- } catch { return refusal(400, 'exchange.invalid', 'passport.contacts must be https origins, one per entry', 'passport'); }
-- }
-- return { ok: true, value: out };
--}
--
--function normalizeManifest(input, version) {
-- const m = input && typeof input === 'object' ? input : {};
-- const allowed = ['name', 'entry', 'runtime'];
-- const unknown = Object.keys(m).filter((k) => !allowed.includes(k));
-- if (unknown.length) return refusal(400, 'exchange.invalid', `A manifest may name only name, entry and runtime (not ${unknown[0]})`, 'manifest');
-- const name = cleanText(m.name, 80);
-- const entry = cleanText(m.entry, 200);
-- const runtime = cleanText(m.runtime, 80);
-- if (name.length < 2 || name.length > 80) return refusal(400, 'exchange.invalid', 'manifest.name must be 2 to 80 characters', 'manifest');
-- if (entry.length < 1 || entry.length > 200) return refusal(400, 'exchange.invalid', 'manifest.entry must be 1 to 200 characters', 'manifest');
-- if (runtime && (runtime.length < 1 || runtime.length > 80)) return refusal(400, 'exchange.invalid', 'manifest.runtime may be at most 80 characters', 'manifest');
-- for (const [key, value] of [['name', name], ['entry', entry], ['runtime', runtime]]) {
-- if (SECRET_VALUE_RE.test(value)) return refusal(400, 'exchange.manifest_secret', `manifest.${key} looks like a credential; a manifest is public metadata and must never carry a secret`, 'manifest');
-- if (/[\u0000-\u001f\u007f]/.test(value)) return refusal(400, 'exchange.invalid', 'a manifest is plain text: no control characters', 'manifest');
-- }
-- return { ok: true, value: { name, entry, runtime: runtime || null, version } };
--}
--
--/** A listing's downloads: at most 20 { format, label, url } entries with unique formats. */
--function normalizeFiles(input) {
-- if (input == null) return { ok: true, value: [] };
-- if (!Array.isArray(input)) return refusal(400, 'exchange.invalid', 'files must be an array of downloads', 'files');
-- if (input.length > 20) return refusal(400, 'exchange.invalid', 'a listing may carry at most 20 download files', 'files');
-- const out = [];
-- const seen = new Set();
-- for (const entry of input) {
-- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return refusal(400, 'exchange.invalid', 'each file must be { format, label, url }', 'files');
-- const unknown = Object.keys(entry).filter((k) => !['format', 'label', 'url'].includes(k));
-- if (unknown.length) return refusal(400, 'exchange.invalid', `Unknown file field: ${unknown[0]}`, 'files');
-- const format = cleanText(entry.format, 40);
-- const label = cleanText(entry.label, 80);
-- const url = cleanText(entry.url, 300);
-- if (!/^[a-z0-9][a-z0-9._-]{0,39}$/.test(format)) return refusal(400, 'exchange.invalid', 'file.format must be a lower-case id such as css or tokens.json', 'files');
-- if (label.length < 1 || label.length > 80) return refusal(400, 'exchange.invalid', 'file.label must be 1 to 80 characters', 'files');
-- if (!/^https:\/\/[^\s"'<>]{1,300}$/.test(url)) return refusal(400, 'exchange.invalid', 'file.url must be an https URL', 'files');
-- if (seen.has(format)) return refusal(400, 'exchange.invalid', `Two files share the format ${format}`, 'files');
-- seen.add(format);
-- out.push({ format, label, url });
-- }
-- return { ok: true, value: out };
--}
--
--/** Validate a new listing body. Returns { ok, value } or a refusal; the page form and the API share it. */
--function normalizeListing(body) {
-- const b = body && typeof body === 'object' ? body : {};
-- const allowed = ['title', 'kind', 'mode', 'summary', 'description', 'license', 'license_url', 'version', 'wants', 'manifest', 'passport', 'home_url', 'files'];
-- const unknown = Object.keys(b).filter((k) => !allowed.includes(k));
-- if (unknown.length) return refusal(400, 'exchange.invalid', `Unknown field: ${unknown[0]}`, unknown[0]);
-- const title = cleanText(b.title, 120);
-- if (title.length < 3 || title.length > 120) return refusal(400, 'exchange.invalid', 'title must be 3 to 120 characters', 'title');
-- if (!KINDS.includes(b.kind)) return refusal(400, 'exchange.invalid', `kind must be one of ${KINDS.join(', ')}`, 'kind');
-- if (!MODES.includes(b.mode)) return refusal(400, 'exchange.invalid', `mode must be one of ${MODES.join(', ')}`, 'mode');
-- const summary = cleanText(b.summary, 600);
-- if (summary.length < 3 || summary.length > 600) return refusal(400, 'exchange.invalid', 'summary must be 3 to 600 characters', 'summary');
-- const description = cleanText(b.description, 4000);
-- const license = cleanText(b.license, 64);
-- if (!LICENSE_RE.test(license)) return refusal(400, 'exchange.invalid', 'license must be an SPDX identifier such as CC0-1.0, MIT or AGPL-3.0-only', 'license');
-- const licenseUrl = cleanText(b.license_url, 300);
-- if (licenseUrl && !/^https:\/\/[^\s"'<>]{1,300}$/.test(licenseUrl)) return refusal(400, 'exchange.invalid', 'license_url must be an https URL', 'license_url');
-- const homeUrl = cleanText(b.home_url, 300);
-- if (homeUrl && !/^https:\/\/[^\s"'<>]{1,300}$/.test(homeUrl)) return refusal(400, 'exchange.invalid', 'home_url must be an https URL', 'home_url');
-- const version = cleanText(b.version, 32);
-- if (!VERSION_RE.test(version)) return refusal(400, 'exchange.invalid', 'version must be a semantic version such as 1.0.0', 'version');
-- const wants = cleanText(b.wants, 300);
-- if (b.mode === 'swap' && (wants.length < 3 || wants.length > 300)) return refusal(400, 'exchange.invalid', 'a swap listing must say what it would like in return (3 to 300 characters)', 'wants');
-- if (b.mode === 'free' && wants) return refusal(400, 'exchange.invalid', 'a free listing does not name what it wants in return', 'wants');
-- const manifest = normalizeManifest(b.manifest, version);
-- if (!manifest.ok) return manifest;
-- const passport = normalizePassport(b.passport);
-- if (!passport.ok) return passport;
-- const files = normalizeFiles(b.files);
-- if (!files.ok) return files;
-- const hit = refusals(title, summary, description, wants);
-- if (hit) return hit;
-- return {
-- ok: true,
-- value: {
-- title, kind: b.kind, mode: b.mode, summary, description: description || null, license,
-- license_url: licenseUrl || null, home_url: homeUrl || null, files: files.value, version, wants: wants || null,
-- manifest: manifest.value, passport: passport.value,
-- },
-- };
--}
--
--/** Does a first-party row still say the same thing as the source? */
--function firstPartyChanged(row, v, creator) {
-- const manifest = wireManifest(row.manifest);
-- const passport = wirePassport(row.passport);
-- return row.title !== v.title
-- || row.kind !== v.kind
-- || row.mode !== v.mode
-- || (row.summary || '') !== (v.summary || '')
-- || (row.description || '') !== (v.description || '')
-- || (row.wants || '') !== (v.wants || '')
-- || row.license !== v.license
-- || (row.license_url || '') !== (v.license_url || '')
-- || (row.home_url || '') !== (v.home_url || '')
-- || row.version !== v.version
-- || (row.creator || '') !== creator
-- || manifest.name !== v.manifest.name
-- || manifest.entry !== v.manifest.entry
-- || (manifest.runtime || '') !== (v.manifest.runtime || '')
-- || JSON.stringify(passport) !== JSON.stringify(v.passport)
-- || JSON.stringify(wireFiles(row.files)) !== JSON.stringify(v.files)
-- || JSON.stringify(wirePalette(row.palette)) !== JSON.stringify(v.palette);
--}
--
--/** The four conformance tests run for every version. A listing is not public until every one passed. */
--function checksFor(v) {
-- return [
-- { name: 'manifest', status: 'passed', detail: 'The manifest names the listing and its entry point; it carries no credential.' },
-- { name: 'passport', status: 'passed', detail: 'The permission passport is present and names only read, write and contact grants.' },
-- { name: 'licence', status: 'passed', detail: `The licence is ${v.license}.` },
-- { name: 'no_money', status: 'passed', detail: `Mode ${v.mode}; the passport declares no spend. ${SAFETY}` },
-- ];
--}
--
--function slugify(title) {
-- const s = String(title || '')
-- .toLowerCase()
-- .normalize('NFKD')
-- .replace(/[\u0300-\u036f]/g, '')
-- .replace(/[^a-z0-9]+/g, '-')
-- .replace(/^-+|-+$/g, '')
-- .slice(0, 70);
-- return s || 'listing';
--}
--
--function createStore(s, { search, config }) {
-- const db = s.db;
-- const q = (t) => t || db;
-- const sandboxTtlMs = Math.max(5, Number(config && config.sandbox && config.sandbox.ttlMinutes) || 60) * 60_000;
-- const sandboxPerDay = Math.max(1, Number(config && config.sandbox && config.sandbox.perDay) || 20);
--
-- async function bySlug(slug, t) {
-- return await q(t).maybe('SELECT * FROM exchange_listings WHERE slug = $1', [String(slug || '')]);
-- }
-- async function byId(id, t) {
-- return await q(t).maybe('SELECT * FROM exchange_listings WHERE id = $1', [String(id || '')]);
-- }
-- async function testsFor(listingId, version = null, t) {
-- const rows = version
-- ? await q(t).many('SELECT * FROM exchange_listing_tests WHERE listing_id = $1 AND version = $2 ORDER BY id ASC', [listingId, version])
-- : await q(t).many('SELECT * FROM exchange_listing_tests WHERE listing_id = $1 ORDER BY id ASC', [listingId]);
-- return rows.map(wireTest);
-- }
-- async function verified(listingId, version, t) {
-- const rows = await testsFor(listingId, version, t);
-- return rows.length > 0 && rows.every((r) => r.status === 'passed');
-- }
--
-- async function uniqueSlug(base, t) {
-- const root = String(base || 'listing').slice(0, 70).replace(/^-+|-+$/g, '') || 'listing';
-- let slug = root;
-- for (let i = 2; i < 200; i++) {
-- const found = await q(t).maybe('SELECT id FROM exchange_listings WHERE slug = $1', [slug]);
-- if (!found) return slug;
-- slug = `${root}-${i}`;
-- }
-- return `${root}-${Date.now().toString(36)}`;
-- }
--
-- async function event(t, listingId, from, to, reason, changedBy) {
-- await t.exec('INSERT INTO exchange_listing_events (listing_id, from_status, to_status, reason, changed_by) VALUES ($1, $2, $3, $4, $5)',
-- [listingId, from, to, reason || null, changedBy || null]);
-- }
--
-- /** Insert one draft and its conformance tests in the caller's transaction. Never publishes. */
-- async function createListing({ owner, creator, body }, t = null) {
-- const checked = normalizeListing(body);
-- if (!checked.ok) return checked;
-- const v = checked.value;
-- const run = async (tx) => {
-- const id = s.newId('exl');
-- const slug = await uniqueSlug(slugify(v.title), tx);
-- const creatorName = cleanText(creator, 80) || 'a maker';
-- const row = await tx.maybe(
-- `INSERT INTO exchange_listings (id, slug, title, kind, mode, summary, description, wants, license, license_url, home_url, files, version,
-- creator, owner, status, manifest, passport, revision, created_at, updated_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, $15, 'draft', $16::jsonb, $17::jsonb, 1, now(), now())
-- RETURNING *`,
-- [id, slug, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.home_url,
-- JSON.stringify(v.files), v.version, creatorName, owner, JSON.stringify(v.manifest), JSON.stringify(v.passport)],
-- );
-- const checks = checksFor(v);
-- for (const c of checks) {
-- await tx.exec('INSERT INTO exchange_listing_tests (listing_id, version, name, status, detail) VALUES ($1, $2, $3, $4, $5)',
-- [id, v.version, c.name, c.status, c.detail]);
-- }
-- await event(tx, id, null, 'draft', 'created', owner);
-- const tests = await testsFor(id, v.version, tx);
-- return { ok: true, listing: wireListing(row, { tests, includeOwner: true }) };
-- };
-- return t ? await run(t) : await s.tx(run);
-- }
--
-- /**
-- * Public listings, interleaved by kind so one kind cannot fill a page, newest first inside each kind. The cursor
-- * is an opaque (kind_rank, listed_at, id) keyset position from the interleaved order.
-- */
-- async function list({ limit = 20, cursor = null, mode = null, kind = null, q: text = null } = {}) {
-- const n = Math.min(50, Math.max(1, Number(limit) || 20));
-- const args = [];
-- let where = "status = 'listed'";
-- if (mode) { if (!MODES.includes(mode)) return { error: 'mode' }; args.push(mode); where += ` AND mode = $${args.length}`; }
-- if (kind) { if (!KINDS.includes(kind)) return { error: 'kind' }; args.push(kind); where += ` AND kind = $${args.length}`; }
-- const term = cleanText(text, 120);
-- if (term) {
-- args.push(`%${term.replace(/[%_\\]/g, (c) => `\\${c}`)}%`);
-- where += ` AND (title ILIKE $${args.length} OR summary ILIKE $${args.length} OR creator ILIKE $${args.length})`;
-- }
-- let pos = null;
-- if (cursor) {
-- try {
-- const decoded = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
-- if (Number.isInteger(decoded.r) && decoded.r > 0 && typeof decoded.t === 'string' && typeof decoded.i === 'string' && !Number.isNaN(Date.parse(decoded.t))) pos = decoded;
-- } catch { /* refused below */ }
-- if (!pos) return { error: 'cursor' };
-- }
-- let after = '';
-- if (pos) {
-- args.push(pos.r, pos.t, pos.i);
-- after = ` WHERE (kind_rank > $${args.length - 2} OR (kind_rank = $${args.length - 2} AND (listed_at, id) < ($${args.length - 1}::timestamptz, $${args.length})))`;
-- }
-- args.push(n + 1);
-- const rows = await db.many(
-- `WITH ranked AS (
-- SELECT l.*,
-- ROW_NUMBER() OVER (PARTITION BY l.kind ORDER BY l.listed_at DESC, l.id DESC) AS kind_rank,
-- (SELECT COUNT(*)::int FROM exchange_listing_tests t WHERE t.listing_id = l.id AND t.version = l.version AND t.status = 'passed') AS test_count,
-- (SELECT COUNT(*)::int FROM exchange_listing_tests t WHERE t.listing_id = l.id AND t.version = l.version AND t.status = 'failed') AS test_failed
-- FROM exchange_listings l WHERE ${where}
-- )
-- SELECT * FROM ranked${after}
-- ORDER BY kind_rank ASC, listed_at DESC, id DESC LIMIT $${args.length}`,
-- args,
-- );
-- const hasMore = rows.length > n;
-- const page = rows.slice(0, n);
-- const last = page[page.length - 1];
-- return {
-- listings: page.map((r) => wireListing(r, { summaryTests: true })),
-- next_cursor: hasMore && last
-- ? Buffer.from(JSON.stringify({ r: Number(last.kind_rank), t: iso(last.listed_at), i: last.id })).toString('base64url')
-- : null,
-- };
-- }
--
-- /** One listing with its conformance tests and public counters. */
-- async function detail(slug) {
-- const row = await bySlug(slug);
-- if (!row) return null;
-- const enriched = await db.maybe(
-- `SELECT l.*,
-- (SELECT COUNT(*)::int FROM exchange_installs i WHERE i.listing_id = l.id AND i.status = 'active') AS install_count,
-- (SELECT COUNT(*)::int FROM exchange_offers o WHERE o.listing_id = l.id AND o.status = 'open') AS offer_count
-- FROM exchange_listings l WHERE l.id = $1`,
-- [row.id],
-- );
-- return { row: enriched, listing: wireListing(enriched, { tests: await testsFor(row.id), includeOwner: true }) };
-- }
--
-- async function listMine(owner, { limit = 100 } = {}) {
-- const rows = await db.many('SELECT * FROM exchange_listings WHERE owner = $1 ORDER BY created_at DESC LIMIT $2', [owner, Math.min(200, Math.max(1, Number(limit) || 100))]);
-- const out = [];
-- for (const row of rows) out.push(wireListing(row, { tests: await testsFor(row.id) }));
-- return out;
-- }
--
-- async function counts() {
-- const row = await db.maybe(
-- `SELECT COUNT(*)::int AS listings,
-- COUNT(*) FILTER (WHERE mode = 'free')::int AS free,
-- COUNT(*) FILTER (WHERE mode = 'swap')::int AS swap,
-- (SELECT COUNT(DISTINCT owner)::int FROM exchange_listings WHERE status = 'listed' AND owner IS NOT NULL) AS makers,
-- (SELECT COUNT(*)::int FROM exchange_installs WHERE status = 'active') AS installs
-- FROM exchange_listings WHERE status = 'listed'`,
-- );
-- return row || { listings: 0, free: 0, swap: 0, makers: 0, installs: 0 };
-- }
--
-- /** Owner publishes a draft (or a closed one cannot be relisted). Every conformance test must have passed. */
-- async function publish(slug, { owner } = {}) {
-- return await s.tx(async (t) => {
-- const row = await bySlug(slug, t);
-- if (!row || row.owner !== owner) return refusal(404, 'exchange.not_found', 'No such listing of yours.');
-- if (row.status === 'listed') return { ok: true, listing: wireListing(row, { tests: await testsFor(row.id), includeOwner: true }), unchanged: true };
-- if (row.status === 'hidden') return refusal(409, 'exchange.hidden', 'Staff hid this listing; it cannot be relisted here.');
-- if (row.status === 'closed') return refusal(409, 'exchange.closed', 'A closed listing cannot be relisted; submit a new one.');
-- if (!(await verified(row.id, row.version, t))) return refusal(409, 'exchange.tests_not_passed', 'Every conformance test for this version must pass before the listing can be public.');
-- const updated = await t.maybe("UPDATE exchange_listings SET status = 'listed', listed_at = now(), revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [row.id]);
-- await event(t, row.id, row.status, 'listed', 'listed', owner);
-- await search.sync(t, wireListing(updated), []);
-- return { ok: true, listing: wireListing(updated, { tests: await testsFor(row.id), includeOwner: true }), unchanged: false };
-- });
-- }
--
-- async function close(slug, { owner, reason = 'withdrawn' } = {}) {
-- return await s.tx(async (t) => {
-- const row = await bySlug(slug, t);
-- if (!row || row.owner !== owner) return refusal(404, 'exchange.not_found', 'No such listing of yours.');
-- if (row.status === 'closed') return { ok: true, listing: wireListing(row, { tests: await testsFor(row.id), includeOwner: true }), unchanged: true };
-- const why = CLOSED_REASONS.includes(reason) ? reason : 'withdrawn';
-- const updated = await t.maybe("UPDATE exchange_listings SET status = 'closed', closed_reason = $2, closed_at = now(), revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [row.id, why]);
-- await t.exec("UPDATE exchange_offers SET status = 'declined', decided_at = now() WHERE listing_id = $1 AND status = 'open'", [row.id]);
-- await event(t, row.id, row.status, 'closed', why, owner);
-- await search.sync(t, wireListing(updated), []);
-- return { ok: true, listing: wireListing(updated, { tests: await testsFor(row.id), includeOwner: true }), unchanged: false };
-- });
-- }
--
-- async function hide(slug, { changedBy } = {}) {
-- return await s.tx(async (t) => {
-- const row = await bySlug(slug, t);
-- if (!row) return refusal(404, 'exchange.not_found', 'No such listing.');
-- if (row.status === 'hidden') return { ok: true, listing: wireListing(row, { tests: await testsFor(row.id), includeOwner: true }), unchanged: true };
-- const updated = await t.maybe("UPDATE exchange_listings SET status = 'hidden', revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [row.id]);
-- await event(t, row.id, row.status, 'hidden', 'staff hide', changedBy);
-- await search.sync(t, wireListing(updated), []);
-- return { ok: true, listing: wireListing(updated, { tests: await testsFor(row.id), includeOwner: true }), unchanged: false };
-- });
-- }
--
-- /** A sandbox trial: a manifest dry run against the passport; no listing code is executed. */
-- async function sandbox(slug, { subject }) {
-- const row = await bySlug(slug);
-- if (!row || row.status !== 'listed') return refusal(404, 'exchange.not_found', 'No such public listing.');
-- const tests = await testsFor(row.id, row.version);
-- const dayStart = new Date(s.now());
-- dayStart.setUTCHours(0, 0, 0, 0);
-- const used = Number(await db.value('SELECT COUNT(*)::int FROM exchange_sandbox_trials WHERE subject = $1 AND created_at >= $2::timestamptz', [subject, dayStart.toISOString()])) || 0;
-- if (used >= sandboxPerDay) return refusal(429, 'exchange.sandbox_limit', 'You have tried as many listings in the sandbox today as v1 allows; try again tomorrow.');
-- const passport = wirePassport(row.passport);
-- const verifiedTests = tests.length > 0 && tests.every((x) => x.status === 'passed');
-- const checks = [
-- { name: 'listed', status: 'passed', detail: 'The listing is public.' },
-- { name: 'tests', status: verifiedTests ? 'passed' : 'failed', detail: verifiedTests ? `${tests.length} conformance tests passed for version ${row.version}.` : 'The conformance tests for this version are not all passing.' },
-- { name: 'passport', status: passport.spends.length === 0 ? 'passed' : 'failed', detail: passport.spends.length === 0 ? 'The passport declares read, write and contact grants only; no spend.' : 'The passport declares a spend, which v1 does not allow.' },
-- { name: 'execution', status: 'passed', detail: 'Dry run only: Exchange v1 does not execute the listing. A real Run sandbox needs the run.sandbox.execute grant (README).' },
-- ];
-- const status = checks.every((c) => c.status === 'passed') ? 'passed' : 'failed';
-- const id = s.newId('exs');
-- const expiresAt = new Date(s.now() + sandboxTtlMs).toISOString();
-- const saved = await db.maybe(
-- `INSERT INTO exchange_sandbox_trials (id, listing_id, subject, version, status, passport, checks, created_at, expires_at)
-- VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, now(), $8::timestamptz) RETURNING *`,
-- [id, row.id, subject, row.version, status, JSON.stringify(passport), JSON.stringify(checks), expiresAt],
-- );
-- return { ok: true, trial: wireTrial(saved, row), checks, listing: wireListing(row) };
-- }
--
-- async function trialById(id, { subject }) {
-- const row = await db.maybe(
-- `SELECT t.*, l.slug, l.title, l.mode, l.version AS listing_version FROM exchange_sandbox_trials t
-- JOIN exchange_listings l ON l.id = t.listing_id WHERE t.id = $1 AND t.subject = $2`,
-- [String(id || ''), subject],
-- );
-- if (!row) return null;
-- const listing = { slug: row.slug, title: row.title, mode: row.mode, version: row.listing_version };
-- return { row, trial: wireTrial(row, listing), listing };
-- }
--
-- async function hasPassedTrial(listingId, subject, version) {
-- const row = await db.maybe(
-- `SELECT id FROM exchange_sandbox_trials
-- WHERE listing_id = $1 AND subject = $2 AND version = $3 AND status = 'passed' AND consumed_at IS NULL AND expires_at > $4::timestamptz
-- ORDER BY created_at DESC LIMIT 1`,
-- [listingId, subject, version, s.iso()],
-- );
-- return Boolean(row);
-- }
--
-- async function activeInstall(listingId, subject) {
-- const row = await db.maybe("SELECT * FROM exchange_installs WHERE listing_id = $1 AND subject = $2 AND status = 'active'", [listingId, subject]);
-- return wireInstall(row);
-- }
--
-- async function install(slug, { subject }) {
-- return await s.tx(async (t) => {
-- const row = await bySlug(slug, t);
-- if (!row || row.status !== 'listed') return refusal(404, 'exchange.not_found', 'No such public listing.');
-- const existing = await t.maybe("SELECT * FROM exchange_installs WHERE listing_id = $1 AND subject = $2 AND status = 'active'", [row.id, subject]);
-- if (existing) return { ok: true, install: wireInstall({ ...existing, slug: row.slug, title: row.title, mode: row.mode, kind: row.kind, license: row.license }), unchanged: true };
-- const trial = await t.maybe(
-- `SELECT * FROM exchange_sandbox_trials
-- WHERE listing_id = $1 AND subject = $2 AND version = $3 AND status = 'passed' AND consumed_at IS NULL AND expires_at > $4::timestamptz
-- ORDER BY created_at DESC LIMIT 1`,
-- [row.id, subject, row.version, s.iso()],
-- );
-- if (!trial) return refusal(409, 'exchange.sandbox_required', 'Try this version in the sandbox first; a passed trial is required before an install.');
-- const id = s.newId('exi');
-- const saved = await t.maybe(
-- `INSERT INTO exchange_installs (id, listing_id, subject, version, passport, status, installed_at, revision)
-- VALUES ($1, $2, $3, $4, $5::jsonb, 'active', now(), 1) RETURNING *`,
-- [id, row.id, subject, row.version, JSON.stringify(wirePassport(row.passport))],
-- );
-- await t.exec('UPDATE exchange_sandbox_trials SET consumed_at = now() WHERE id = $1', [trial.id]);
-- return { ok: true, install: wireInstall({ ...saved, slug: row.slug, title: row.title, mode: row.mode, kind: row.kind, license: row.license }), unchanged: false };
-- });
-- }
--
-- async function installsFor(subject) {
-- const rows = await db.many(
-- `SELECT i.*, l.slug, l.title, l.mode, l.kind, l.license FROM exchange_installs i
-- JOIN exchange_listings l ON l.id = i.listing_id WHERE i.subject = $1 ORDER BY i.installed_at DESC`,
-- [subject],
-- );
-- return rows.map(wireInstall);
-- }
--
-- async function revoke(installId, { subject }) {
-- return await s.tx(async (t) => {
-- const row = await t.maybe('SELECT * FROM exchange_installs WHERE id = $1 AND subject = $2', [String(installId || ''), subject]);
-- if (!row) return refusal(404, 'exchange.install_not_found', 'No such installation of yours.');
-- if (row.status === 'revoked') return { ok: true, install: wireInstall(row), unchanged: true };
-- const updated = await t.maybe("UPDATE exchange_installs SET status = 'revoked', revoked_at = now(), revision = revision + 1 WHERE id = $1 RETURNING *", [row.id]);
-- // v1 has no Network grant to revoke: this record is the entitlement. v2 calls Network's grant revoke
-- // with the exchange service token (README, "Grants it will need").
-- return { ok: true, install: wireInstall(updated), unchanged: false };
-- });
-- }
--
-- /** A free claim or a swap offer. The owner cannot offer on their own listing. */
-- async function offer(slug, { subject, fromName, body, give }) {
-- return await s.tx(async (t) => {
-- const row = await bySlug(slug, t);
-- if (!row || row.status !== 'listed') return refusal(404, 'exchange.not_found', 'No such public listing.');
-- if (row.owner === subject) return refusal(400, 'exchange.own_listing', 'This is your listing; you cannot claim or swap with yourself.');
-- const existing = await t.maybe("SELECT id FROM exchange_offers WHERE listing_id = $1 AND from_subject = $2 AND status = 'open'", [row.id, subject]);
-- if (existing) return refusal(409, 'exchange.offer_exists', 'You already have an open offer on this listing.');
-- const kind = row.mode === 'free' ? 'claim' : 'swap';
-- const message = cleanText(body, 1000);
-- if (message.length < 3 || message.length > 1000) return refusal(400, 'exchange.invalid', 'Say something to the maker (3 to 1000 characters).', 'body');
-- const giving = cleanText(give, 1000);
-- if (kind === 'swap' && (giving.length < 3 || giving.length > 1000)) return refusal(400, 'exchange.invalid', 'A swap offer must say what you offer in return (3 to 1000 characters).', 'give');
-- if (kind === 'claim' && giving && (giving.length < 3 || giving.length > 1000)) return refusal(400, 'exchange.invalid', 'A claim may include a note of 3 to 1000 characters, or nothing at all.', 'give');
-- const hit = refusals(message, giving);
-- if (hit) return hit;
-- const id = s.newId('exo');
-- const saved = await t.maybe(
-- `INSERT INTO exchange_offers (id, listing_id, from_subject, from_name, kind, body, give, status, created_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, 'open', now()) RETURNING *`,
-- [id, row.id, subject, cleanText(fromName, 80) || 'a maker', kind, message, giving || null],
-- );
-- return { ok: true, offer: wireOffer({ ...saved, slug: row.slug, title: row.title, mode: row.mode, listing_owner: row.owner }) };
-- });
-- }
--
-- async function offersFor(subject) {
-- const sent = await db.many(
-- `SELECT o.*, l.slug, l.title, l.mode, l.owner AS listing_owner FROM exchange_offers o
-- JOIN exchange_listings l ON l.id = o.listing_id WHERE o.from_subject = $1 ORDER BY o.created_at DESC`,
-- [subject],
-- );
-- const received = await db.many(
-- `SELECT o.*, l.slug, l.title, l.mode, l.owner AS listing_owner FROM exchange_offers o
-- JOIN exchange_listings l ON l.id = o.listing_id WHERE l.owner = $1 ORDER BY o.created_at DESC`,
-- [subject],
-- );
-- return { sent: sent.map(wireOffer), received: received.map(wireOffer) };
-- }
--
-- async function offersForListing(listingId) {
-- const rows = await db.many("SELECT * FROM exchange_offers WHERE listing_id = $1 AND status = 'open' ORDER BY created_at ASC", [listingId]);
-- return rows.map(wireOffer);
-- }
--
-- /** accept/decline are the owner's; withdraw is the offerer's. Accept closes the listing (given or swapped). */
-- async function decideOffer(offerId, { subject, action }) {
-- if (!['accept', 'decline', 'withdraw'].includes(action)) return refusal(400, 'exchange.invalid', 'action must be accept, decline or withdraw');
-- return await s.tx(async (t) => {
-- const o = await t.maybe('SELECT * FROM exchange_offers WHERE id = $1', [String(offerId || '')]);
-- if (!o) return refusal(404, 'exchange.offer_not_found', 'No such offer.');
-- const listing = await byId(o.listing_id, t);
-- if (!listing) return refusal(404, 'exchange.offer_not_found', 'No such offer.');
-- if (action === 'withdraw' && o.from_subject !== subject) return refusal(403, 'exchange.offer_not_yours', 'Only the person who made an offer can withdraw it.');
-- if (action !== 'withdraw' && listing.owner !== subject) return refusal(403, 'exchange.owner_only', 'Only the listing owner can accept or decline an offer.');
-- if (o.status !== 'open') return { ok: true, offer: wireOffer(o), unchanged: true };
-- const status = action === 'accept' ? 'accepted' : action === 'decline' ? 'declined' : 'withdrawn';
-- const updated = await t.maybe("UPDATE exchange_offers SET status = $2, decided_at = now() WHERE id = $1 RETURNING *", [o.id, status]);
-- let closed = null;
-- if (status === 'accepted') {
-- const why = listing.mode === 'free' ? 'given' : 'swapped';
-- const updatedListing = await t.maybe("UPDATE exchange_listings SET status = 'closed', closed_reason = $2, closed_at = now(), revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [listing.id, why]);
-- await t.exec("UPDATE exchange_offers SET status = 'declined', decided_at = now() WHERE listing_id = $1 AND id <> $2 AND status = 'open'", [listing.id, o.id]);
-- await event(t, listing.id, listing.status, 'closed', why, subject);
-- await search.sync(t, wireListing(updatedListing), []);
-- closed = wireListing(updatedListing, { tests: await testsFor(listing.id), includeOwner: true });
-- }
-- return { ok: true, offer: wireOffer(updated), closed, unchanged: false };
-- });
-- }
--
-- /** Imported/seedable listing used by scripts/seed-proof.js: goes through the same validation and tests. */
-- async function seedListing({ owner, creator, body }) {
-- return await createListing({ owner, creator, body });
-- }
--
-- /**
-- * One first-party listing, refreshed in place. It is owned by the network (owner NULL) and marked official, so it
-- * can never stand in for a person; source/source_id is its idempotency key. A person's row never enters this
-- * path, and a staff-hidden row is left hidden rather than resurrected.
-- */
-- async function upsertFirstParty({ source, sourceId, body, creator = 'OpenVibe', palette = [] }, t = null) {
-- const src = cleanText(source, 80);
-- const sid = cleanText(sourceId, 160);
-- if (!src || !sid) return refusal(400, 'exchange.invalid', 'a first-party listing needs a source and a source id');
-- const checked = normalizeListing(body);
-- if (!checked.ok) return checked;
-- const v = { ...checked.value, palette: wirePalette(palette) };
-- const run = async (tx) => {
-- const nowIso = new Date(s.now()).toISOString();
-- const creatorName = cleanText(creator, 80) || 'OpenVibe';
-- let row = await tx.maybe('SELECT * FROM exchange_listings WHERE source = $1 AND source_id = $2', [src, sid]);
-- if (!row) {
-- const id = s.newId('exl');
-- const slug = await uniqueSlug(slugify(v.title), tx);
-- row = await tx.maybe(
-- `INSERT INTO exchange_listings (id, slug, title, kind, mode, summary, description, wants, license, license_url, home_url, files, version,
-- creator, owner, status, manifest, passport, revision, official, source, source_id, created_at, updated_at, palette)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, NULL, 'draft', $15::jsonb, $16::jsonb, 1, true, $17, $18, $19::timestamptz, $19::timestamptz, $20::jsonb)
-- RETURNING *`,
-- [id, slug, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.home_url,
-- JSON.stringify(v.files), v.version, creatorName, JSON.stringify(v.manifest), JSON.stringify(v.passport), src, sid, nowIso, JSON.stringify(v.palette)],
-- );
-- await ensureTests(tx, row.id, v);
-- const listed = await tx.maybe(
-- `UPDATE exchange_listings SET status = 'listed', listed_at = $2::timestamptz, revision = revision + 1, updated_at = $2::timestamptz
-- WHERE id = $1 RETURNING *`,
-- [row.id, nowIso],
-- );
-- await listingEvent(tx, row.id, null, 'listed', 'first-party seed', 'service:exchange', nowIso);
-- await search.sync(tx, wireListing(listed));
-- return { ok: true, created: true, listing: wireListing(listed, { tests: await testsFor(listed.id, listed.version, tx), includeOwner: true }) };
-- }
-- if (row.status === 'hidden') {
-- return { ok: true, created: false, unchanged: true, skipped: 'hidden', listing: wireListing(row, { tests: await testsFor(row.id, row.version, tx), includeOwner: true }) };
-- }
-- if (row.status === 'listed' && !firstPartyChanged(row, v, creatorName)) {
-- return { ok: true, created: false, unchanged: true, listing: wireListing(row, { tests: await testsFor(row.id, row.version, tx), includeOwner: true }) };
-- }
-- const updated = await tx.maybe(
-- `UPDATE exchange_listings SET title = $2, kind = $3, mode = $4, summary = $5, description = $6, wants = $7,
-- license = $8, license_url = $9, home_url = $10, files = $11::jsonb, version = $12, creator = $13,
-- manifest = $14::jsonb, passport = $15::jsonb, official = true, status = 'listed',
-- listed_at = COALESCE(listed_at, $16::timestamptz), closed_at = NULL, closed_reason = NULL,
-- revision = revision + 1, updated_at = $16::timestamptz, palette = $17::jsonb
-- WHERE id = $1 RETURNING *`,
-- [row.id, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.home_url,
-- JSON.stringify(v.files), v.version, creatorName, JSON.stringify(v.manifest), JSON.stringify(v.passport), nowIso, JSON.stringify(v.palette)],
-- );
-- await ensureTests(tx, row.id, v);
-- await listingEvent(tx, row.id, row.status, 'listed', 'first-party refresh', 'service:exchange', nowIso);
-- await search.sync(tx, wireListing(updated));
-- return { ok: true, created: false, unchanged: false, listing: wireListing(updated, { tests: await testsFor(updated.id, updated.version, tx), includeOwner: true }) };
-- };
-- return t ? await run(t) : await s.tx(run);
-- }
--
-- /** Retire first-party listings a successful read no longer lists. A hidden row is already out of the catalogue. */
-- async function retireFirstParty({ source, seen = [], now = null }) {
-- const src = cleanText(source, 80);
-- if (!src) return { retired: 0 };
-- const nowIso = new Date(now == null ? s.now() : now).toISOString();
-- return await s.tx(async (tx) => {
-- const rows = await tx.many(
-- `SELECT * FROM exchange_listings
-- WHERE source = $1 AND official = true AND status = 'listed' AND NOT (source_id = ANY($2::text[]))`,
-- [src, seen],
-- );
-- for (const row of rows) {
-- const updated = await tx.maybe(
-- `UPDATE exchange_listings SET status = 'closed', closed_reason = 'withdrawn', closed_at = $2::timestamptz,
-- revision = revision + 1, updated_at = $2::timestamptz WHERE id = $1 RETURNING *`,
-- [row.id, nowIso],
-- );
-- await listingEvent(tx, row.id, row.status, 'closed', 'first-party refresh: the source no longer lists it', 'service:exchange', nowIso);
-- await search.sync(tx, wireListing(updated));
-- }
-- return { retired: rows.length };
-- });
-- }
--
-- /**
-- * Remove every official listing a retired source created. The old one-per-token-file rows are deleted, with a
-- * Search tombstone first, because they were never meant to be separate listings; a person's row is never touched.
-- */
-- async function removeFirstPartySource(source) {
-- const src = cleanText(source, 80);
-- if (!src) return { removed: 0 };
-- return await s.tx(async (tx) => {
-- const rows = await tx.many('SELECT id, revision FROM exchange_listings WHERE source = $1 AND official = true', [src]);
-- for (const row of rows) await search.remove(tx, row.id, Number(row.revision) + 1);
-- await tx.exec('DELETE FROM exchange_listings WHERE source = $1 AND official = true', [src]);
-- return { removed: rows.length };
-- });
-- }
--
-- async function ensureTests(tx, listingId, v) {
-- for (const c of checksFor(v)) {
-- await tx.exec(
-- `INSERT INTO exchange_listing_tests (listing_id, version, name, status, detail, ran_at)
-- VALUES ($1, $2, $3, $4, $5, now())
-- ON CONFLICT (listing_id, version, name) DO UPDATE SET status = EXCLUDED.status, detail = EXCLUDED.detail, ran_at = now()`,
-- [listingId, v.version, c.name, c.status, c.detail],
-- );
-- }
-- }
--
-- async function listingEvent(tx, listingId, from, to, reason, changedBy, at) {
-- await tx.exec(
-- 'INSERT INTO exchange_listing_events (listing_id, from_status, to_status, reason, changed_by, changed_at) VALUES ($1, $2, $3, $4, $5, $6::timestamptz)',
-- [listingId, from, to, reason, changedBy, at],
-- );
-- }
--
-- return {
-- wireListing, wireTrial, wireInstall, wireOffer, wirePassport, wireTest,
-- normalizeListing, normalizePassport, normalizeManifest, checksFor,
-- bySlug, byId, testsFor, verified, uniqueSlug,
-- createListing, list, detail, listMine, counts,
-- publish, close, hide,
-- sandbox, trialById,
-- hasPassedTrial, activeInstall, install, installsFor, revoke,
-- offer, offersFor, offersForListing, decideOffer,
-- seedListing, upsertFirstParty, retireFirstParty, removeFirstPartySource,
-- KINDS, MODES, STATUSES, CLOSED_REASONS, OFFER_KINDS, OFFER_STATUSES, SAFETY,
-- };
--}
--
--module.exports = {
-- createStore, slugify, normalizeListing, normalizePassport, normalizeManifest, checksFor,
-- wireListing, wireTrial, wireInstall, wireOffer, wirePassport, wireTest,
-- SLUG_RE, KINDS, MODES, STATUSES, CLOSED_REASONS, OFFER_KINDS, OFFER_STATUSES, VERSION_RE, LICENSE_RE,
--};
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
nnew file mode 100644
iindex 0000000..f73272e
---- /dev/null
++++ b/server/views/exchange.js
@@@ -0,0 +1,522 @@
++'use strict';
++
++/**
++ * The Exchange pages, built from openvibe-shared/kit: the home with live numbers, the model list and a model's
++ * page (every offer, the presets, the Try box, curl and the SDK), the API page, selling compute, the uncensored
++ * tier, AI in OpenVibe and the status board. Every number comes from the AI read API (server/ai.js); when that
++ * read is unavailable the page says so and shows no invented figure.
++ */
++const kit = require('openvibe-shared/kit');
++const { esc } = require('../render/html');
++const { mark, product, productMark } = require('./marks');
++
++const ENDPOINT = 'https://api.openvibe.exchange/v1/chat/completions';
++
++const money = (n, free = false) => (free ? 'free' : n == null ? '–' : n === 0 ? '$0' : `$${Number(n) < 0.1 ? Number(n).toPrecision(2) : Number(n).toFixed(2)}`);
++const inOut = (price) => (price ? `${money(price.in)} in · ${money(price.out)} out` : 'no per-token price');
++const ms = (n) => (n == null ? '–' : n < 1000 ? `${Math.round(n)} ms` : `${(n / 1000).toFixed(1)} s`);
++const pct = (n) => (n == null ? '–' : `${(n * 100).toFixed(1)}%`);
++const count = (n) => (n == null ? '–' : Number(n).toLocaleString('en-US'));
++const blended = (price) => (price ? (Number(price.in) + Number(price.out)) / 2 : Number.POSITIVE_INFINITY);
++const cheapest = (m) => (m.offers || []).filter((o) => o.price).sort((a, b) => blended(a.price) - blended(b.price))[0] || null;
++const hrefOf = (m) => `/m/${String(m.slug).split('/').map(encodeURIComponent).join('/')}`;
++const providerCount = (models) => new Set(models.flatMap((m) => (m.offers || []).map((o) => o.provider))).size;
++const offerCount = (models) => models.reduce((n, m) => n + (m.offers || []).length, 0);
++
++/**
++ * Only chat and vision models are sold here, and the read API's kind (when it sends one) is the truth; the
++ * older type field is the fallback until every deployment carries kind.
++ */
++const kindOf = (m) => String((m.kind || m.type || 'chat')).toLowerCase();
++const NOT_CHAT = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);
++const isChat = (m) => !NOT_CHAT.has(kindOf(m));
++const chatModels = (catalog) => (catalog.models || []).filter(isChat);
++
++/**
++ * A provider's internal label ("Shared provider (Live's shared key)", "Local model (OpenAI-compatible server
++ * on this host)") is an operations name, not something to print. It is shown as what it is in public: OpenVibe's
++ * own capacity. Community capacity is a person's machine, said as that.
++ */
++const INTERNAL = /provider|shared key|openai-compatible|local model|speech-to-text|whisper\.cpp|^local$|^shared$/i;
++function providerLabel(offer) {
++ if (!offer) return '';
++ const name = String(offer.provider_name || offer.provider || '');
++ if (INTERNAL.test(name) || INTERNAL.test(String(offer.provider || ''))) return offer.trust === 'community' ? 'A community machine' : 'OpenVibe';
++ return name;
++}
++
++function familyLabel(m) {
++ const name = String((m && m.family) || '');
++ return !name || INTERNAL.test(name) || name.length > 24 ? 'Other' : name;
++}
++
++/**
++ * The privacy label the design asks for, from facts only: a community machine sees the prompt; zero retention
++ * and no training is Private; no training alone is said as that. "Anonymized" is never claimed, because no
++ * offer here anonymises the request.
++ */
++function privacyOf(offer) {
++ if (!offer) return null;
++ if (offer.trust === 'community') return { label: 'Community', kind: 'accent', short: 'Community' };
++ const p = offer.data_policy;
++ if (p && p.anonymized === true) return { label: 'Anonymized', kind: 'accent', short: 'Anonymized' };
++ if (p && p.trains === false && p.retention_days === 0) return { label: 'Private', kind: 'ok', short: 'Private' };
++ if (p && p.trains === false) return { label: 'No training', kind: 'ok', short: 'No training' };
++ return { label: 'Standard', kind: '', short: 'Standard' };
++}
++
++const freeTier = (o) => Boolean(o && o.free_tier === true);
++
++function badgesOf(m, best) {
++ const out = [
++ m.uncensored ? kit.badge('18+', 'bad') : '',
++ m.community ? kit.badge('Community', 'accent') : '',
++ (m.offers || []).some(freeTier) ? kit.badge('Free to try') : '',
++ m.capabilities && m.capabilities.tools ? kit.badge('Tools') : '',
++ ];
++ const p = privacyOf(best);
++ if (p && p.label === 'Private') out.push(kit.badge('Private', 'ok'));
++ return out.join('');
++}
++
++function unavailable(catalog, what = 'The live catalog') {
++ if (!catalog.unavailable && !catalog.stale) return '';
++ return kit.notice({ kind: 'warn', text: `${what} is not reachable right now. Prices and speeds are read live from OpenVibe.AI; reload in a moment.` });
++}
++
++/** A card: one section, in the page's one column, with its links inside its padding. */
++function panel({ title, note = '', id, body, cls = '' }) {
++ // kit.section is already the card: do not wrap it in another one, and keep its body on the heading's line.
++ return kit.section({ title, note, id, cls: `ex-card${cls ? ` ${cls}` : ''}`, body: `<div class="ex-sec-body">${body}</div>` });
++}
++
++/** One model row: family mark, name and badges, the cheapest offer's price, speed and uptime, and Try it. */
++function modelRow(m, { withTry = true } = {}) {
++ const best = cheapest(m);
++ const bits = [];
++ if (best) bits.push(providerLabel(best), inOut(best.price), ms(best.speed.p50_ms), pct(best.uptime));
++ else if (m.offers[0]) bits.push(providerLabel(m.offers[0]), 'free capacity');
++ if (best && best.refusal_rate != null) bits.push(`refuses ${pct(best.refusal_rate)}`);
++ const pv = privacyOf(best || m.offers[0]);
++ if (pv) bits.push(pv.short);
++ return `<li class="ex-mrow">
++<a class="ex-mrow-main" href="${hrefOf(m)}">${mark(familyLabel(m))}<span class="ex-mrow-text"><span class="ex-mrow-title"><b>${esc(m.name)}</b>${badgesOf(m, best)}</span><small>${bits.map((b) => esc(b)).join(' · ')}</small></span></a>
++${withTry ? `<a class="ex-try-link" href="${hrefOf(m)}#try">Try it</a>` : ''}
++</li>`;
++}
++
++function modelList(models, { empty = 'No model matches yet.', withTry = true } = {}) {
++ if (!models.length) return kit.empty({ icon: 'spark', title: 'Nothing here yet', text: empty });
++ return `<ul class="ex-mlist">${models.map((m) => modelRow(m, { withTry })).join('')}</ul>`;
++}
++
++// ── Home ───────────────────────────────────────────────────
++function home({ catalog, usage, viewer, q = '', config = null }) {
++ const models = chatModels(catalog);
++ const paid = models.map((m) => ({ m, o: cheapest(m) })).filter((x) => x.o).sort((a, b) => blended(a.o.price) - blended(b.o.price));
++ const popular = [...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 8);
++ const bestPrice = paid[0] && paid[0].o;
++ const preview = (paid.length >= 2 ? paid.slice(0, 2) : models.slice(0, 2).map((m) => ({ m, o: cheapest(m) })));
++
++ const stats = [];
++ if (models.length) stats.push([models.length, 'models']);
++ if (providerCount(models)) stats.push([providerCount(models), 'providers']);
++ if (bestPrice) stats.push([money(bestPrice.price.in), 'cheapest in, per million']);
++ if (config) stats.push([config.chat.perDay, 'messages free every day']);
++ if (usage.today && usage.today.requests >= 1000) stats.push([count(usage.today.requests), 'requests today']);
++
++ return `<div class="ex-wrap ex-home">
++<section class="ex-hero">
++<div class="ex-hero-copy">
++<p class="ex-kicker">OpenVibe.Exchange</p>
++<h1>Every AI model, one account.</h1>
++<p class="ex-lead">DeepSeek, Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds: the live price, speed and uptime of every offer, one OpenAI-compatible endpoint, and a chat that puts two or three models side by side.</p>
++${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model: DeepSeek, Qwen, Kimi, GLM…', value: q, icon: 'search' })}
++<p class="ex-hero-actions">${kit.button({ href: '/models', label: 'All models' })} ${kit.button({ href: '/chat', label: 'Open chat', kind: 'quiet' })} ${kit.button({ href: '/api', label: 'The API', kind: 'ghost' })}</p>
++</div>
++${preview.length === 2 ? previewCard(preview) : ''}
++</section>
++${unavailable(catalog)}
++${stats.length ? kit.stats(stats) : ''}
++${panel({
++ title: 'Popular now', note: 'By real requests in the last seven days.', id: 'popular',
++ body: `${modelList(popular, { empty: 'No requests yet. The first one will show up here.', withTry: true })}
++${models.length ? `<p class="ex-more"><a href="/models">All ${models.length} models →</a></p>` : ''}`,
++ })}
++<div class="ex-lines">
++${lineCard({ href: '/uncensored', name: 'Uncensored (18+)', text: 'Abliterated models for adults, with the floor that holds on every request.', mark: mark('Llama') })}
++${lineCard({ href: '/sell', name: 'Run on people’s machines', text: 'Community GPUs through OpenVibe Node, labelled on every answer.', mark: mark('Qwen') })}
++${lineCard({ href: '/api', name: 'Bring your own key, 0% fee', text: 'Your provider key pays the provider directly; the router never adds a fee on it.', mark: mark('GLM') })}
++</div>
++${panel({
++ title: 'Use it from code', note: 'One endpoint, compatible with OpenAI and with Anthropic.', id: 'code', body: `
++<p class="ex-prose">Point an OpenAI client at <code>https://api.openvibe.exchange/v1</code>, or Claude Code at its <code>/v1/messages</code>. Model ids are <code>deepseek/deepseek-chat</code>, or <code>openvibe/auto</code> to let the router pick by your floor, ceiling and policy.</p>
++${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"deepseek/deepseek-chat","messages":[{"role":"user","content":"Hello"}]}'`, id: 'home-curl' })}
++<p class="ex-more"><a href="/api">Keys, usage and the full reference →</a></p>`,
++ })}
++${panel({
++ title: 'AI in OpenVibe', note: 'Which products run which models, and what each costs a day.', id: 'products',
++ body: productsSummary(usage),
++ })}
++</div>`;
++}
++
++/** The hero's side-by-side chat preview: real models and prices, example words, said as an example. */
++function previewCard(preview) {
++ const cells = preview.map(({ m, o }) => `<article class="ex-preview-cell">
++<header>${mark(familyLabel(m), { size: 22, label: false })}<b>${esc(m.name)}</b></header>
++<p>${esc(EXAMPLE_ANSWER[familyLabel(m)] || EXAMPLE_ANSWER.Default)}</p>
++<footer>${o ? `${money(o.price.in)} in · ${money(o.price.out)} out · ${ms(o.speed.p50_ms)}` : 'free capacity'}</footer>
++</article>`).join('');
++ return `<aside class="ex-preview" aria-label="A preview of the side-by-side chat">
++<p class="ex-preview-tag">A side-by-side answer <span>example words · live prices</span></p>
++<p class="ex-preview-q">“Summarize this contract in three bullets.”</p>
++<div class="ex-preview-grid">${cells}</div>
++<p class="ex-more"><a href="/chat">Open the real chat →</a></p>
++</aside>`;
++}
++
++const EXAMPLE_ANSWER = {
++ DeepSeek: 'Three bullets: the term renews yearly, either side may exit with 30 days’ notice, and liability is capped at the fees paid.',
++ Qwen: '• Renews yearly\n• 30 days’ notice to exit\n• Liability capped at fees paid',
++ Kimi: 'It runs a year at a time, needs 30 days’ notice to end, and caps liability at what was paid.',
++ GLM: 'Three points: a yearly term, a 30-day exit notice, and liability limited to the fees.',
++ MiniMax: 'The term is yearly; notice to end is 30 days; liability stops at the fees paid.',
++ Llama: 'Renews yearly. Exit needs 30 days’ notice. Liability is capped at the fees paid.',
++ Default: 'The term renews yearly, either side may exit with 30 days’ notice, and liability is capped at the fees paid.',
++};
++
++function lineCard({ href, name, text, mark: markHtml }) {
++ return `<a class="ex-line" href="${href}">${markHtml}<span><b>${esc(name)}</b><small>${esc(text)}</small></span><span class="ex-line-go" aria-hidden="true">→</span></a>`;
++}
++
++function productsSummary(usage) {
++ const products = usage.by_product || [];
++ if (usage.unavailable) return kit.notice({ kind: 'info', text: 'Product usage is read from OpenVibe.AI and is not available right now.' });
++ if (!products.length) return kit.empty({ icon: 'code', title: 'No product has run a model yet', text: 'When one does, its models and daily cost show here and on <a href="/ai">AI in OpenVibe</a>.' });
++ const rows = products.slice(0, 5).map((p) => {
++ const prod = product(p.product);
++ const top = (p.models || []).slice(0, 2).map((m) => m.model || m.provider).filter(Boolean).join(', ');
++ return `<li class="ex-product-row"><a href="/ai">${productMark(p.product, { size: 28 })}<span><b>${esc(prod.name)}</b><small>${top ? `runs ${esc(top)}` : 'runs models through OpenVibe.AI'}</small></span><span class="ex-trail"><b>${count(p.requests)}</b><small>requests</small></span></a></li>`;
++ }).join('');
++ return `<ul class="ex-product-list">${rows}</ul>`;
++}
++
++// ── Models ─────────────────────────────────────────────────
++function modelsPage({ catalog, q = '', family = '', sort = '' }) {
++ let models = chatModels(catalog);
++ const families = [...new Set(models.map(familyLabel))].filter((f) => f !== 'Other').sort();
++ if (family) models = models.filter((m) => familyLabel(m) === family);
++ if (q) {
++ const needle = String(q).toLowerCase();
++ models = models.filter((m) => `${m.name} ${familyLabel(m)} ${m.slug} ${m.offers.map(providerLabel).join(' ')}`.toLowerCase().includes(needle));
++ }
++ if (sort === 'cheap') models = [...models].sort((a, b) => blended((cheapest(a) || { price: null }).price) - blended((cheapest(b) || { price: null }).price));
++ else if (sort === 'fast') models = [...models].sort((a, b) => (cheapest(a) ? cheapest(a).speed.p50_ms || 1e9 : 1e9) - (cheapest(b) ? cheapest(b).speed.p50_ms || 1e9 : 1e9));
++ else models = [...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0));
++
++ const links = [['All', '/models'], ['Cheapest', '/models?sort=cheap'], ['Fastest', '/models?sort=fast']].map(([label, href]) => `<a class="ex-chip${(sort === 'cheap' && href.includes('cheap')) || (sort === 'fast' && href.includes('fast')) || (!sort && href === '/models') ? ' is-on' : ''}" href="${href}">${label}</a>`).join('');
++ const familyLinks = families.slice(0, 10).map((f) => `<a class="ex-chip${f === family ? ' is-on' : ''}" href="/models?family=${encodeURIComponent(f)}">${esc(f)}</a>`).join('');
++
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'Models', sub: `${models.length} model${models.length === 1 ? '' : 's'} · ${offerCount(models)} offers, live`, actions: [kit.button({ href: '/chat', label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
++${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model or a provider', value: q, icon: 'search' })}
++<div class="ex-chips" role="group" aria-label="Sort">${links}</div>
++${families.length ? `<div class="ex-chips" role="group" aria-label="Family">${familyLinks}</div>` : ''}
++${unavailable(catalog)}
++${panel({ title: '', body: modelList(models, { empty: 'No model matches that search. Try a family (DeepSeek, Qwen, Kimi, GLM) or a provider.' }) })}
++<p class="ex-more"><a href="/status">Uptime and incidents →</a></p>
++</div>`;
++}
++
++// ── One model ──────────────────────────────────────────────
++function modelPage({ model, viewer, result = null, problem = null, text = '', adult = false }) {
++ const offers = model.offers || [];
++ const best = cheapest(model);
++ const badges = [model.uncensored ? kit.badge('18+', 'bad') : '', model.community ? kit.badge('Community', 'accent') : '', offers.some(freeTier) ? kit.badge('Free to try') : ''].join('');
++ const stats = [
++ best ? [money(best.price.in), 'cheapest in, per M'] : null,
++ best ? [money(best.price.out), 'cheapest out, per M'] : null,
++ model.context ? [count(model.context), 'context, tokens'] : null,
++ best ? [ms(best.speed.p50_ms), 'typical first answer'] : null,
++ best ? [pct(best.uptime), 'uptime, 7 days'] : null,
++ ].filter(Boolean);
++
++ const columns = [
++ { key: 'provider', label: 'Offer', primary: true, html: true },
++ { key: 'price', label: 'In / out per M', html: true },
++ { key: 'speed', label: 'Speed', html: true },
++ { key: 'uptime', label: 'Uptime' },
++ { key: 'privacy', label: 'Privacy', html: true },
++ { key: 'policy', label: 'Data policy', html: true },
++ { key: 'try', label: 'Try', html: true },
++ ];
++ const rows = offers.map((o) => {
++ const p = privacyOf(o);
++ return {
++ provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a>${o.trust === 'community' ? ` ${kit.badge('Community', 'accent')}` : ''}${freeTier(o) ? ` ${kit.badge('Free to try')}` : ''}`,
++ price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}` : '<span class="ex-muted">free capacity</span>',
++ speed: `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,
++ uptime: o.uptime == null ? '–' : pct(o.uptime),
++ privacy: p ? `${kit.badge(p.label, p.kind)}${o.refusal_rate != null ? `<br><small>refuses ${pct(o.refusal_rate)}</small>` : ''}` : '–',
++ policy: policyText(o.data_policy),
++ try: '<a class="ex-mini" href="#try">Try it</a>',
++ };
++ });
++
++ const bestOffer = offers.find((o) => o.id) || offers[0];
++ const modelId = (bestOffer && bestOffer.id) || model.canonical;
++ const code = `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"${modelId}","messages":[{"role":"user","content":"Hello"}]}'`;
++ const sdk = `import OpenAI from 'openai';\nconst ai = new OpenAI({ baseURL: 'https://api.openvibe.exchange/v1', apiKey: process.env.OPENVIBE_KEY });\nconst r = await ai.chat.completions.create({ model: '${modelId}', messages: [{ role: 'user', content: 'Hello' }] });`;
++
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: model.name, sub: `${familyLabel(model)}${model.variant ? ` · ${model.variant}` : ''} · ${offers.length} offer${offers.length === 1 ? '' : 's'}`, back: { href: '/models', label: 'Models' }, actions: badges ? [badges] : [] })}
++${stats.length ? kit.stats(stats) : ''}
++${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}
++${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}
++${panel({ title: 'Presets', note: 'A preset fixes the router’s floor, ceiling and policy, and you name it as the model.', id: 'presets', body: presetsBox() })}
++${panel({ title: 'Try it', id: 'try', body: tryBox(model, viewer, { result, problem, text, adult }) })}
++${panel({ title: 'Use it from code', id: 'code', body: `${kit.snippet({ code, id: 'model-curl' })}
++${kit.snippet({ code: sdk, id: 'model-sdk' })}
++<p class="ex-more">The endpoint, keys and usage: <a href="/api">the API</a>.</p>` })}
++</div>`;
++}
++
++const PRESETS = [
++ ['openvibe/economy', 'Cheapest', 'The lowest cost per successful answer.'],
++ ['openvibe/fast', 'Fastest', 'The quickest measured offer.'],
++ ['openvibe/private', 'Private', 'Only capacity OpenVibe runs or that sits on a local machine.'],
++ ['openvibe/auto', 'Balanced', 'Cost, health and latency weighed together. The default.'],
++];
++
++function presetsBox() {
++ const cards = PRESETS.map(([id, name, text]) => `<article class="ex-preset">
++<header><b>${esc(name)}</b><code>model: "${esc(id)}"</code></header>
++<p>${esc(text)}</p>
++</article>`).join('');
++ return `<div class="ex-presets">${cards}</div>
++<p class="ex-prose">These four aliases work today. Saved presets (<code>model: "@preset/cheapest"</code>) add your own floor, ceiling, quantization, region, data policy and <code>max_price</code>; they arrive with the router’s presets.</p>`;
++}
++
++function tryBox(model, viewer, { result, problem, text, adult }) {
++ const canTry = viewer && viewer.kind === 'user' && viewer.subject;
++ const blocked = model.uncensored && !adult;
++ if (blocked) {
++ return kit.card({ tone: 'wait', icon: 'shield', title: 'This is an uncensored model', text: 'Adults only, with the floor. Confirm you are 18 or over to use it.', html: '<p><a class="ovk-btn ovk-btn--primary" href="/uncensored">Read the floor and confirm</a></p>' });
++ }
++ if (!canTry) {
++ return `<div class="ex-signin">${kit.button({ href: `/auth/login?next=${encodeURIComponent(hrefOf(model))}`, label: 'Sign in to try it' })}<span class="ex-muted">Free daily allowance, no card.</span></div>`;
++ }
++ return `<form class="ovk-form ex-try" method="post" action="${hrefOf(model)}/try">
++${problem ? kit.notice({ kind: 'error', text: problem }) : ''}
++<label class="ex-label" for="try-text">Try ${esc(model.name)}</label>
++<textarea id="try-text" name="text" rows="4" maxlength="4000" required placeholder="Ask it anything. The answer is one run on your own allowance.${model.uncensored ? ' The floor applies.' : ''}">${esc(text)}</textarea>
++<div class="ex-try-row">${kit.button({ label: 'Send', type: 'submit' })}<span class="ex-muted">Free daily allowance; the run’s cost is shown with the answer.</span></div>
++</form>
++${result ? resultCard(result) : ''}`;
++}
++
++function policyText(p) {
++ if (!p) return '<span class="ex-muted">not stated</span>';
++ const bits = [];
++ bits.push(p.trains === false ? 'no training' : p.trains === true ? 'may train' : 'training not stated');
++ if (p.retention_days != null) bits.push(p.retention_days === 0 ? 'no retention' : `kept ${p.retention_days} d`);
++ if (p.region) bits.push(esc(p.region));
++ return bits.join(' · ');
++}
++
++function resultCard(result) {
++ const facts = [
++ ['Offer', result.provider ? (INTERNAL.test(String(result.provider)) ? 'OpenVibe' : result.provider) : '–'],
++ ['Cost', result.cost_usd == null ? '–' : `$${Number(result.cost_usd).toFixed(6)}`],
++ ['Latency', result.latency_ms == null ? '–' : ms(result.latency_ms)],
++ ['Tokens', `${count(result.tokens_in)} in · ${count(result.tokens_out)} out`],
++ ['Run', result.run_id || '–'],
++ ];
++ return kit.receipt({
++ title: result.model ? `${result.model} answered` : 'The answer',
++ body: `<div class="ex-answer">${esc(result.content || '(no text)')}</div>`,
++ facts,
++ });
++}
++
++// ── API ────────────────────────────────────────────────────
++function apiPage({ catalog, usage }) {
++ const models = chatModels(catalog).filter((m) => !m.uncensored).slice(0, 12);
++ const rows = models.map((m) => ({ model: `<code>${esc(m.slug)}</code>`, family: esc(familyLabel(m)), context: m.context || null }));
++ const byDay = (usage.by_day || []).slice(-14).reverse();
++ const usageRows = byDay.map((d) => ({ day: d.day, requests: count(d.requests), tokens: count((d.tokens_in || 0) + (d.tokens_out || 0)), cost: d.cost_usd == null ? '–' : `$${Number(d.cost_usd).toFixed(4)}` }));
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: '/chat', label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
++${panel({ title: 'One endpoint', body: `
++<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>
++${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"openvibe/auto","messages":[{"role":"user","content":"Hello"}]}'`, id: 'api-curl' })}
++<h3 class="ex-h3">Keys</h3>
++<p class="ex-prose">Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time. They are shown once and never stored here.</p>
++<p>${kit.button({ href: 'https://openvibe.network/developers', label: 'Make a key on Network' })}</p>
++<h3 class="ex-h3">Model ids</h3>
++<p class="ex-prose"><code>openvibe/auto</code> lets the router pick by your floor, ceiling and policy, and the answer says which model ran and why. An exact id pins one artifact. Bring your own provider key and those requests pay the provider directly, at no fee from us.</p>
++${kit.table({ columns: [{ key: 'model', label: 'Model', primary: true, html: true }, { key: 'family', label: 'Family' }, { key: 'context', label: 'Context', num: true }], rows, label: 'Model ids' })}
++` })}
++${panel({ title: 'Usage', note: 'Aggregate, from OpenVibe.AI: no prompt and no person.', id: 'usage', body: usage.unavailable ? kit.notice({ kind: 'info', text: 'Usage is not available right now.' }) : (usageRows.length ? kit.table({ columns: [{ key: 'day', label: 'Day', primary: true }, { key: 'requests', label: 'Requests' }, { key: 'tokens', label: 'Tokens' }, { key: 'cost', label: 'Cost' }], rows: usageRows, label: 'Usage by day', caption: 'The last 14 days, all products and people.' }) : kit.empty({ icon: 'spark', title: 'No requests yet', text: 'The first call through the endpoint will show here.' })) })}
++${panel({ title: 'What it costs', body: '<p class="ex-prose">Every price is the offer\'s own, per million tokens, and the answer carries the exact cost of that run. A small free daily allowance comes with every account; it grows with account age and contribution. Paid use, when the owner turns money on, is at cost plus a margin and shown before the request.</p>' })}
++</div>`;
++}
++
++// ── Sell compute ───────────────────────────────────────────
++function sellPage({ catalog, usage }) {
++ const offers = (catalog.models || []).flatMap((m) => m.offers.filter((o) => o.trust === 'community').map((o) => ({ model: m, offer: o })));
++ const first = offers[0];
++ const seller = first
++ ? sellerCard({ machine: providerLabel(first.offer), model: first.model, offer: first.offer, example: false })
++ : sellerCard({ machine: 'RTX 3090', model: { name: 'Qwen 32B', family: 'Qwen' }, offer: { speed: { p50_ms: 24 }, uptime: 0.994 }, example: true });
++ const machineRows = offers.slice(0, 10).map(({ model, offer }) => ({
++ model: `<a href="${hrefOf(model)}">${esc(model.name)}</a>`, provider: esc(providerLabel(offer)),
++ price: offer.price ? `${money(offer.price.in)} / ${money(offer.price.out)}` : 'volunteer (free)',
++ uptime: offer.uptime == null ? '–' : pct(offer.uptime),
++ }));
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'Sell your compute', sub: 'Turn a GPU or a spare machine into credits.', actions: [kit.button({ href: 'https://openvibe.node', label: 'Get Node', kind: 'quiet' })] })}
++${panel({ title: 'A machine on the network', id: 'seller', body: `${seller}<p class="ex-prose">Every offer shows this card: the machine, what it runs, how fast it answers, its uptime, and whether it is verified.</p>` })}
++${panel({ title: 'Turn it on', body: `
++<p class="ex-prose">Install <a href="https://openvibe.node">OpenVibe Node</a> on the machine, open the AI plugin, and turn on <strong>Offer to the network</strong>. Choose the models it serves, the hours, how many requests at once, a price (0 to volunteer), and who may use it: everyone, your followers, or a group.</p>
++<p class="ex-prose"><strong>Idle-only:</strong> it pauses while you game, stream or render, so the machine stays yours. Flip it back when you are done.</p>
++<p class="ex-prose">A new machine serves the free tier until it is trusted: canary prompts with known answers, a model fingerprint that proves the declared model and quantization, and spot re-runs (about 1% of requests) compared on a trusted machine. Uptime and latency build your reputation score. Verified tokens earn credits, usable across OpenVibe.</p>
++<p class="ex-prose"><strong>Kudos for volunteers:</strong> an offer at 0 earns kudos that never expire and buy queue priority and bigger free limits. Kudos are never cash.</p>
++<p class="ex-prose"><strong>Privacy, said plainly:</strong> a community machine sees the prompt. Only requests that allow community capacity ever go there, and the answer is labelled Community.</p>
++<div class="ex-steps"><ol><li>Install Node and sign in.</li><li>Open the AI plugin and pick the models it can serve.</li><li>Set hours, concurrency, price, idle-only and who may use it.</li><li>Watch tokens served, earnings and verification on this page.</li></ol></div>
++<p>${kit.button({ href: 'https://openvibe.node', label: 'Install OpenVibe Node' })}</p>` })}
++${panel({ title: 'Your dashboard', id: 'dashboard', body: kit.empty({ icon: 'spark', title: 'No machine offered yet', text: 'When Node is offering capacity, its tokens served, credits, uptime, verification and reputation show here.' }) })}
++${panel({ title: 'What is not sold here', note: 'Said plainly, because it gets accounts banned elsewhere.', id: 'not-sold', body: `
++<ul class="ex-list">
++<li><strong>Personal subscription plans</strong> (Codex, ChatGPT, Claude, MiniMax and others). Their terms make them personal, and pooled accounts get banned. A plan works for its owner on their own machine, through <a href="https://openvibe.codes">OpenVibe.Codes</a>.</li>
++<li><strong>Personal API keys.</strong> A key serves its owner only, never a pool.</li>
++<li><strong>What is sold:</strong> compute people own, and capacity OpenVibe buys under terms that allow resale.</li>
++</ul>
++<p class="ex-prose">A contributor's offer obeys the same content floor as every offer here. An offer that breaks it is removed, and so is the account.</p>` })}
++${panel({ title: 'Machines on the network now', id: 'community', body: machineRows.length ? kit.table({ columns: [{ key: 'model', label: 'Model', primary: true, html: true }, { key: 'provider', label: 'Machine' }, { key: 'price', label: 'In / out per M' }, { key: 'uptime', label: 'Uptime' }], rows: machineRows, label: 'Community offers', empty: 'No community machine is offering capacity yet.' }) : kit.empty({ icon: 'robot', title: 'No community capacity yet', text: 'Be the first: the owner’s own machine is off by default, and volunteers set the free tier.' }) })}
++</div>`;
++}
++
++function sellerCard({ machine, model, offer, example }) {
++ const speed = offer.speed && offer.speed.p50_ms != null ? `answers in ${ms(offer.speed.p50_ms)}` : 'speed not measured yet';
++ const label = /owner|volunteer|machine/i.test(String(machine)) ? '@name’s own machine' : `@name’s ${machine}`;
++ return `<article class="ex-seller${example ? ' is-example' : ''}">
++<span class="ex-mk ex-mk--machine" style="--ex-mk-size:36px" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 6h16v9H4zM8 19h8M12 15v4"/></svg></span>
++<div><b>${esc(label)}</b>
++<p>${esc(model.name)} · ${speed} · ${offer.uptime == null ? 'uptime not measured yet' : `${pct(offer.uptime)} uptime`} · verified</p></div>
++${example ? '<span class="ex-badge-note">Example</span>' : ''}
++</article>`;
++}
++
++// ── Uncensored ─────────────────────────────────────────────
++function uncensoredPage({ catalog, adult, signedIn }) {
++ const models = (catalog.models || []).filter((m) => m.uncensored);
++ const gate = !signedIn
++ ? kit.section({ title: 'Adults only', body: `<p class="ex-prose">The uncensored tier is for adults. Sign in first (the confirmation sits on your account), then confirm you are 18 or over.</p><p>${kit.button({ href: '/auth/login?next=%2Funcensored', label: 'Sign in with OpenVibe' })}</p>` })
++ : !adult
++ ? kit.section({ title: 'Confirm you are 18 or over', body: `<p class="ex-prose">Uncensored models are abliterated builds. They will answer what a filtered model refuses. The floor below applies to every request, and a breach ends the account.</p>${floorText()}<form method="post" action="/uncensored/confirm" class="ovk-form ex-confirm"><label class="ex-check"><input type="checkbox" name="adult" value="yes" required> <span>I am 18 or over and I accept the floor.</span></label>${kit.button({ label: 'Confirm', type: 'submit' })}</form>` })
++ : '';
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'Uncensored', sub: 'Abliterated models, for adults. The same floor, always.', actions: [kit.badge('18+', 'bad')] })}
++${gate || ''}
++${panel({ title: 'The floor on every offer, uncensored or not', id: 'floor', body: floorText(true) })}
++${adult ? panel({ title: 'Uncensored models', id: 'models', body: modelList(models, { empty: 'No uncensored model is live right now. Featherless and Refuseless offers appear here when their keys are on.' }) }) : ''}
++${panel({ title: 'How to use it', body: '<p class="ex-prose">Pick an uncensored model in <a href="/chat">chat</a>, or send its exact id to the API. Your 18+ confirmation sits on your account, so it follows you here; the floor applies to every request on this tier, and a breach ends the account.</p>' })}
++</div>`;
++}
++
++function floorText() {
++ const body = `<ul class="ex-list ex-floor">
++<li>No sexual content involving minors.</li>
++<li>No non-consensual sexual content of real people.</li>
++<li>No help with weapons of mass harm or attacks on others.</li>
++</ul><p class="ex-prose">A breach ends the account. This is not a suggestion a model may talk its way past; it is the condition of being here.</p>`;
++ return `<div class="ex-floor-box">${body}</div>`;
++}
++
++// ── AI in OpenVibe ─────────────────────────────────────────
++function aiPage({ catalog, usage }) {
++ const products = usage.by_product || [];
++ const days = usage.days ? Math.max(1, usage.days) : 30;
++ const rows = products.map((p) => {
++ const prod = product(p.product);
++ return {
++ product: `<span class="ex-product-cell">${productMark(p.product, { size: 28 })}<b>${esc(prod.name)}</b></span>`,
++ models: (p.models || []).slice(0, 4).map((m) => `<code>${esc(m.model || m.provider)}</code>`).join(' ') || '–',
++ requests: count(p.requests),
++ tokens: count((p.tokens_in || 0) + (p.tokens_out || 0)),
++ cost: p.cost_usd == null ? '–' : `$${(Number(p.cost_usd) / days).toFixed(4)}`,
++ };
++ });
++ const perDay = (n) => (n == null ? '–' : `$${(Number(n) / days).toFixed(4)}`);
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'AI in OpenVibe', sub: 'One layer for every product: the same router, offers, failover and prices.', actions: [kit.button({ href: '/models', label: 'Models', kind: 'quiet' })] })}
++${usage.unavailable ? kit.notice({ kind: 'info', text: 'Usage is not reachable right now.' }) : ''}
++${panel({ title: 'Who runs what', note: `Requests in the last ${days} days, from OpenVibe.AI.`, id: 'products', body: rows.length ? kit.table({ columns: [
++ { key: 'product', label: 'Product', primary: true, html: true },
++ { key: 'models', label: 'Models', html: true },
++ { key: 'requests', label: 'Requests' },
++ { key: 'tokens', label: 'Tokens', hide: 'sm' },
++ { key: 'cost', label: 'Cost a day' },
++ ], rows, label: 'Product usage', caption: 'A product names its quality floor, price ceiling and policy; the router does the rest.' }) : kit.empty({ icon: 'spark', title: 'No product has run a model yet', text: 'Products appear here the first time they call the router.' }) })}
++${panel({ title: 'What each product gets', body: `
++<ul class="ex-list">
++<li>the same offers and failover as this site;</li>
++<li>its own quality floor, price ceiling and content policy;</li>
++<li>usage and cost, per product, per day;</li>
++<li>no product keeps its own provider keys or its own model list.</li>
++</ul>
++<p class="ex-prose">Watch, Actor, Codes, Live, Blog, News, Wiki, Vision, Bench, Space, Help, Food and Trade call the same router with their service token. The numbers above are read from AI's daily rollup, never from a prompt or a person.</p>` })}
++${panel({ title: 'The cost, per day', body: usage.totals ? `<p class="ex-prose">${count(usage.totals.requests)} requests and ${count((usage.totals.tokens_in || 0) + (usage.totals.tokens_out || 0))} tokens over ${days} days, at about ${perDay(usage.totals.cost_usd)} a day across the network.</p>` : '<p class="ex-prose">Cost is not available right now.</p>' })}
++</div>`;
++}
++
++// ── Status ─────────────────────────────────────────────────
++function statusPage({ catalog, usage }) {
++ const offers = chatModels(catalog).flatMap((m) => m.offers.map((o) => ({ m, o })));
++ offers.sort((a, b) => (b.o.requests_7d || 0) - (a.o.requests_7d || 0));
++ const rows = offers.slice(0, 40).map(({ m, o }) => ({
++ model: `<a href="${hrefOf(m)}">${esc(m.name)}</a>`,
++ provider: esc(providerLabel(o)),
++ uptime: o.uptime == null ? '<span class="ex-muted">no traffic</span>' : pct(o.uptime),
++ traffic: count(o.requests_7d),
++ speed: o.speed.p95_ms == null ? '–' : ms(o.speed.p95_ms),
++ updated: o.updated_at ? String(o.updated_at).slice(0, 10) : '–',
++ }));
++ const recent = [...offers].filter(({ o }) => o.updated_at).sort((a, b) => String(b.o.updated_at).localeCompare(String(a.o.updated_at))).slice(0, 10)
++ .map(({ m, o }) => `<li><time>${esc(String(o.updated_at).slice(0, 10))}</time> ${esc(m.name)} on ${esc(providerLabel(o))}${o.price ? ` · ${inOut(o.price)}` : ''}</li>`).join('');
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'Status', sub: 'Uptime per offer, the model changelog, and incidents.', actions: [kit.button({ href: '/models', label: 'Models', kind: 'quiet' })] })}
++${unavailable(catalog, 'The status board')}
++${panel({ title: 'Uptime per offer', note: 'Measured from real requests in the last seven days. An offer with no traffic has no uptime to show, and says so.', id: 'uptime', body: rows.length ? kit.table({ columns: [
++ { key: 'model', label: 'Model', primary: true, html: true },
++ { key: 'provider', label: 'Offer' },
++ { key: 'uptime', label: 'Uptime', html: true },
++ { key: 'traffic', label: 'Requests, 7 d' },
++ { key: 'speed', label: 'p95' },
++ { key: 'updated', label: 'Changed', hide: 'sm' },
++ ], rows, label: 'Uptime per offer' }) : kit.empty({ icon: 'spark', title: 'No offers yet', text: 'Offers appear here when a provider key or a Node machine is on.' }) })}
++${panel({ title: 'Model changelog', note: 'What changed in the catalogue, newest first. Prices move when a provider moves them.', id: 'changelog', body: recent ? `<ul class="ex-changelog">${recent}</ul>` : '<p class="ex-prose">No catalogue change recorded yet.</p>' })}
++${panel({ title: 'Incidents', id: 'incidents', body: catalog.unavailable || catalog.stale ? kit.notice({ kind: 'error', text: 'OpenVibe.AI is not answering right now, so these numbers are not live. Live prices and the catalog on the other pages say so too.' }) : '<p class="ex-prose">No incident recorded. When an offer goes down, a failover is used and the answer says so; here is where an outage is written down.</p>' })}
++${panel({ title: 'Requests today', body: usage.unavailable ? '<p class="ex-prose">Usage is not reachable right now.</p>' : `<p class="ex-prose">${count(usage.today ? usage.today.requests : 0)} requests today, ${count(usage.today ? (usage.today.tokens_in || 0) + (usage.today.tokens_out || 0) : 0)} tokens.</p>` })}
++</div>`;
++}
++
++// ── Retired v1 ─────────────────────────────────────────────
++function retired(pathName) {
++ return `<div class="ex-wrap ex-page ex-narrow">
++${kit.empty({
++ icon: 'spark', title: 'This part of Exchange is retired',
++ text: `“${pathName}” was Exchange v1: free and swap listings for agents, templates, themes and game assets. Exchange is now the network's AI: every model, one account.`,
++ })}
++${panel({ title: 'Where those things live now', body: `<ul class="ex-list">
++<li>Agents and MCP tools: <a href="https://openvibe.codes">OpenVibe.Codes</a> and <a href="https://openvibe.network">Connect</a>.</li>
++<li>Templates: <a href="https://openvibe.space">OpenVibe.Space</a> and <a href="https://openvibe.report">OpenVibe.Report</a>.</li>
++<li>Themes: <a href="https://openvibe.design">OpenVibe.Design</a>.</li>
++<li>Every AI model, with live prices and chat: <a href="/">the new Exchange</a>.</li>
++</ul><p class="ex-prose">v1's tables are kept read-only for their owners; nothing new is written to them.</p>` })}
++</div>`;
++}
++
++module.exports = { home, modelsPage, modelPage, apiPage, sellPage, uncensoredPage, aiPage, statusPage, retired, privacyOf, providerLabel, familyLabel, chatModels, ENDPOINT };
ddiff --git a/server/views/index.js b/server/views/index.js
ddeleted file mode 100644
iindex 2622e9d..0000000
---- a/server/views/index.js
++++ /dev/null
@@@ -1,459 +0,0 @@
--'use strict';
--
--/**
-- * The Exchange room's pages (plan 2026-10-28 §5.11). Server-rendered and whole without JavaScript: every action is a
-- * plain form or link. public/js/exchange.js and public/css/exchange.css are one-comment placeholders the main
-- * session replaces; the sandbox, install, revoke and offers work without them.
-- *
-- * home({ listings, counts }) the hero, the three scenes, public listings
-- * listingsPage({ listings, filters }) browse and filter
-- * listingPage({ listing, tests, ... }) passport, tests, sandbox, install/revoke and offers
-- * submitForm({ values, mine, problem }) create a listing (draft)
-- * sandboxPage({ trial, listing }) one sandbox trial and its checks
-- * installsPage({ installs }) the caller's installations
-- * offersPage({ sent, received }) free claims and swap offers
-- * safetyPage() free and swap safety rules
-- * developersPage() the API
-- * signIn({ title, text, next }) a page that needs an account
-- * notFound()
-- * Each returns HTML for the page body; server/http/pages.js sends it through the layout. Ids and classes are
-- * stable and start with `ex-`.
-- */
--const { esc, time } = require('../render/html');
--const kit = require('openvibe-shared/kit');
--const sc = require('openvibe-shared/showcase');
--
--const KIND_LABEL = { agent: 'Agent', mcp_tool: 'MCP tool', template: 'Template', theme: 'Theme or kit', game_asset: 'Game asset', service: 'Creator service' };
--const MODE_LABEL = { free: 'Free', swap: 'Swap' };
--const STATUS_LABEL = { draft: 'Draft', listed: 'Listed', hidden: 'Hidden', closed: 'Closed' };
--const CLOSED_LABEL = { given: 'Given away', swapped: 'Swapped', withdrawn: 'Withdrawn' };
--const PASSPORT_LABEL = { reads: 'Can read', writes: 'Can write', spends: 'Can spend', contacts: 'Can contact' };
--
--const kindLabel = (k) => esc(KIND_LABEL[k] || k);
--const modeBadge = (m) => `<span class="ex-mode ex-mode-${esc(m)}">${esc(MODE_LABEL[m] || m)}</span>`;
--const statusBadge = (s) => `<span class="ex-status ex-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;
--
--/** A palette a page may paint: only plain #rrggbb, at most six, and nothing else ever reaches the style attribute. */
--const HEX_RE = /^#[0-9a-f]{6}$/i;
--function swatches(palette, { label = 'Preview palette' } = {}) {
-- const colours = (Array.isArray(palette) ? palette : [])
-- .filter((colour) => typeof colour === 'string' && HEX_RE.test(colour))
-- .slice(0, 6)
-- .map((colour) => colour.toLowerCase());
-- if (!colours.length) return '';
-- return `<span class="ex-palette" role="img" aria-label="${esc(label)}">${colours.map((colour) => `<i style="background:${colour}"></i>`).join('')}</span>`;
--}
--
--function verifiedBadge(listing) {
-- if (!listing || !listing.tests) return '';
-- if (listing.tests.verified) return `<span class="ex-verified" title="Every conformance test for this version passed">✓ tests pass</span>`;
-- return `<span class="ex-unverified">${Number(listing.tests.failed) ? 'tests failed' : 'not tested'}</span>`;
--}
--
--function card(l) {
-- return `<li class="ex-listing-card" data-listing="${esc(l.slug)}" data-kind="${esc(l.kind)}" data-mode="${esc(l.mode)}">
--<a class="ex-listing-link" href="/listings/${esc(l.slug)}">
--<span class="ex-listing-top">${modeBadge(l.mode)}<span class="ex-kind">${kindLabel(l.kind)}</span>${verifiedBadge(l)}</span>
--<span class="ex-listing-title">${esc(l.title)}</span>
--<span class="ex-listing-summary">${esc(l.summary)}</span>
--${swatches(l.palette, { label: `${l.title} preview palette` })}
--<span class="ex-listing-meta"><span class="ex-creator">${esc(l.creator)}</span> · <span class="ex-license">${esc(l.license)}</span> · <span class="ex-version">v${esc(l.version)}</span></span>
--</a></li>`;
--}
--
--function empty(text, href = '/submit', label = 'List something') {
-- return `<div class="ex-empty"><p>${esc(text)}</p><a class="ex-btn ex-btn-quiet" href="${esc(href)}">${esc(label)}</a></div>`;
--}
--
--function passport(p = {}, { id = 'ex-passport' } = {}) {
-- const rows = ['reads', 'writes', 'spends', 'contacts'];
-- const value = (key) => {
-- const list = Array.isArray(p[key]) ? p[key] : [];
-- if (!list.length) return key === 'spends' ? '<span class="ex-none">Nothing — v1 has no money</span>' : '<span class="ex-none">Nothing</span>';
-- return `<ul class="ex-passport-list">${list.map((x) => `<li>${key === 'contacts' ? `<code>${esc(x)}</code>` : `<code>${esc(x)}</code>`}</li>`).join('')}</ul>`;
-- };
-- return `<section class="ex-passport"${id ? ` id="${id}"` : ''} aria-label="Permission passport">
--<div class="ex-section-head"><h2>Permission passport</h2><span class="ex-passport-stamp" aria-hidden="true">passport</span></div>
--<p class="ex-fine">Exactly what this listing declares it can read, write, spend and contact. The passport is checked in the sandbox and snapshotted at install; a later edit to the listing cannot widen an old install.</p>
--<dl class="ex-passport-grid">
--${rows.map((key) => `<div class="ex-passport-row ex-passport-${key}"><dt>${esc(PASSPORT_LABEL[key])}</dt><dd>${value(key)}</dd></div>`).join('')}
--</dl>
--</section>`;
--}
--
--function testsBlock(tests = []) {
-- return `<section class="ex-tests-sec" id="ex-tests" aria-labelledby="ex-tests-h">
--<div class="ex-section-head"><h2 id="ex-tests-h">Tests for this version</h2>${tests.length && tests.every((t) => t.status === 'passed') ? '<span class="ex-verified">✓ all pass</span>' : '<span class="ex-unverified">not passing</span>'}</div>
--${tests.length ? `<ul class="ex-tests">${tests.map((t) => `<li class="ex-test ex-test-${esc(t.status)}"><span class="ex-test-name"><b>${esc(t.name)}</b> <span class="ex-test-status">${esc(t.status)}</span></span><span class="ex-test-detail">${esc(t.detail || '')}</span></li>`).join('')}</ul>` : '<p class="ex-muted">No conformance test has been recorded for this version.</p>'}
--</section>`;
--}
--
--/** The room's picture: two arrows looping an item between makers, and a passport stamping. */
--function motif() {
-- return `<div class="ex-motif" aria-hidden="true">
--<svg class="ex-motif-arrows" viewBox="0 0 420 260" fill="none" xmlns="http://www.w3.org/2000/svg">
--<path d="M84 92c32-46 92-62 148-42 30 11 54 34 68 62" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
--<path d="m286 96 16 18-24 6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
--<path d="M336 168c-32 46-92 62-148 42-30-11-54-34-68-62" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".55"/>
--<path d="m134 164-16-18 24-6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" opacity=".55"/>
--</svg>
--<span class="ex-motif-item ex-motif-item-a">a template</span>
--<span class="ex-motif-item ex-motif-item-b">a theme</span>
--<span class="ex-motif-passport"><small>passport</small><b>safe</b></span>
--</div>`;
--}
--
--function home({ listings = [], counts = {} } = {}) {
-- // Real first (the owner, 10-31: empty sections made the rooms look unfinished): Fresh listings shows only with listings
-- // in it; an empty market is one short "be the first" row after the scenes.
-- const live = listings.length ? `<section class="ex-section" id="ex-listings" aria-labelledby="ex-listings-h">
--<div class="ex-section-head"><h2 id="ex-listings-h">Fresh listings</h2><a class="ex-more" href="/listings">All listings${kit.icon('chevron')}</a></div>
--<ol class="ex-grid">${listings.slice(0, 8).map(card).join('')}</ol>
--</section>` : '';
-- const beFirst = listings.length ? '' : `<section class="ex-section ex-first" aria-labelledby="ex-first-h"><h2 id="ex-first-h">Be the first</h2>
--<ul class="ex-first-list"><li><b>List the first thing you made</b><span>A template, a theme, an agent or a tool, with the passport that says what it can touch. Free and swap only in v1.</span><a href="/submit">List something</a></li></ul></section>`;
-- const room = sc.room({
-- room: 'exchange',
-- hero: {
-- title: 'Trade what people make',
-- lede: 'Agents, MCP tools, templates, themes, game assets and creator services — each with a permission passport that says exactly what it can read, write, spend and contact. Try it in the sandbox, install it with those grants, revoke it in one tap. Free and swap only in v1: there is no money.',
-- start: { href: '/listings', label: 'Browse the listings' },
-- chips: [{ label: 'List something', href: '/submit' }, { label: 'Safety rules', href: '/safety' }, { label: 'The API', href: '/developers' }],
-- proof: Number(counts.listings) ? [
-- { value: Number(counts.listings), label: Number(counts.listings) === 1 ? 'listing' : 'listings' },
-- { value: Number(counts.free) || 0, label: 'free' },
-- { value: Number(counts.swap) || 0, label: 'swap' },
-- { value: Number(counts.makers) || 0, label: Number(counts.makers) === 1 ? 'maker' : 'makers' },
-- ] : [],
-- motif: motif(),
-- },
-- scenes: [
-- {
-- title: 'Try it in the sandbox',
-- text: 'A sandbox trial reads the listing\'s manifest and checks its passport before anything is installed. v1 is an honest dry run: Exchange does not execute a listing\'s code, and says so. A passed trial is good for one hour and is required before an install.',
-- html: `<div class="ex-demo ex-demo-sandbox"><span class="ex-demo-head">sandbox trial</span><ul class="ex-demo-checks"><li>✓ manifest names the entry point</li><li>✓ passport declares no spend</li><li>✓ 4 conformance tests passed</li><li class="ex-muted">dry run: no listing code runs</li></ul></div>`,
-- action: { href: '/listings', label: 'Find something to try' },
-- },
-- {
-- title: 'Install it and see its passport grants',
-- text: 'The install keeps the exact passport it was made with: read, write and contact grants, and no spend in v1. The listing page shows the same passport before and after, so nothing arrives hidden.',
-- html: `<div class="ex-demo ex-demo-passport"><span class="ex-demo-head">permission passport</span><ul class="ex-demo-grants"><li><b>reads</b> search.read · space.files.read</li><li><b>writes</b> space.files.write</li><li><b>spends</b> nothing</li><li><b>contacts</b> api.example.com</li></ul><span class="ex-demo-stamp">installed</span></div>`,
-- action: { href: '/installs', label: 'See your installations' },
-- },
-- {
-- title: 'Revoke it in one tap',
-- text: 'A revoke marks the installation revoked on the spot and removes the passport with it. In v1 the Exchange install record is the entitlement; v2 also calls Network\'s grant revoke, so the access disappears everywhere at once.',
-- html: `<div class="ex-demo ex-demo-revoke"><span class="ex-demo-head">your installation</span><span class="ex-demo-row"><b>A starter site</b><span class="ex-ok">active</span></span><span class="ex-demo-row ex-muted">passport snapshot · 2026-10-28</span><span class="ex-demo-button">Revoke</span></div>`,
-- action: { href: '/installs', label: 'Manage installations' },
-- },
-- ],
-- faq: {
-- items: [
-- ['What is a permission passport?', 'A listing\'s public declaration of what it can read, write, spend and contact, taken from its manifest. It is shown on every listing, checked in the sandbox and snapshotted at install. In v1 a passport may not declare a spend at all.'],
-- ['Is anything paid?', 'No. v1 is free and swap only: no prices, no Billing, no cash, no Vibes, and no way to pay through Exchange. A listing or offer that asks for money is refused.'],
-- ['What does the sandbox actually run?', 'In v1, nothing of the listing\'s. It is a manifest and passport dry run that records its checks. A real Run sandbox is the next version\'s job; the capability it will need is named in the README.'],
-- ['How do I give something away or swap it?', 'List it as free or swap. A free listing takes claims; a swap listing asks what you would like in return and takes offers. Accepting one closes the listing as given or swapped.'],
-- ['Can I take something back?', 'Yes. Revoke an installation in one tap from Installations. Closing a listing stops new offers and removes it from search and the public list; an install you already made stays until you revoke it.'],
-- ],
-- },
-- });
-- // What is real goes above the scenes; what is still missing goes after them.
-- const at = room.indexOf('<section class="sc-room-scenes"');
-- const end = at < 0 ? -1 : room.indexOf('</section>', at) + '</section>'.length;
-- const page = at < 0 ? live + room + beFirst : room.slice(0, at) + live + room.slice(at, end) + beFirst + room.slice(end);
-- return `<div class="ex-wrap ex-home" id="ex-home">${page}</div>`;
--}
--
--function filtersBar(filters = {}) {
-- const f = { q: '', mode: '', kind: '', ...filters };
-- return `<form class="ex-filters" id="ex-filters" method="get" action="/listings" role="search">
--<label class="ex-field"><span>Search</span><input type="search" name="q" value="${esc(f.q)}" maxlength="120" placeholder="a template, a theme, a maker…"></label>
--<label class="ex-field"><span>Mode</span><select name="mode"><option value="">Free and swap</option><option value="free"${f.mode === 'free' ? ' selected' : ''}>Free</option><option value="swap"${f.mode === 'swap' ? ' selected' : ''}>Swap</option></select></label>
--<label class="ex-field"><span>Kind</span><select name="kind"><option value="">Every kind</option>${Object.entries(KIND_LABEL).map(([k, label]) => `<option value="${esc(k)}"${f.kind === k ? ' selected' : ''}>${esc(label)}</option>`).join('')}</select></label>
--<button class="ex-btn" type="submit">Filter</button>
--</form>`;
--}
--
--function listingsPage({ listings = [], nextCursor = null, filters = {} } = {}) {
-- const q = filters.q ? `&q=${encodeURIComponent(filters.q)}` : '';
-- const mode = filters.mode ? `&mode=${encodeURIComponent(filters.mode)}` : '';
-- const kind = filters.kind ? `&kind=${encodeURIComponent(filters.kind)}` : '';
-- return `<div class="ex-wrap" id="ex-listings-page">
--<header class="ex-page-h"><p class="ex-kicker">The marketplace</p><h1>Everything people make</h1>
--<p class="ex-lede">Free and swap only in v1. Every listing carries its permission passport, its licence and the conformance tests for its version. Try before you install; revoke any time.</p></header>
--${filtersBar(filters)}
--${listings.length ? `<ol class="ex-grid">${listings.map(card).join('')}</ol>` : empty('No listing matches this filter yet.', '/listings', 'Clear the filters')}
--${nextCursor ? `<p class="ex-more-row"><a class="ex-btn ex-btn-quiet" href="/listings?cursor=${encodeURIComponent(nextCursor)}${q}${mode}${kind}">More listings</a></p>` : ''}
--</div>`;
--}
--
--function offerList(offers = [], { owner = false } = {}) {
-- if (!offers.length) return '<p class="ex-muted">No open claim or offer yet.</p>';
-- return `<ul class="ex-offers-list">${offers.map((o) => `<li class="ex-offer" data-offer="${esc(o.id)}">
--<div class="ex-offer-head"><b class="ex-offer-from">${esc(o.from_name)}</b><span class="ex-offer-kind">${o.kind === 'claim' ? 'claims this' : 'offers a swap'}</span><span class="ex-offer-when">${time(o.created_at)}</span></div>
--<p class="ex-offer-body">${esc(o.body)}</p>
--${o.give ? `<p class="ex-offer-give"><b>In return:</b> ${esc(o.give)}</p>` : ''}
--${owner ? `<div class="ex-actions"><form method="post" action="/offers/${esc(o.id)}/accept" class="ex-inline"><button class="ex-btn ex-btn-small" type="submit">Accept${o.kind === 'claim' ? ' the claim' : ' the swap'}</button></form><form method="post" action="/offers/${esc(o.id)}/decline" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Decline</button></form></div>` : `<form method="post" action="/offers/${esc(o.id)}/withdraw" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Withdraw</button></form>`}
--</li>`).join('')}</ul>`;
--}
--
--function listingPage({
-- listing: l, tests = [], mine = false, staff = false, signedIn = false, activeInstall = null,
-- trialPassed = false, offers = [], received = false, problem = null, notice = null,
--} = {}) {
-- const offersOpen = l.status === 'listed';
-- const ownerPanel = mine ? `<section class="ex-owner" id="ex-owner" aria-labelledby="ex-owner-h">
--<div class="ex-section-head"><h2 id="ex-owner-h">Your listing</h2>${statusBadge(l.status)}</div>
--<p class="ex-fine">A draft becomes public only when every conformance test for this version has passed. Closing it stops new offers and removes it from the public list; a closed listing cannot be relisted.</p>
--${l.status === 'draft' ? `<form method="post" action="/listings/${esc(l.slug)}/list" class="ex-inline"><button class="ex-btn" type="submit"${l.tests && l.tests.verified ? '' : ' disabled'}>List it publicly</button></form>` : ''}
--${l.status === 'draft' && !(l.tests && l.tests.verified) ? '<p class="ex-note is-bad">Fix the failed tests before this can be public.</p>' : ''}
--${l.status === 'listed' ? `<form method="post" action="/listings/${esc(l.slug)}/close" class="ex-inline"><input type="hidden" name="reason" value="withdrawn"><button class="ex-btn ex-btn-quiet" type="submit">Close the listing</button></form>` : ''}
--${l.status === 'closed' ? `<p class="ex-muted">Closed: ${esc(CLOSED_LABEL[l.closed_reason] || l.closed_reason || 'closed')}.</p>` : ''}
--</section>` : '';
--
-- const sandbox = offersOpen ? `<section class="ex-sandbox-sec" id="ex-sandbox" aria-labelledby="ex-sandbox-h">
--<div class="ex-section-head"><h2 id="ex-sandbox-h">Try it in the sandbox</h2>${trialPassed ? '<span class="ex-verified">✓ trial passed</span>' : ''}</div>
--<p class="ex-fine">A manifest and passport dry run. v1 does not execute the listing\'s code; it records the checks and gives you a one-hour pass to install.</p>
--${signedIn ? `<form method="post" action="/listings/${esc(l.slug)}/sandbox" class="ex-inline"><button class="ex-btn" type="submit">Run the sandbox</button></form>` : `<p class="ex-note"><a href="/auth/login?next=${encodeURIComponent(`/listings/${l.slug}`)}">Sign in</a> to try this listing in the sandbox.</p>`}
--</section>` : '';
--
-- const install = offersOpen ? `<section class="ex-install-sec" id="ex-install" aria-labelledby="ex-install-h">
--<div class="ex-section-head"><h2 id="ex-install-h">Install</h2>${activeInstall ? '<span class="ex-status ex-status-active">installed</span>' : ''}</div>
--${activeInstall ? `<p class="ex-fine">Installed version ${esc(activeInstall.version)} with the passport below. Revoking removes it here and, from v2, at Network too.</p><form method="post" action="/installs/${esc(activeInstall.id)}/revoke" class="ex-inline"><button class="ex-btn ex-btn-quiet" type="submit">Revoke in one tap</button></form>` : ''}
--${!activeInstall && trialPassed ? `<p class="ex-fine">Your sandbox trial passed for version ${esc(l.version)}. The install keeps this passport; it does not widen it.</p><form method="post" action="/listings/${esc(l.slug)}/install" class="ex-inline"><button class="ex-btn" type="submit">Install with these grants</button></form>` : ''}
--${!activeInstall && !trialPassed && signedIn && !mine ? '<p class="ex-note">Run the sandbox first: a passed trial for this version is required before an install.</p>' : ''}
--${!signedIn ? `<p class="ex-note"><a href="/auth/login?next=${encodeURIComponent(`/listings/${l.slug}`)}">Sign in</a> to install.</p>` : ''}
--</section>` : '';
--
-- const offerForm = offersOpen && signedIn && !mine ? `<section class="ex-offer-form-sec" id="ex-offer-form" aria-labelledby="ex-offer-form-h">
--<div class="ex-section-head"><h2 id="ex-offer-form-h">${l.mode === 'free' ? 'Claim this' : 'Offer a swap'}</h2>${modeBadge(l.mode)}</div>
--<form method="post" action="/listings/${esc(l.slug)}/offers" class="ex-form">
--<label class="ex-field"><span>${l.mode === 'free' ? 'Say why you would like it' : 'What you offer in return'}</span><textarea name="${l.mode === 'free' ? 'body' : 'body'}" rows="3" maxlength="1000" required></textarea></label>
--${l.mode === 'swap' ? `<label class="ex-field"><span>What you are giving</span><textarea name="give" rows="3" maxlength="1000" required></textarea></label>` : ''}
--<button class="ex-btn" type="submit">${l.mode === 'free' ? 'Send the claim' : 'Send the offer'}</button>
--</form>
--<p class="ex-fine">No money changes hands. A swap is between the two people; accepting one closes the listing.</p>
--</section>` : '';
--
-- const offersSection = mine && offers.length ? `<section class="ex-offers-sec" id="ex-offers" aria-labelledby="ex-offers-h">
--<div class="ex-section-head"><h2 id="ex-offers-h">Open ${l.mode === 'free' ? 'claims' : 'offers'}</h2><span class="ex-count">${offers.length}</span></div>
--${offerList(offers, { owner: true })}
--</section>` : '';
--
-- return `<div class="ex-wrap" id="ex-listing" data-listing="${esc(l.slug)}">
--<article class="ex-listing">
--<header class="ex-listing-h">
--<p class="ex-kicker">${kindLabel(l.kind)} · ${modeBadge(l.mode)}${statusBadge(l.status)}${staff ? ' · <span class="ex-staff">staff view</span>' : ''}</p>
--<h1>${esc(l.title)}</h1>
--<p class="ex-lede">${esc(l.summary)}</p>
--${swatches(l.palette, { label: `${l.title} preview palette` })}
--<p class="ex-byline">by <span class="ex-creator">${esc(l.creator)}</span> · version <span class="ex-version">${esc(l.version)}</span> · <span class="ex-license">${esc(l.license)}</span>${l.license_url ? ` · <a href="${esc(l.license_url)}" rel="nofollow noopener">the licence</a>` : ''}${l.official ? ' · <span class="ex-official">listed by OpenVibe</span>' : ''}</p>
--</header>
--
--${notice ? `<p class="ex-note" role="status">${esc(notice)}</p>` : ''}
--${problem ? `<p class="ex-note is-bad" role="alert">${esc(problem)}</p>` : ''}
--
--<section class="ex-about" id="ex-about" aria-labelledby="ex-about-h">
--<h2 id="ex-about-h">About this listing</h2>
--${l.description ? `<div class="ex-prose">${esc(l.description).replace(/\n/g, '<br>')}</div>` : '<p class="ex-muted">The maker has not added a longer description.</p>'}
--${l.mode === 'swap' && l.wants ? `<p class="ex-wants"><b>Wants in return:</b> ${esc(l.wants)}</p>` : ''}
--<dl class="ex-facts">
--<div><dt>Version</dt><dd>${esc(l.version)}</dd></div>
--<div><dt>Licence</dt><dd>${esc(l.license)}</dd></div>
--<div><dt>Mode</dt><dd>${esc(MODE_LABEL[l.mode] || l.mode)}</dd></div>
--<div><dt>Listed</dt><dd>${l.listed_at ? time(l.listed_at) : 'not yet'}</dd></div>
--<div><dt>Revision</dt><dd>${Number(l.revision) || 1}</dd></div>
--${l.home_url ? `<div><dt>Home</dt><dd><a href="${esc(l.home_url)}" rel="noopener">${esc(l.home_url)}</a></dd></div>` : ''}
--</dl>
--</section>
--
--${l.files && l.files.length ? `<section class="ex-files" id="ex-files" aria-labelledby="ex-files-h">
--<div class="ex-section-head"><h2 id="ex-files-h">Downloads</h2><span class="ex-count">${l.files.length} files</span></div>
--<p class="ex-fine">Every file is generated from the same tokens, so pick the platform you need; the format is on each file.</p>
--<ul class="ex-file-list">${l.files.map((f) => `<li><a href="${esc(f.url)}" rel="noopener" download><b>${esc(f.label)}</b><span>${esc(f.format)}</span></a></li>`).join('')}</ul>
--</section>` : ''}
--
--${passport(l.passport)}
--${testsBlock(tests)}
--${sandbox}
--${install}
--${offersSection}
--${offerForm}
--${ownerPanel}
--
--<section class="ex-manifest" id="ex-manifest" aria-labelledby="ex-manifest-h">
--<div class="ex-section-head"><h2 id="ex-manifest-h">Manifest</h2></div>
--<dl class="ex-facts"><div><dt>Name</dt><dd>${esc(l.manifest ? l.manifest.name : '')}</dd></div><div><dt>Entry</dt><dd><code>${esc(l.manifest ? l.manifest.entry : '')}</code></dd></div><div><dt>Runtime</dt><dd>${esc(l.manifest && l.manifest.runtime ? l.manifest.runtime : 'not stated')}</dd></div></dl>
--<p class="ex-fine">Public metadata only. A manifest that looks like a credential is refused when the listing is submitted.</p>
--</section>
--
--<footer class="ex-listing-foot"><p class="ex-fine">${esc(SAFETY_NOTE)}</p></footer>
--</article>
--</div>`;
--}
--
--const SAFETY_NOTE = 'v1 is free and swap only: nobody should send money. Read the passport before installing, and revoke anything you no longer use.';
--
--const textarea = (name, value, rows = 4, max = 4000) => `<textarea name="${name}" rows="${rows}" maxlength="${max}">${esc(value || '')}</textarea>`;
--
--function submitForm({ values = {}, mine = [], problem = null } = {}) {
-- const v = {
-- title: '', kind: 'template', mode: 'free', summary: '', description: '', wants: '', license: 'CC0-1.0',
-- license_url: '', version: '1.0.0', manifest_name: '', manifest_entry: '', manifest_runtime: '',
-- passport_reads: '', passport_writes: '', passport_contacts: '', ...values,
-- };
-- return `<div class="ex-wrap" id="ex-submit">
--<header class="ex-page-h"><p class="ex-kicker">List something</p><h1>Trade what you make</h1>
--<p class="ex-lede">A listing is a draft first. Exchange runs the conformance tests when you submit it; when they pass you can list it publicly. v1 is free or swap only: there is no price and the passport may not declare a spend.</p></header>
--${problem ? `<p class="ex-note is-bad" role="alert">${esc(problem)}</p>` : ''}
--<form id="ex-listing-form" class="ex-form ex-card" method="post" action="/submit">
--<label class="ex-field"><span>Title</span><input name="title" type="text" maxlength="120" required value="${esc(v.title)}" placeholder="A starter website template"></label>
--<div class="ex-field-row">
--<label class="ex-field"><span>Kind</span><select name="kind">${Object.entries(KIND_LABEL).map(([k, label]) => `<option value="${esc(k)}"${v.kind === k ? ' selected' : ''}>${esc(label)}</option>`).join('')}</select></label>
--<label class="ex-field"><span>Mode</span><select name="mode"><option value="free"${v.mode === 'free' ? ' selected' : ''}>Free</option><option value="swap"${v.mode === 'swap' ? ' selected' : ''}>Swap</option></select></label>
--</div>
--<label class="ex-field"><span>Summary</span><input name="summary" type="text" maxlength="600" required value="${esc(v.summary)}" placeholder="What it is, in one line."></label>
--<label class="ex-field"><span>Description</span>${textarea('description', v.description, 6, 4000)}</label>
--<label class="ex-field"><span>What a swap wants in return (swap only)</span><input name="wants" type="text" maxlength="300" value="${esc(v.wants)}" placeholder="A type kit, a game map, a skill swap…"></label>
--<div class="ex-field-row">
--<label class="ex-field"><span>Licence (SPDX)</span><input name="license" type="text" maxlength="64" required value="${esc(v.license)}" placeholder="CC0-1.0"></label>
--<label class="ex-field"><span>Licence URL (optional)</span><input name="license_url" type="url" maxlength="300" value="${esc(v.license_url)}" placeholder="https://…"></label>
--<label class="ex-field"><span>Version</span><input name="version" type="text" maxlength="32" required value="${esc(v.version)}" placeholder="1.0.0"></label>
--</div>
--<fieldset class="ex-fieldset"><legend>Permission passport</legend>
--<p class="ex-fine">One permission per line. Reads and writes are lower-case names such as <code>search.read</code> or <code>space.files.write</code>; contacts are https origins. Spending is fixed to nothing in v1.</p>
--<label class="ex-field"><span>Can read</span>${textarea('passport_reads', v.passport_reads, 3, 1000)}</label>
--<label class="ex-field"><span>Can write</span>${textarea('passport_writes', v.passport_writes, 3, 1000)}</label>
--<label class="ex-field"><span>Can contact</span>${textarea('passport_contacts', v.passport_contacts, 3, 1000)}</label>
--<label class="ex-field"><span>Can spend</span><input type="text" value="Nothing — v1 has no money" disabled></label>
--</fieldset>
--<fieldset class="ex-fieldset"><legend>Manifest</legend>
--<p class="ex-fine">Public metadata only: the name, the entry point and the runtime. Never put a secret, token or key in a manifest; it is refused.</p>
--<label class="ex-field"><span>Manifest name</span><input name="manifest_name" type="text" maxlength="80" required value="${esc(v.manifest_name)}" placeholder="Starter site"></label>
--<label class="ex-field"><span>Entry point</span><input name="manifest_entry" type="text" maxlength="200" required value="${esc(v.manifest_entry)}" placeholder="templates/starter/index.html"></label>
--<label class="ex-field"><span>Runtime</span><input name="manifest_runtime" type="text" maxlength="80" value="${esc(v.manifest_runtime)}" placeholder="static"></label>
--</fieldset>
--<div class="ex-actions"><button class="ex-btn" type="submit">Save the draft</button><a class="ex-btn ex-btn-quiet" href="/listings">Cancel</a></div>
--<p class="ex-fine">By listing you confirm you have the rights to what you share and that it follows the <a href="/safety">safety rules</a>.</p>
--</form>
--${mine.length ? `<section class="ex-mine" id="ex-mine" aria-labelledby="ex-mine-h"><div class="ex-section-head"><h2 id="ex-mine-h">Your listings</h2><span class="ex-count">${mine.length}</span></div><ul class="ex-mine-list">${mine.map((l) => `<li class="ex-mine-item"><a href="/listings/${esc(l.slug)}">${esc(l.title)}</a> ${statusBadge(l.status)} ${modeBadge(l.mode)} <span class="ex-muted">v${esc(l.version)}</span></li>`).join('')}</ul></section>` : ''}
--</div>`;
--}
--
--function sandboxPage({ trial, listing } = {}) {
-- const t = trial;
-- return `<div class="ex-wrap" id="ex-sandbox-trial" data-trial="${esc(t.id)}">
--<header class="ex-page-h"><p class="ex-kicker">Sandbox trial</p><h1>${t.status === 'passed' ? 'The dry run passed' : 'The dry run did not pass'}</h1>
--<p class="ex-lede">${esc(listing ? listing.title : '')} · version ${esc(t.version)}. ${t.status === 'passed' ? 'You can install this version with its passport until the trial expires.' : 'Nothing was installed. Fix the failed checks and try again.'}</p></header>
--<p class="ex-note ${t.status === 'passed' ? '' : 'is-bad'}" role="status">Status: <b>${esc(t.status)}</b> · expires ${time(t.expires_at)}</p>
--<ul class="ex-checks">${(t.checks || []).map((c) => `<li class="ex-check ex-check-${esc(c.status)}"><b>${esc(c.name)}</b> <span>${esc(c.status)}</span><span class="ex-check-detail">${esc(c.detail || '')}</span></li>`).join('')}</ul>
--${passport(t.passport)}
--<div class="ex-actions">
--${t.status === 'passed' && !t.consumed_at && listing ? `<form method="post" action="/listings/${esc(listing.slug)}/install" class="ex-inline"><button class="ex-btn" type="submit">Install with these grants</button></form>` : ''}
--${t.consumed_at ? '<p class="ex-fine">This trial was used by an install. Run a fresh one to install again.</p>' : ''}
--<a class="ex-btn ex-btn-quiet" href="/listings/${esc(listing ? listing.slug : '')}">Back to the listing</a>
--</div>
--</div>`;
--}
--
--function installsPage({ installs = [] } = {}) {
-- const active = installs.filter((i) => i.status === 'active');
-- const revoked = installs.filter((i) => i.status !== 'active');
-- return `<div class="ex-wrap" id="ex-installs">
--<header class="ex-page-h"><p class="ex-kicker">Your installations</p><h1>What you have installed</h1>
--<p class="ex-lede">Each install keeps the exact passport it was made with. Revoke in one tap; installing again needs a fresh sandbox trial, so the passport is re-checked.</p></header>
--<section class="ex-install-active" id="ex-installs-active" aria-labelledby="ex-installs-active-h">
--<div class="ex-section-head"><h2 id="ex-installs-active-h">Active</h2><span class="ex-count">${active.length}</span></div>
--${active.length ? `<ul class="ex-install-list">${active.map((i) => `<li class="ex-install" data-install="${esc(i.id)}"><div class="ex-install-main"><a class="ex-install-title" href="/listings/${esc(i.listing.slug)}">${esc(i.listing.title)}</a><span class="ex-install-meta">${esc(KIND_LABEL[i.listing.kind] || i.listing.kind)} · v${esc(i.version)} · installed ${time(i.installed_at)}</span>${passport(i.passport, { id: null })}</div><form method="post" action="/installs/${esc(i.id)}/revoke" class="ex-inline"><button class="ex-btn ex-btn-quiet" type="submit">Revoke</button></form></li>`).join('')}</ul>` : empty('Nothing installed yet. Try a listing in the sandbox, then install it with its passport.', '/listings', 'Browse listings')}
--</section>
--<section class="ex-install-revoked" id="ex-installs-revoked" aria-labelledby="ex-installs-revoked-h">
--<div class="ex-section-head"><h2 id="ex-installs-revoked-h">Revoked</h2><span class="ex-count">${revoked.length}</span></div>
--${revoked.length ? `<ul class="ex-install-list">${revoked.map((i) => `<li class="ex-install is-revoked" data-install="${esc(i.id)}"><div class="ex-install-main"><a class="ex-install-title" href="/listings/${esc(i.listing.slug)}">${esc(i.listing.title)}</a><span class="ex-install-meta">v${esc(i.version)} · revoked ${time(i.revoked_at)}</span></div><span class="ex-status ex-status-revoked">revoked</span></li>`).join('')}</ul>` : '<p class="ex-muted">No installation has been revoked.</p>'}
--</section>
--</div>`;
--}
--
--function offersPage({ sent = [], received = [] } = {}) {
-- return `<div class="ex-wrap" id="ex-offers">
--<header class="ex-page-h"><p class="ex-kicker">Free claims and swap offers</p><h1>Your offers</h1>
--<p class="ex-lede">A free listing takes claims, a swap listing takes offers. Accepting one closes the listing as given or swapped; nothing is paid through Exchange.</p></header>
--<section id="ex-offers-received" aria-labelledby="ex-offers-received-h">
--<div class="ex-section-head"><h2 id="ex-offers-received-h">Received</h2><span class="ex-count">${received.length}</span></div>
--${received.length ? `<ul class="ex-offer-list">${received.map((o) => `<li class="ex-offer" data-offer="${esc(o.id)}"><div class="ex-offer-head"><a href="/listings/${esc(o.listing.slug)}">${esc(o.listing.title)}</a><span class="ex-offer-kind">${o.kind === 'claim' ? 'claim' : 'swap offer'}</span><span class="ex-offer-when">${time(o.created_at)}</span></div><p class="ex-offer-body">${esc(o.body)}</p>${o.give ? `<p class="ex-offer-give"><b>In return:</b> ${esc(o.give)}</p>` : ''}<span class="ex-status ex-status-${esc(o.status)}">${esc(o.status)}</span>${o.status === 'open' ? `<div class="ex-actions"><form method="post" action="/offers/${esc(o.id)}/accept" class="ex-inline"><button class="ex-btn ex-btn-small" type="submit">Accept</button></form><form method="post" action="/offers/${esc(o.id)}/decline" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Decline</button></form></div>` : ''}</li>`).join('')}</ul>` : '<p class="ex-muted">Nobody has claimed or offered on your listings yet.</p>'}
--</section>
--<section id="ex-offers-sent" aria-labelledby="ex-offers-sent-h">
--<div class="ex-section-head"><h2 id="ex-offers-sent-h">Sent</h2><span class="ex-count">${sent.length}</span></div>
--${sent.length ? `<ul class="ex-offer-list">${sent.map((o) => `<li class="ex-offer" data-offer="${esc(o.id)}"><div class="ex-offer-head"><a href="/listings/${esc(o.listing.slug)}">${esc(o.listing.title)}</a><span class="ex-offer-kind">${o.kind === 'claim' ? 'claim' : 'swap offer'}</span><span class="ex-offer-when">${time(o.created_at)}</span></div><p class="ex-offer-body">${esc(o.body)}</p><span class="ex-status ex-status-${esc(o.status)}">${esc(o.status)}</span>${o.status === 'open' ? `<form method="post" action="/offers/${esc(o.id)}/withdraw" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Withdraw</button></form>` : ''}</li>`).join('')}</ul>` : '<p class="ex-muted">You have not claimed or offered on a listing yet.</p>'}
--</section>
--</div>`;
--}
--
--function safetyPage() {
-- return `<div class="ex-wrap" id="ex-safety">
--<header class="ex-page-h"><p class="ex-kicker">Safety</p><h1>Free and swap, safely</h1>
--<p class="ex-lede">OpenVibe.Exchange v1 has no money in it. That removes most of the scams; it does not remove all of them.</p></header>
--<p class="ex-note">${esc(SAFETY_NOTE)}</p>
--<section><h2>No money, anywhere</h2><ul class="ex-prose-list">
--<li>There is no price, no checkout and no Billing in v1. A listing is free or a swap.</li>
--<li>Nobody should send money, a gift card, crypto or a deposit for anything here. A listing, offer or passport that asks for payment is refused when it is submitted.</li>
--<li>A passport's <b>spend</b> is always nothing in v1. If a listing ever shows a spend in its passport, do not install it; report it to staff.</li>
--</ul></section>
--<section><h2>Read the passport</h2><p class="ex-prose">The passport says exactly what the listing can read, write and contact. The sandbox checks it, the install snapshots it, and a later edit to the listing cannot widen an install you already made. Revoke anything you no longer use.</p></section>
--<section><h2>What may not be listed</h2><p class="ex-prose">The fixed prohibited-items list: weapons and ammunition, illegal drugs, malware and stolen credentials or access, sexual services or content, forged or counterfeit documents, and live animals. Staff can hide anything else that breaks the rules.</p></section>
--<section><h2>For a local swap</h2><ul class="ex-prose-list">
--<li>Meet in a public place, in daylight if you can, and take someone with you.</li>
--<li>Check what you are swapping before you hand anything over; Exchange does not inspect or hold it.</li>
--<li>Keep the arrangement on the listing where you can, and keep a copy of what you agreed.</li>
--<li>Never share a password, a token or a private key to prove something works. A listing's manifest is public metadata and must never carry one.</li>
--</ul></section>
--<section><h2>What Exchange does not do</h2><p class="ex-prose">Exchange does not execute a listing in v1 (the sandbox is a dry run), does not hold a swap in escrow, and does not guarantee that a maker owns what they list. The licence on the listing is the maker's promise; read it before you install.</p></section>
--</div>`;
--}
--
--function developersPage() {
-- const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;
-- return `<div class="ex-wrap" id="ex-developers">
--<header class="ex-page-h"><p class="ex-kicker">Developers</p><h1>The Exchange API</h1>
--<p class="ex-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>exchange_at</code> session cookie or a Network user token as a Bearer.</p></header>
--<section><h2>Read</h2><dl class="ex-eps">
--${ep('GET', '/api/v1/listings?limit=&cursor=&mode=&kind=&q=', 'Public listings, interleaved by kind, newest first inside each kind; cursor-paged (at most 50).')}
--${ep('GET', '/api/v1/listings/:slug', 'One listing with its passport, manifest, licence, tests and counters. A draft/hidden/closed one is 404 unless it is yours or you are staff.')}
--${ep('GET', '/api/v1/listings/:slug/passport', 'The exact passport and manifest an installer accepts.')}
--${ep('GET', '/api/v1/listings/:slug/tests', 'The conformance tests for the version the listing names.')}
--</dl></section>
--<section><h2>Write (signed in, same-origin)</h2><dl class="ex-eps">
--${ep('POST', '/api/v1/listings', '<code>{ title, kind, mode, summary, description?, wants?, license, license_url?, version, manifest: { name, entry, runtime? }, passport: { reads, writes, spends, contacts } }</code>. Saves a draft; <code>spends</code> must be empty.')}
--${ep('POST', '/api/v1/listings/:slug/list', 'Publish a draft after every conformance test for its version passed.')}
--${ep('POST', '/api/v1/listings/:slug/close', '<code>{ reason: "withdrawn"|"given"|"swapped" }</code>, the owner. Closes it and tombstones its Search document.')}
--${ep('POST', '/api/v1/listings/:slug/sandbox', 'Run a manifest dry-run trial. A passed trial is valid for the configured window.')}
--${ep('GET', '/api/v1/sandbox/:id', 'One trial of yours and its checks.')}
--${ep('POST', '/api/v1/listings/:slug/install', 'Install with the passport snapshot. 409 <code>exchange.sandbox_required</code> without a passed trial for this version.')}
--${ep('GET', '/api/v1/installs', 'Your installations, active and revoked.')}
--${ep('POST', '/api/v1/installs/:id/revoke', 'Revoke in one tap. Idempotent.')}
--${ep('POST', '/api/v1/listings/:slug/offers', '<code>{ body, give? }</code>: a free claim, or a swap offer with what you give. No money.')}
--${ep('GET', '/api/v1/offers', 'The offers you sent and received.')}
--${ep('POST', '/api/v1/offers/:id/accept|decline|withdraw', 'The owner accepts/declines; the sender withdraws. Accepting closes the listing as given or swapped.')}
--${ep('GET', '/api/v1/me/listings', 'Every listing of yours, all statuses.')}
--</dl></section>
--<section><h2>Staff (Network role <code>global_mod</code> or above)</h2><dl class="ex-eps">
--${ep('POST', '/api/v1/listings/:slug/hide', 'Hide a listing and tombstone its Search document.')}
--${ep('GET', '/api/v1/listings/:slug/offers', 'The open claims/offers on a listing.')}
--</dl></section>
--<section id="ex-search"><h2>Search</h2><p class="ex-fine">Each public listing is one <code>search.index-document@1</code> (owner <code>exchange</code>, type <code>listing</code>, canonical page <code>/listings/&lt;slug&gt;</code>) queued in <code>exchange_outbox</code> and delivered to OpenVibe.Search within a minute; closing or hiding it queues a tombstone.</p></section>
--<section id="ex-grants"><h2>Grants v1 does not have yet</h2><p class="ex-fine">The plan enforces a passport with Network grants and executes a listing in a Run sandbox. v1 does neither: an install is an Exchange record and the sandbox is a dry run. The service will ask for <code>network.grants.write</code> and <code>network.grants.revoke</code> before an install/revoke can reach Network, <code>run.sandbox.execute</code> before a trial can run code, and <code>search.document.write</code> before the outbox can reach Search. Account export and deletion need <code>network.account.export.contribute</code> and <code>network.account.deletion.confirm</code>; the two Events subscriptions need <code>events.subscription.manage</code>.</p></section>
--</div>`;
--}
--
--function signIn({ title, text, next }) {
-- return `<div class="ex-wrap ex-page-pad ex-narrow"><div class="ex-empty"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="ex-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;
--}
--
--function notFound() {
-- return `<div class="ex-wrap ex-page-pad ex-narrow"><div class="ex-empty" id="ex-not-found"><p class="ex-kicker">404</p><h1>This listing is not here</h1><p>It may have been closed, hidden by staff, or the address was never a listing.</p><a class="ex-btn" href="/listings">Browse the listings</a></div></div>`;
--}
--
--module.exports = {
-- home, listingsPage, listingPage, submitForm, sandboxPage, installsPage, offersPage, safetyPage, developersPage,
-- signIn, notFound, passport, testsBlock, card, empty, kindLabel, modeBadge, statusBadge,
-- KIND_LABEL, MODE_LABEL, STATUS_LABEL, CLOSED_LABEL, PASSPORT_LABEL, SAFETY_NOTE,
--};
ddiff --git a/server/views/marks.js b/server/views/marks.js
nnew file mode 100644
iindex 0000000..668c718
---- /dev/null
++++ b/server/views/marks.js
@@@ -0,0 +1,81 @@
++'use strict';
++
++/**
++ * Model family marks and product marks.
++ *
++ * A family's mark is a monochrome glyph on a tint from the theme tokens (never a new colour), with the family
++ * name as its accessible label. An unknown family gets the neutral spark, so a row never shows a stranger's
++ * initials in a circle (the design review, 2026-11-06).
++ */
++const appIcons = require('openvibe-shared/app-icons');
++const { esc } = require('../render/html');
++
++const FAMILIES = {
++ DeepSeek: { hue: 'accent', path: 'M4 9c3-4 6-4 8 0s5 4 8 0M4 15c3-4 6-4 8 0s5 4 8 0' },
++ Qwen: { hue: 'ok', path: 'M12 3a9 9 0 1 0 5.2 16.3M17 13l3 8' },
++ Kimi: { hue: 'live', path: 'M15 3a9 9 0 1 0 6 15 8 8 0 0 1-6-15' },
++ GLM: { hue: 'warn', path: 'M12 2l8 5v10l-8 5-8-5V7zM12 7l4 2.5v5L12 17l-4-2.5v-5z' },
++ MiniMax: { hue: 'bad', path: 'M4 7l6 5-6 5M14 7l6 5-6 5' },
++ Llama: { hue: 'accent', path: 'M6 20v-7a6 6 0 0 1 12 0v7M9 20v-4M15 20v-4M8 8L6 3M16 8l2-5' },
++ Mistral: { hue: 'warn', path: 'M3 8h12a3 3 0 1 0-3-3M3 13h15a3 3 0 1 1-3 3M3 18h9' },
++ Gemma: { hue: 'ok', path: 'M12 2l7 10-7 10-7-10z' },
++ OpenAI: { hue: 'ok', path: 'M12 3a9 9 0 1 1-6.3 15.4M12 21a9 9 0 1 1 6.3-15.4M12 7v10M7.5 9.5l9 5M7.5 14.5l9-5' },
++ Anthropic: { hue: 'warn', path: 'M12 3v18M4 7l16 10M20 7L4 17' },
++ Google: { hue: 'accent', path: 'M20 12h-8M20 12a8 8 0 1 1-3-6.2' },
++ Meta: { hue: 'accent', path: 'M4 12a4 4 0 0 1 8 0 4 4 0 0 0 8 0 4 4 0 0 0-8 0 4 4 0 0 1-8 0' },
++ Microsoft: { hue: 'accent', path: 'M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z' },
++ Other: { hue: 'muted', path: 'M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z' },
++};
++
++/** A family name that the table has, else Other; the key is also the class's suffix. */
++function familyKey(family) {
++ const name = String(family || '').trim();
++ if (FAMILIES[name]) return name;
++ const lower = name.toLowerCase();
++ for (const key of Object.keys(FAMILIES)) if (key.toLowerCase() === lower) return key;
++ if (/deepseek/i.test(name)) return 'DeepSeek';
++ if (/qwen|qwq/i.test(name)) return 'Qwen';
++ if (/kimi|moonshot/i.test(name)) return 'Kimi';
++ if (/glm|zhipu|z\.ai/i.test(name)) return 'GLM';
++ if (/minimax/i.test(name)) return 'MiniMax';
++ if (/llama|meta/i.test(name)) return 'Llama';
++ if (/mistral|mixtral/i.test(name)) return 'Mistral';
++ if (/gemma/i.test(name)) return 'Gemma';
++ if (/gpt|openai|o1|o3/i.test(name)) return 'OpenAI';
++ if (/claude|anthropic/i.test(name)) return 'Anthropic';
++ if (/gemini|google/i.test(name)) return 'Google';
++ if (/microsoft|phi/i.test(name)) return 'Microsoft';
++ return 'Other';
++}
++
++/**
++ * mark(family, { size, label }) — the family's glyph; the name is the aria-label, never a letter in a circle.
++ * label: false leaves it decorative (a row already names the family).
++ */
++function mark(family, { size = 28, label = true, cls = '' } = {}) {
++ const key = familyKey(family);
++ const def = FAMILIES[key];
++ const px = Math.max(16, Math.min(64, Number(size) || 28));
++ const name = String(family || key);
++ return `<span class="ex-mk ex-mk--${key.toLowerCase()}${cls ? ` ${esc(cls)}` : ''}" style="--ex-mk-size:${px}px"`
++ + `${label ? ` role="img" aria-label="${esc(name)}"` : ' aria-hidden="true"'}>`
++ + `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${def.path}"/></svg></span>`;
++}
++
++/** An OpenVibe product's own mark and full name (OpenVibe.Live, never "live"). */
++function product(service) {
++ const id = String(service || '').toLowerCase();
++ const site = appIcons.site(id);
++ const name = (site && site.name) || (id ? `OpenVibe.${id.replace(/(^|[^a-z])([a-z])/g, (m, a, b) => a + b.toUpperCase())}` : 'OpenVibe');
++ const src = appIcons.url(id, 192);
++ return { id, name, src };
++}
++
++function productMark(service, { size = 22 } = {}) {
++ const p = product(service);
++ const px = Number(size) || 22;
++ if (!p.src) return mark('Other', { size: px, label: false, cls: 'ex-mk--product' });
++ return `<img class="ex-mk ex-mk--product" src="${esc(p.src)}" width="${px}" height="${px}" alt="" loading="lazy" decoding="async">`;
++}
++
++module.exports = { mark, familyKey, product, productMark, FAMILIES };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex f84cfd6..53d2f70 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -1,19 +1,20 @@
'use strict';
/**
-- * ADR-033: Exchange's part of an account export (listings, installations, sandbox trials, offers) and of a deletion,
-- * applied through /internal/events with a stand-in Network. A deletion anonymises and closes a public listing with
-- * a Search tombstone, deletes a draft outright, and erases the person's installs, trials and offers; another
-- * person's listing stays. A redelivery changes nothing; a bad signature and a proxied request are refused.
++ * ADR-033: Exchange's part of an account export and of a deletion, applied through /internal/events with a stand-in
++ * Network. The export carries the person's v1 listings (kept read-only) and their new chats, messages and 18+
++ * confirmation. A deletion anonymises and closes a v1 listing, deletes a draft, and erases the person's installs,
++ * trials, offers, chats, messages and confirmation; another person's rows stay. A redelivery changes nothing; a bad
++ * signature and a proxied request are refused.
*/
const assert = require('assert');
const http = require('http');
const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/boot');
--const fixtures = require('./helpers/fixtures');
const { createNetworkSender, uncovered } = require('openvibe-sdk/account-data');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { TABLES } = require('../server/identity/account-data');
const { testDb } = require('./helpers/db');
++const { createFakeAi } = require('./helpers/ai');
const A = ids.newId('user');
const B = ids.newId('user');
@@@ -26,6 +27,9 @@ const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.accoun
const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };
const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
++const PASSPORT = '{"reads":[],"writes":[],"spends":[],"contacts":[]}';
++const MANIFEST = '{"name":"x","entry":"y"}';
++
async function startNetworkStub() {
const calls = [];
const server = http.createServer((req, res) => {
@@@ -43,13 +47,22 @@ async function startNetworkStub() {
return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
}
++async function seedListing(db, { owner, title, status = 'listed', kind = 'theme' }) {
++ const id = `exl_${ids.ulid()}`;
++ const slug = `t-${ids.ulid().slice(-10).toLowerCase()}`;
++ await db.exec(`INSERT INTO exchange_listings (id, slug, title, mode, summary, license, version, creator, owner, kind, status, listed_at, manifest, passport)
++ VALUES ($1, $2, $3, 'free', 'A summary.', 'CC0-1.0', '1.0.0', 'a maker', $4, $5, $6, $7::timestamptz, $8::jsonb, $9::jsonb)`,
++ [id, slug, title, owner, kind, status, status === 'listed' ? new Date().toISOString() : null, MANIFEST, PASSPORT]);
++ return { id, slug };
++}
++
(async () => {
await check('every table that names a person is covered or exempted with a reason', async () => {
const d = await testDb();
try {
const missing = await uncovered(d.db, {
tables: TABLES,
-- exempt: {account_data_events: 'the ledger of requests itself: which export and deletion exchange applied'},
++ exempt: { account_data_events: 'the ledger of requests itself: which export and deletion exchange applied' },
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
@@@ -57,19 +70,26 @@ async function startNetworkStub() {
const stub = await startNetworkStub();
const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'exchange', clientSecret: 'exchange-secret' });
-- const t = await boot({ env: { EXCHANGE_EVENTS_SECRET: SECRET }, accountSend: sender });
-- const alice = { id: 101, subject: A, username: 'alice', display_name: 'alice', role: 'user' };
-- const bob = { id: 102, subject: B, username: 'bob', display_name: 'bob', role: 'user' };
++ const t = await boot({ env: { EXCHANGE_EVENTS_SECRET: SECRET }, accountSend: sender, ai: createFakeAi() });
try {
-- const aListed = await fixtures.listed(t, alice, { title: 'Alice\'s listed theme', kind: 'theme' });
-- const aDraft = await fixtures.create(t, alice, { title: 'Alice\'s draft theme', kind: 'theme' });
-- const bListed = await fixtures.listed(t, bob, { title: 'Bob\'s listed theme', kind: 'theme' });
-- const sb = await fixtures.sandbox(t, alice, bListed.slug);
-- const inst = await fixtures.install(t, alice, bListed.slug);
-- const claim = await t.get(`/api/v1/listings/${bListed.slug}/offers`, { as: alice, json: { body: 'I would like Bob\'s theme.' } });
-- assert.strictEqual(claim.status, 201);
++ const db = t.ctx.s.db;
++ const aListed = await seedListing(db, { owner: A, title: 'Alice\'s listed theme' });
++ const aDraft = await seedListing(db, { owner: A, title: 'Alice\'s draft theme', status: 'draft' });
++ const bListed = await seedListing(db, { owner: B, title: 'Bob\'s listed theme' });
++ const trial = `exs_${ids.ulid()}`;
++ await db.exec(`INSERT INTO exchange_sandbox_trials (id, listing_id, subject, version, status, passport, checks, expires_at)
++ VALUES ($1, $2, $3, '1.0.0', 'passed', $4::jsonb, '[]'::jsonb, now() + interval '1 hour')`, [trial, bListed.id, A, PASSPORT]);
++ const install = `exi_${ids.ulid()}`;
++ await db.exec(`INSERT INTO exchange_installs (id, listing_id, subject, version, passport) VALUES ($1, $2, $3, '1.0.0', $4::jsonb)`, [install, bListed.id, A, PASSPORT]);
++ const offer = `exo_${ids.ulid()}`;
++ await db.exec(`INSERT INTO exchange_offers (id, listing_id, from_subject, from_name, kind, body) VALUES ($1, $2, $3, 'alice', 'claim', 'I would like Bob''s theme.')`, [offer, bListed.id, A]);
++
++ const chat = await t.ctx.chats.create({ owner: A, models: ['deepseek/deepseek-chat'], title: 'A question for the models' });
++ await t.ctx.chats.append(A, chat.id, { role: 'user', content: 'What is a mutex?' });
++ await t.ctx.chats.append(A, chat.id, { role: 'assistant', model: 'deepseek/deepseek-chat', content: 'A lock.', meta: { run_id: 'run_x' } });
++ await t.ctx.adult.confirm(A, '1');
-- await check('an export carries Alice\'s listings, trial, install and offer, and not Bob\'s', async () => {
++ await check('an export carries Alice\'s v1 rows, chats, messages and confirmation, and not Bob\'s', async () => {
const body = bodyOf(exportEvent);
const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
assert.strictEqual(res.status, 200, res.text);
@@@ -78,22 +98,23 @@ async function startNetworkStub() {
assert.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_exchange');
assert.strictEqual(part.body.subject, A);
-- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['installs.json', 'listings.json', 'offers.json', 'sandbox-trials.json']);
++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(),
++ ['adult-confirmation.json', 'chat-messages.json', 'chats.json', 'installs.json', 'listings.json', 'offers.json', 'sandbox-trials.json']);
const all = JSON.stringify(part.body);
assert.ok(all.includes('Alice\'s listed theme'));
assert.ok(all.includes('Alice\'s draft theme'));
-- assert.ok(!all.includes('Bob\'s listed theme'), 'nobody else\'s listing');
-- assert.strictEqual(part.body.files.find((f) => f.name === 'installs.json').content[0].id, inst.id);
-- assert.strictEqual(part.body.files.find((f) => f.name === 'sandbox-trials.json').content[0].id, sb.trial.id);
++ assert.ok(!all.includes('Bob\'s listed theme'), 'nobody else\'s row');
++ assert.ok(all.includes('A question for the models') && all.includes('What is a mutex?') && all.includes('A lock.'), 'the chat and its messages');
++ assert.strictEqual(part.body.files.find((f) => f.name === 'installs.json').content[0].id, install);
++ assert.strictEqual(part.body.files.find((f) => f.name === 'sandbox-trials.json').content[0].id, trial);
assert.match(all, /I would like Bob/);
});
-- await check('a deletion closes the listed listing with a tombstone, deletes the draft and Alice\'s rows; Bob stays', async () => {
++ await check('a deletion closes the listed listing, deletes the draft, and erases Alice\'s rows; Bob stays', async () => {
const body = bodyOf(deleteEvent);
const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
assert.strictEqual(res.status, 200, res.text);
assert.strictEqual(res.json().outcome, 'erased');
-- const db = t.ctx.s.db;
const listed = await db.maybe('SELECT owner, status, closed_reason, title, summary, creator FROM exchange_listings WHERE id = $1', [aListed.id]);
assert.strictEqual(listed.owner, null);
assert.strictEqual(listed.status, 'closed');
@@@ -105,12 +126,12 @@ async function startNetworkStub() {
const bobRow = await db.maybe('SELECT owner, status FROM exchange_listings WHERE id = $1', [bListed.id]);
assert.strictEqual(bobRow.owner, B);
assert.strictEqual(bobRow.status, 'listed');
-- assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_installs WHERE id = $1', [inst.id])), 0);
-- assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_sandbox_trials WHERE id = $1', [sb.trial.id])), 0);
-- assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_offers WHERE id = $1', [claim.json().id])), 0);
-- const tomb = await db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' AND payload->>'id' = $1", [aListed.id]);
-- assert.ok(tomb, 'a tombstone was queued');
-- assert.strictEqual(tomb.payload.deleted, true);
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_installs WHERE id = $1', [install])), 0);
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_sandbox_trials WHERE id = $1', [trial])), 0);
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_offers WHERE id = $1', [offer])), 0);
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_chats WHERE owner = $1', [A])), 0, 'the chat is erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_chat_messages WHERE owner = $1', [A])), 0, 'its messages are erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_adult_confirmations WHERE subject = $1', [A])), 0, 'the confirmation is erased');
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
assert.ok(confirmation, 'the confirmation was sent');
assert.ok(confirmation.body.erased.exchange_listings_drafts >= 1);
@@@ -123,7 +144,6 @@ async function startNetworkStub() {
const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.json().outcome, 'unchanged');
-- assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' AND payload->>'id' = $1", [aListed.id])), 1, 'no second tombstone');
});
await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
ddiff --git a/test/agent-layer.test.js b/test/agent-layer.test.js
iindex dcadc5f..6cb445b 100644
---- a/test/agent-layer.test.js
++++ b/test/agent-layer.test.js
@@@ -1,16 +1,14 @@
'use strict';
/**
-- * The agent layer: /.well-known/agent.json, the home's discovery <link> and Link header, and Markdown twins of
-- * the public pages rendered signed out. An unknown twin is a 404; the well-known prefix is not swallowed.
++ * The agent layer: /.well-known/agent.json, the home's discovery <link> and Link header, and Markdown twins of the
++ * new public pages rendered signed out. A chat has no twin and is a 404; the well-known prefix is not swallowed.
*/
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
--const fixtures = require('./helpers/fixtures');
++const { createFakeAi } = require('./helpers/ai');
(async () => {
-- const t = await boot();
-- const alice = t.network.addUser('alice');
-- const listing = await fixtures.listed(t, alice, { title: 'A twin-tested template', summary: 'A template with a Markdown twin.' });
++ const t = await boot({ ai: createFakeAi() });
try {
await check('manifest is JSON with only the endpoints Exchange really serves', async () => {
const r = await t.get('/.well-known/agent.json');
@@@ -19,11 +17,10 @@ const fixtures = require('./helpers/fixtures');
const m = r.json();
assert.strictEqual(m.id, 'exchange');
assert.strictEqual(m.home, 'https://openvibe.exchange');
-- assert.strictEqual(m.api, 'https://openvibe.exchange/api/v1');
-- assert.strictEqual(m.docs, 'https://openvibe.exchange/developers');
++ assert.strictEqual(m.api, 'https://api.openvibe.exchange/v1');
++ assert.strictEqual(m.docs, 'https://openvibe.exchange/api');
assert.deepStrictEqual(m.policy.auth, ['none', 'Network OIDC']);
-- assert.strictEqual(m.openapi, 'https://openvibe.exchange/openapi.json', 'the API document it serves (SEO-3)');
-- assert.strictEqual(m.mcp, undefined);
++ assert.strictEqual(m.openapi, undefined, 'the API document is AI’s, not this site’s');
});
await check('the home names its twin in the head and the Link header', async () => {
@@@ -32,34 +29,29 @@ const fixtures = require('./helpers/fixtures');
assert.ok(r.text.includes('<link rel="alternate" type="text/markdown" href="https://openvibe.exchange/index.md">'));
assert.ok(r.text.includes('<link rel="describedby" href="/llms.txt">'));
assert.match(r.headers.get('link'), /https:\/\/openvibe\.exchange\/index\.md>; rel="alternate"; type="text\/markdown"/);
-- assert.ok(r.text.includes('A twin-tested template'));
});
-- await check('/index.md is Markdown of the same home, and its listings', async () => {
++ await check('/index.md is Markdown of the same home', async () => {
const r = await t.get('/index.md');
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type'), /text\/markdown/);
assert.ok(r.text.startsWith('---\ntitle:'));
-- assert.ok(r.text.includes('# OpenVibe.Exchange: Trade what people make.'));
-- assert.ok(r.text.includes('A twin-tested template'));
++ assert.ok(r.text.includes('Every AI model, one account.'));
++ assert.ok(r.text.includes('DeepSeek Chat'));
});
-- await check('one listing twin is the public page, a signed-in-only page and an unknown .md are 404', async () => {
-- const r = await t.get(`/listings/${listing.slug}.md`);
++ await check('a model page has a twin; a chat and an unknown .md are 404', async () => {
++ const r = await t.get('/m/deepseek/deepseek-chat.md');
assert.strictEqual(r.status, 200);
-- assert.match(r.headers.get('content-type'), /text\/markdown/);
-- assert.ok(r.text.includes('# A twin-tested template'), 'title heading');
-- assert.ok(r.text.includes('CC0-1.0'), 'the licence from the same view');
-- assert.ok(r.text.includes('A template with a Markdown twin.'), 'the summary');
-- assert.ok(!r.text.includes('Run the sandbox'), 'a signed-out twin does not offer a sandbox run');
-- assert.strictEqual((await t.get('/installs.md')).status, 404);
++ assert.ok(r.text.includes('# DeepSeek Chat'));
++ assert.ok(r.text.includes('no training'));
++ assert.strictEqual((await t.get('/chat.md')).status, 404);
assert.strictEqual((await t.get('/no-such-page.md')).status, 404);
});
await check('/.well-known/security.txt is not swallowed by the layer', async () => {
const r = await t.get('/.well-known/security.txt');
-- assert.strictEqual(r.status, 404);
-- assert.doesNotMatch(r.headers.get('content-type') || '', /application\/json/);
++ assert.notStrictEqual(r.status, 200, 'the layer only takes agent.json');
});
} finally {
await t.close();
ddiff --git a/test/api.test.js b/test/api.test.js
ddeleted file mode 100644
iindex c9c3cfd..0000000
---- a/test/api.test.js
++++ /dev/null
@@@ -1,163 +0,0 @@
--'use strict';
--/**
-- * The JSON API: a draft is created and is not public; publishing it needs the conformance tests; the public list
-- * pages with an opaque cursor and filters; detail/passport/tests shapes; validation refusals; owner and staff
-- * visibility; close and hide. There is no price in any shape.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./helpers/boot');
--const fixtures = require('./helpers/fixtures');
--
--(async () => {
-- const t = await boot();
-- const alice = t.network.addUser('alice');
-- const bob = t.network.addUser('bob');
-- const mod = t.network.addUser('mod', { role: 'global_mod' });
-- try {
-- let draft = null;
-- await check('POST /listings saves a draft with its passport and tests; it is not public yet', async () => {
-- draft = await fixtures.create(t, alice);
-- assert.match(draft.id, /^exl_[0-9A-HJKMNP-TV-Z]{26}$/);
-- assert.strictEqual(draft.slug, 'starter-site-template');
-- assert.strictEqual(draft.status, 'draft');
-- assert.strictEqual(draft.mode, 'free');
-- assert.deepStrictEqual(draft.passport.spends, []);
-- assert.strictEqual(draft.tests.verified, true);
-- assert.strictEqual(draft.tests.count, 4);
-- assert.ok(!('price' in draft));
-- const pub = await t.get('/api/v1/listings');
-- assert.strictEqual(pub.json().count, 0);
-- assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`)).status, 404);
-- });
--
-- await check('the owner and staff see a draft; another person does not', async () => {
-- const owner = await t.get(`/api/v1/listings/${draft.slug}`, { as: alice });
-- assert.strictEqual(owner.status, 200);
-- assert.strictEqual(owner.json().status, 'draft');
-- assert.strictEqual(owner.json().owner, alice.subject);
-- assert.strictEqual(owner.json().mine, true);
-- assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`, { as: bob })).status, 404);
-- const staff = await t.get(`/api/v1/listings/${draft.slug}`, { as: mod });
-- assert.strictEqual(staff.status, 200);
-- assert.strictEqual(staff.json().staff, true);
-- });
--
-- await check('publishing makes it public with the passport, licence and versioned tests', async () => {
-- const pub = await t.get(`/api/v1/listings/${draft.slug}/list`, { as: alice, method: 'POST' });
-- assert.strictEqual(pub.status, 200, pub.text);
-- assert.strictEqual(pub.json().status, 'listed');
-- const list = await t.get('/api/v1/listings?mode=free&kind=template');
-- assert.strictEqual(list.json().count, 1);
-- assert.strictEqual(list.headers.get('cache-control'), 'public, max-age=60');
-- const one = list.json().listings[0];
-- assert.strictEqual(one.slug, draft.slug);
-- assert.strictEqual(one.tests.verified, true);
-- assert.deepStrictEqual(one.passport.reads, ['search.read']);
-- const detail = await t.get(`/api/v1/listings/${draft.slug}`);
-- assert.strictEqual(detail.status, 200);
-- assert.strictEqual(detail.headers.get('cache-control'), 'public, max-age=60');
-- assert.strictEqual(detail.json().license, 'CC0-1.0');
-- assert.strictEqual(detail.json().manifest.entry, 'templates/starter/index.html');
-- assert.strictEqual(detail.json().tests.rows.length, 4);
-- const passport = await t.get(`/api/v1/listings/${draft.slug}/passport`);
-- assert.strictEqual(passport.status, 200);
-- assert.deepStrictEqual(passport.json().passport.writes, ['space.files.write']);
-- assert.deepStrictEqual(passport.json().passport.spends, []);
-- const tests = await t.get(`/api/v1/listings/${draft.slug}/tests`);
-- assert.strictEqual(tests.json().verified, true);
-- assert.deepStrictEqual(tests.json().tests.map((x) => x.name).sort(), ['licence', 'manifest', 'no_money', 'passport']);
-- });
--
-- await check('the list pages with an opaque cursor and filters, and refuses a garbage cursor', async () => {
-- const two = await fixtures.listed(t, alice, { title: 'A calm theme kit', kind: 'theme', mode: 'swap', wants: 'A type kit' });
-- const three = await fixtures.listed(t, alice, { title: 'A surf map', kind: 'game_asset' });
-- const one = await t.get('/api/v1/listings?limit=2');
-- assert.strictEqual(one.json().count, 2);
-- assert.ok(one.json().next_cursor);
-- const next = await t.get(`/api/v1/listings?limit=2&cursor=${encodeURIComponent(one.json().next_cursor)}`);
-- assert.strictEqual(next.status, 200);
-- assert.strictEqual(next.json().count, 1);
-- assert.strictEqual(next.json().next_cursor, null);
-- assert.strictEqual((await t.get('/api/v1/listings?cursor=garbage')).status, 400);
-- assert.strictEqual((await t.get('/api/v1/listings?kind=nope')).status, 400);
-- assert.strictEqual((await t.get('/api/v1/listings?mode=paid')).status, 400);
-- const swaps = await t.get('/api/v1/listings?mode=swap');
-- assert.strictEqual(swaps.json().count, 1);
-- assert.strictEqual(swaps.json().listings[0].slug, two.slug);
-- const q = await t.get('/api/v1/listings?q=surf');
-- assert.strictEqual(q.json().count, 1);
-- assert.strictEqual(q.json().listings[0].slug, three.slug);
-- });
--
-- await check('a bad listing is refused with a code, in the API and the form alike', async () => {
-- const post = (body) => t.get('/api/v1/listings', { as: alice, json: body });
-- const base = fixtures.body();
-- assert.strictEqual((await post({ ...base, title: '' })).status, 400);
-- assert.strictEqual((await post({ ...base, spends: [] })).status, 400);
-- const money = await post({ ...base, summary: 'Yours for $20, cash only.' });
-- assert.strictEqual(money.status, 400);
-- assert.strictEqual(money.json().code, 'exchange.prohibited');
-- const spend = await post({ ...base, passport: { ...base.passport, spends: ['billing.spend'] } });
-- assert.strictEqual(spend.status, 400);
-- assert.strictEqual(spend.json().code, 'exchange.no_money');
-- const secret = await post({ ...base, manifest: { ...base.manifest, token: 'ghp_123' } });
-- assert.strictEqual(secret.status, 400);
-- const secretValue = await post({ ...base, manifest: { ...base.manifest, entry: 'api_key=sk-live-123' } });
-- assert.strictEqual(secretValue.status, 400);
-- assert.strictEqual(secretValue.json().code, 'exchange.manifest_secret');
-- const freeWants = await post({ ...base, mode: 'free', wants: 'A type kit' });
-- assert.strictEqual(freeWants.status, 400);
-- const swapNoWants = await post({ ...base, mode: 'swap', wants: '' });
-- assert.strictEqual(swapNoWants.status, 400);
-- const badVersion = await post({ ...base, version: 'latest' });
-- assert.strictEqual(badVersion.status, 400);
-- });
--
-- await check('a malformed body and an unknown API route are problem+json', async () => {
-- const bad = await t.get('/api/v1/listings', { as: alice, body: '{', headers: { 'content-type': 'application/json' } });
-- assert.strictEqual(bad.status, 400);
-- assert.match(bad.headers.get('content-type'), /application\/problem\+json/);
-- assert.strictEqual(bad.json().code, 'request.malformed_json');
-- const nope = await t.get('/api/v1/nope');
-- assert.strictEqual(nope.status, 404);
-- assert.strictEqual(nope.json().code, 'route.not_found');
-- });
--
-- await check('/me/listings has every status of yours', async () => {
-- const draft2 = await fixtures.create(t, alice, { title: 'A second draft' });
-- const mine = await t.get('/api/v1/me/listings', { as: alice });
-- assert.strictEqual(mine.status, 200);
-- assert.ok(mine.json().listings.some((x) => x.slug === draft2.slug && x.status === 'draft'));
-- assert.strictEqual((await t.get('/api/v1/me/listings')).status, 401);
-- });
--
-- await check('close is the owner\'s, removes it from the public list, and is idempotent', async () => {
-- const close = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: bob, method: 'POST' });
-- assert.strictEqual(close.status, 404);
-- const ok = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: alice, method: 'POST', json: { reason: 'withdrawn' } });
-- assert.strictEqual(ok.status, 200);
-- assert.strictEqual(ok.json().status, 'closed');
-- assert.strictEqual(ok.json().closed_reason, 'withdrawn');
-- assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`)).status, 404);
-- const again = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: alice, method: 'POST', json: {} });
-- assert.strictEqual(again.status, 200);
-- const pub = await t.get('/api/v1/listings?limit=50');
-- assert.ok(!pub.json().listings.some((x) => x.slug === draft.slug));
-- });
--
-- await check('staff can hide a listing; the owner still sees it', async () => {
-- const l = await fixtures.listed(t, alice, { title: 'A hideable template' });
-- assert.strictEqual((await t.get(`/api/v1/listings/${l.slug}/hide`, { as: alice, method: 'POST' })).status, 403);
-- const hide = await t.get(`/api/v1/listings/${l.slug}/hide`, { as: mod, method: 'POST' });
-- assert.strictEqual(hide.status, 200);
-- assert.strictEqual(hide.json().status, 'hidden');
-- assert.strictEqual((await t.get(`/api/v1/listings/${l.slug}`)).status, 404);
-- const owner = await t.get(`/api/v1/listings/${l.slug}`, { as: alice });
-- assert.strictEqual(owner.status, 200);
-- assert.strictEqual(owner.json().status, 'hidden');
-- });
-- } finally {
-- await t.close();
-- }
-- done();
--})();
ddiff --git a/test/chat.test.js b/test/chat.test.js
nnew file mode 100644
iindex 0000000..0929803
---- /dev/null
++++ b/test/chat.test.js
@@@ -0,0 +1,124 @@
++'use strict';
++/**
++ * /chat on the shared conversation UI: one model or three side by side, a system prompt and a temperature, the
++ * history in the person's account, the daily allowance, the 18+ gate for an uncensored model, and one account's
++ * chat never readable by another.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++
++(async () => {
++ const ai = createFakeAi();
++ const t = await boot({ ai });
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ try {
++ const confirmed = await t.get('/uncensored/confirm', { as: alice, form: { adult: 'yes' } });
++ assert.strictEqual(confirmed.status, 303, 'alice confirms 18+ for the comparison');
++
++ await check('signed out, chat asks for a sign-in', async () => {
++ const r = await t.get('/chat');
++ assert.strictEqual(r.status, 401);
++ assert.ok(r.text.includes('Sign in with OpenVibe'));
++ });
++
++ await check('the start page is the shared conversation UI with model chips', async () => {
++ const r = await t.get('/chat/new', { as: alice });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('/shared/thread.css') && r.text.includes('/shared/ov-thread.js'));
++ assert.ok(r.text.includes('ovt-composer') && r.text.includes('ovk-sidebar'));
++ assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Llama 3 8B abliterated'), 'the model chips');
++ assert.ok(r.text.includes('System prompt') && r.text.includes('Temperature'));
++ });
++
++ let chatUrl = null;
++ await check('a message runs the chosen models side by side and stores the history', async () => {
++ const r = await t.get('/chat/send', {
++ as: alice,
++ form: {
++ text: 'What is a mutex?',
++ 'models_deepseek/deepseek-chat': '1',
++ 'models_meta-llama/llama-3-8b-abliterated': '1',
++ system: 'Be brief.',
++ temperature: '0.4',
++ },
++ });
++ assert.strictEqual(r.status, 303, r.text);
++ chatUrl = r.headers.get('location');
++ assert.match(chatUrl, /^\/chat\/exc_/);
++ assert.strictEqual(ai.calls.length, 2, 'one run per model');
++ assert.deepStrictEqual(ai.calls.map((c) => c.model).sort(), ['deepseek/deepseek-chat@v1', 'openvibe/auto'], 'the exact id where one exists');
++ assert.ok(ai.calls.every((c) => c.subject === alice.subject), 'each run is on the person’s behalf');
++ assert.strictEqual(ai.calls[0].temperature, 0.4);
++ assert.strictEqual(ai.calls[0].messages[0].role, 'system');
++
++ const page = await t.get(chatUrl, { as: alice });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('What is a mutex?'));
++ assert.ok(page.text.includes('An answer from deepseek/deepseek-chat@v1.'));
++ assert.ok(page.text.includes('An answer from openvibe/auto.'));
++ assert.match(page.headers.get('cache-control'), /private|no-store/);
++ assert.ok(page.text.includes('ex-answer-grid') && page.text.includes('data-count="2"'), 'the two answers sit in one round');
++ assert.ok(page.text.includes('ex-receipt') && page.text.includes('Why this offer'), 'a folded receipt under each answer');
++ assert.ok(page.text.includes('$0.000120') && page.text.includes('420 ms'), 'the receipt shows the price and latency');
++ assert.ok(page.text.includes('ex-mk--deepseek') && page.text.includes('ex-mk--llama'), 'answers carry family marks');
++ assert.ok(!page.text.includes('class="ovt-avatar"'), 'no stray empty avatar above an answer');
++ });
++
++ await check('the next message carries the history, and a second model’s answers never bleed across', async () => {
++ const before = ai.calls.length;
++ const r = await t.get(`${chatUrl}/send`, {
++ as: alice,
++ form: { text: 'And a semaphore?', 'models_deepseek/deepseek-chat': '1', 'models_meta-llama/llama-3-8b-abliterated': '1' },
++ });
++ assert.strictEqual(r.status, 303);
++ const fresh = ai.calls.slice(before);
++ assert.strictEqual(fresh.length, 2);
++ const deep = fresh.find((c) => c.model === 'deepseek/deepseek-chat@v1');
++ assert.deepStrictEqual(deep.messages.map((m) => m.role), ['user', 'assistant', 'user'], 'its own answer is in the history');
++ assert.strictEqual(deep.messages[1].content, 'An answer from deepseek/deepseek-chat@v1.');
++ });
++
++ await check('another account cannot read the chat', async () => {
++ assert.strictEqual((await t.get(chatUrl, { as: bob })).status, 404);
++ assert.strictEqual((await t.get('/chat', { as: bob })).status, 200, 'bob has his own start page');
++ });
++
++ await check('an uncensored model needs the 18+ confirmation first', async () => {
++ const before = ai.calls.length;
++ const r = await t.get('/chat/send', { as: bob, form: { text: 'Hello', 'models_meta-llama/llama-3-8b-abliterated': '1' } });
++ assert.strictEqual(r.status, 403);
++ assert.ok(r.text.includes('18 or over'));
++ assert.strictEqual(ai.calls.length, before, 'no run happened');
++ const confirm = await t.get('/uncensored/confirm', { as: bob, form: { adult: 'yes' } });
++ assert.strictEqual(confirm.status, 303);
++ const ok = await t.get('/chat/send', { as: bob, form: { text: 'Hello', 'models_meta-llama/llama-3-8b-abliterated': '1' } });
++ assert.strictEqual(ok.status, 303);
++ });
++
++ await check('a cross-site send is refused', async () => {
++ const r = await t.get('/chat/send', { as: alice, json: { text: 'hi' }, origin: 'https://evil.test' });
++ assert.strictEqual(r.status, 403);
++ });
++
++ await check('a chat belongs to the person who made it, and can be deleted', async () => {
++ const del = await t.get(`${chatUrl}/delete`, { as: alice, method: 'POST' });
++ assert.strictEqual(del.status, 303);
++ assert.strictEqual((await t.get(chatUrl, { as: alice })).status, 404);
++ });
++ } finally { await t.close(); }
++
++ const capped = await boot({ ai: createFakeAi(), env: { EXCHANGE_CHAT_PER_DAY: '1' } });
++ const carol = capped.network.addUser('carol');
++ try {
++ await check('the daily allowance stops at its number', async () => {
++ const first = await capped.get('/chat/send', { as: carol, form: { text: 'one' } });
++ assert.strictEqual(first.status, 303);
++ const second = await capped.get('/chat/send', { as: carol, form: { text: 'two' } });
++ assert.strictEqual(second.status, 429);
++ assert.ok(second.text.includes('resets at midnight UTC'));
++ });
++ } finally { await capped.close(); }
++ done();
++})();
ddiff --git a/test/contrast.test.js b/test/contrast.test.js
ddeleted file mode 100644
iindex 332a15f..0000000
---- a/test/contrast.test.js
++++ /dev/null
@@@ -1,14 +0,0 @@
--'use strict';
--// WCAG 2.1 AA: the passport stamp's small line was 3.8:1 at .8 opacity. It now sits at .95.
--// node test/contrast.test.js
--const assert = require('assert');
--const fs = require('fs');
--const path = require('path');
--
--const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'exchange.css'), 'utf8');
--assert.ok(
-- css.includes('.ex-motif-passport small { font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; opacity: .95; }'),
-- 'the passport small line is .95, not .8'
--);
--assert.ok(!css.includes('.ex-motif-passport small { font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; opacity: .8; }'), 'the .8 stamp line is gone');
--console.log('contrast: the passport stamp reads');
ddiff --git a/test/first-party.test.js b/test/first-party.test.js
ddeleted file mode 100644
iindex 769bffa..0000000
---- a/test/first-party.test.js
++++ /dev/null
@@@ -1,284 +0,0 @@
--'use strict';
--/**
-- * The first-party catalogue: Design's built-in themes and their token files, Live's OBS scenes and alerts, and Games'
-- * public worlds. The job is idempotent, keeps each thing's real home, marks it official without inventing a person,
-- * retires what a source no longer lists, and never touches a person's own listing.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./helpers/boot');
--const fixtures = require('./helpers/fixtures');
--
--const THEMES = {
-- themes: [
-- {
-- id: 'vibe', name: 'Vibe', slug: 'vibe', mode: 'dark',
-- description: 'The default OpenVibe theme.', is_builtin: 1,
-- variables: {
-- '--bg-primary': '#0a0f1c', '--bg-secondary': '#101828', '--bg-card': '#131c2e',
-- '--text-primary': '#e6edf7', '--text-secondary': '#96a7c2', '--accent': '#3b82f6', '--border': '#1f2d47',
-- },
-- preview_colors: { bg: '#0A0F1C', accent: '#3B82F6', text: '#E6EDF7' },
-- owner_email: '[email protected]', api_key: 'should_never_be_listed',
-- },
-- {
-- id: 'paper', name: 'Paper', slug: 'paper', mode: 'light', description: 'A quiet light theme.', is_builtin: true,
-- variables: { '--bg-primary': '#fffbeb', '--bg-card': '#fffdf3', '--text-primary': '#1f1f1f', '--accent': '#644ac9', '--text-secondary': 'warm grey', '--border': '#e3dab8' },
-- },
-- { id: 'made-by-a-person', name: 'Mine', slug: 'mine', mode: 'dark', description: 'A community theme.', is_builtin: 0, variables: {} },
-- ],
--};
--
--const TARGETS = {
-- targets: [
-- { id: 'css', label: 'CSS variables', ext: 'css', lang: 'css' },
-- { id: 'json', label: 'Design tokens', ext: 'tokens.json', lang: 'json' },
-- ],
--};
--
--const WORLDS = {
-- worlds: [
-- { id: 'wabc123def0', title: 'Floating islands', summary: 'A world of bridges in the sky.', owner: 'maker', plays: 12, online: 0, play_url: '/play?world=wabc123def0' },
-- ],
-- page: 1,
-- more: false,
--};
--
--function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGames = false, failTargets = false } = {}) {
-- return async (url) => {
-- const u = new URL(url);
-- if (u.hostname === 'openvibe.design' && u.pathname === '/api/themes') return { ok: true, status: 200, json: async () => themes };
-- if (u.hostname === 'openvibe.design' && u.pathname === '/api/v1/tokens') {
-- if (failTargets) return { ok: false, status: 503, json: async () => ({}) };
-- return { ok: true, status: 200, json: async () => targets };
-- }
-- if (u.hostname === 'openvibe.games' && u.pathname === '/api/worlds') {
-- if (failGames) throw new Error('Games is restarting');
-- return { ok: true, status: 200, json: async () => worlds };
-- }
-- return { ok: false, status: 404, json: async () => ({}) };
-- };
--}
--
--(async () => {
-- const t = await boot();
-- const alice = t.network.addUser('alice');
-- try {
-- const fetch = fakeFetch();
-- let themeId = null;
-- let revision = null;
--
-- await check('a first pass lists each theme once, with its token files, plus the OBS templates and a public world', async () => {
-- const out = await t.ctx.firstParty.runOnce({ sources: ['design', 'obs', 'games'], fetchImpl: fetch });
-- assert.strictEqual(out.sources.design.themes.ok, true, JSON.stringify(out.sources.design));
-- assert.strictEqual(out.sources.design.themes.imported, 2, 'only the two built-in themes');
-- assert.strictEqual(out.sources.design.removed_tokens, 0);
-- assert.strictEqual(out.sources.obs.imported, 4, 'three scenes and the alerts overlay');
-- assert.strictEqual(out.sources.games.imported, 1);
--
-- const list = await t.get('/api/v1/listings?limit=50');
-- assert.strictEqual(list.status, 200, list.text);
-- assert.strictEqual(list.json().count, 7);
-- const rows = list.json().listings;
-- for (const row of rows) {
-- assert.strictEqual(row.official, true, `${row.slug} is official`);
-- assert.match(row.home_url, /^https:\/\//, `${row.slug} keeps a real home`);
-- assert.strictEqual(row.status, 'listed');
-- assert.strictEqual(row.mode, 'free');
-- assert.ok(!('owner' in row), 'the public list never carries an owner');
-- }
--
-- const theme = rows.find((r) => r.slug === 'vibe-theme');
-- assert.ok(theme, 'the Vibe theme is listed');
-- themeId = theme.id;
-- revision = theme.revision;
-- assert.strictEqual(theme.kind, 'theme');
-- assert.strictEqual(theme.creator, 'OpenVibe');
-- assert.strictEqual(theme.home_url, 'https://openvibe.design/studio?theme=vibe');
-- assert.strictEqual(theme.manifest.entry, theme.home_url);
-- assert.strictEqual(theme.license, 'AGPL-3.0-only');
-- assert.deepStrictEqual(theme.files, [
-- { format: 'css', label: 'CSS variables', url: 'https://openvibe.design/api/v1/tokens/vibe?format=css' },
-- { format: 'json', label: 'Design tokens', url: 'https://openvibe.design/api/v1/tokens/vibe?format=json' },
-- ], 'the theme carries its token files');
-- assert.deepStrictEqual(theme.palette, ['#0a0f1c', '#131c2e', '#e6edf7', '#3b82f6', '#96a7c2', '#1f2d47'], 'six preview colours, in order');
-- const paper = rows.find((r) => r.slug === 'paper-theme');
-- assert.deepStrictEqual(paper.palette, ['#fffbeb', '#fffdf3', '#1f1f1f', '#644ac9', '#e3dab8'], 'only valid hex, up to six');
-- assert.ok(!rows.some((r) => r.home_url.includes('?format=')), 'no token file is a listing of its own');
-- assert.strictEqual(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'design.tokens'"), 0);
--
-- const world = rows.find((r) => r.slug === 'floating-islands');
-- assert.ok(world, 'the public world is listed');
-- assert.strictEqual(world.kind, 'game_asset');
-- assert.strictEqual(world.creator, 'maker', 'the real maker is credited on a network listing');
-- assert.strictEqual(world.home_url, 'https://openvibe.games/play?world=wabc123def0');
--
-- assert.ok(rows.some((r) => r.home_url === 'https://openvibe.live/obs/scene/starting/openvibe'));
-- assert.ok(rows.some((r) => r.home_url === 'https://openvibe.live/obs/scene/brb/openvibe'));
-- assert.ok(rows.some((r) => r.home_url === 'https://openvibe.live/obs/scene/ending/openvibe'));
-- assert.ok(rows.some((r) => r.home_url === 'https://openvibe.live/obs/alerts/openvibe?demo=1'));
-- assert.ok(!rows.some((r) => r.home_url.includes('/mine')), 'a community theme is not relisted as first-party');
-- });
--
-- await check('the palette is in the API and drawn on the card and the listing page', async () => {
-- const api = await t.get('/api/v1/listings?limit=50');
-- const theme = api.json().listings.find((r) => r.slug === 'vibe-theme');
-- assert.deepStrictEqual(theme.palette, ['#0a0f1c', '#131c2e', '#e6edf7', '#3b82f6', '#96a7c2', '#1f2d47']);
-- const page = await t.get('/listings/vibe-theme');
-- assert.strictEqual(page.status, 200, page.text);
-- assert.match(page.text, /class="ex-palette"/);
-- for (const colour of theme.palette) assert.ok(page.text.includes(`<i style="background:${colour}"></i>`), colour);
-- const card = await t.get('/listings?kind=theme');
-- assert.strictEqual(card.status, 200, card.text);
-- assert.match(card.text, /<i style="background:#0a0f1c"><\/i>/);
-- assert.match(card.text, /<i style="background:#3b82f6"><\/i>/);
-- });
--
-- await check('a second pass changes nothing and creates no duplicate', async () => {
-- const before = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings');
-- const out = await t.ctx.firstParty.runOnce({ sources: ['design', 'obs', 'games'], fetchImpl: fetch });
-- assert.strictEqual(out.sources.design.themes.imported, 2);
-- assert.strictEqual(out.sources.design.removed_tokens, 0);
-- assert.strictEqual(out.sources.games.imported, 1);
-- assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings'), before);
-- const dupes = await t.ctx.s.db.many('SELECT source, source_id, COUNT(*)::int AS n FROM exchange_listings WHERE source IS NOT NULL GROUP BY 1, 2 HAVING COUNT(*) > 1');
-- assert.deepStrictEqual(dupes, []);
-- const theme = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
-- assert.strictEqual(theme.revision, revision, 'an unchanged row is untouched');
-- });
--
-- await check('the old one-listing-per-token-file rows are removed, idempotently', async () => {
-- const legacy = await t.ctx.entries.upsertFirstParty({
-- source: 'design.tokens',
-- sourceId: 'vibe:css',
-- creator: 'OpenVibe',
-- body: {
-- title: 'Vibe tokens — CSS variables', kind: 'template', mode: 'free',
-- summary: 'An old per-file listing.', license: 'AGPL-3.0-only', version: '1.0.0',
-- home_url: 'https://openvibe.design/api/v1/tokens/vibe?format=css',
-- manifest: { name: 'Vibe CSS', entry: 'https://openvibe.design/api/v1/tokens/vibe?format=css', runtime: 'css' },
-- passport: { reads: [], writes: [], spends: [], contacts: ['https://openvibe.design'] },
-- },
-- });
-- assert.strictEqual(legacy.ok, true, JSON.stringify(legacy));
-- const out = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fetch });
-- assert.strictEqual(out.sources.design.removed_tokens, 1);
-- assert.strictEqual(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'design.tokens'"), 0);
-- const again = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fetch });
-- assert.strictEqual(again.sources.design.removed_tokens, 0, 'a second run removes nothing');
-- });
--
-- await check('a token endpoint that is down leaves the theme downloads as they were', async () => {
-- const before = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
-- const out = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fakeFetch({ failTargets: true }) });
-- assert.strictEqual(out.sources.design.themes.ok, true);
-- const after = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
-- assert.deepStrictEqual(after.files, before.files);
-- assert.strictEqual(after.revision, before.revision, 'no needless rewrite');
-- });
--
-- await check('a changed theme updates the same listing and bumps its revision', async () => {
-- const themes = JSON.parse(JSON.stringify(THEMES));
-- themes.themes[0].description = 'The default OpenVibe theme, now with a new blurb.';
-- const out = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fakeFetch({ themes }) });
-- assert.strictEqual(out.sources.design.themes.changed, 1);
-- const theme = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
-- assert.strictEqual(theme.summary, 'The default OpenVibe theme, now with a new blurb.');
-- assert.strictEqual(Number(theme.revision), Number(revision) + 1);
-- assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = $1', ['design.theme']), 2);
-- });
--
-- await check('a theme the source no longer lists is retired', async () => {
-- const themes = { themes: [THEMES.themes[1]] }; // Paper only
-- const out = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fakeFetch({ themes }) });
-- assert.strictEqual(out.sources.design.themes.retired, 1);
-- const oldTheme = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
-- assert.strictEqual(oldTheme.status, 'closed');
-- assert.strictEqual(oldTheme.closed_reason, 'withdrawn');
-- const list = await t.get('/api/v1/listings?limit=50');
-- assert.ok(!list.json().listings.some((r) => r.id === themeId));
-- });
--
-- await check('a retired item that comes back is relisted under the same row', async () => {
-- const out = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fakeFetch() });
-- assert.strictEqual(out.sources.design.themes.imported, 2);
-- const back = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
-- assert.strictEqual(back.status, 'listed');
-- assert.strictEqual(back.id, themeId, 'same row, same home, no duplicate');
-- assert.strictEqual(back.closed_at, null);
-- });
--
-- await check('a source that fails retires nothing', async () => {
-- const before = await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'games.world' AND status = 'listed'");
-- const out = await t.ctx.firstParty.runOnce({ sources: ['games'], fetchImpl: fakeFetch({ failGames: true }) });
-- assert.strictEqual(out.sources.games.ok, false);
-- assert.match(out.sources.games.error, /restarting/);
-- assert.strictEqual(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'games.world' AND status = 'listed'"), before);
-- });
--
-- await check('the first page is interleaved by kind, and the theme page shows its downloads', async () => {
-- const page = await t.get('/api/v1/listings?limit=4');
-- assert.strictEqual(page.status, 200, page.text);
-- const kinds = new Set(page.json().listings.map((r) => r.kind));
-- assert.ok(kinds.size >= 2, `one kind must not fill the first page: ${[...kinds].join(', ')}`);
--
-- const html = await t.get('/listings/vibe-theme');
-- assert.strictEqual(html.status, 200, html.text);
-- assert.match(html.text, /Downloads/);
-- assert.match(html.text, /https:\/\/openvibe\.design\/api\/v1\/tokens\/vibe\?format=css/);
-- assert.match(html.text, /https:\/\/openvibe\.design\/api\/v1\/tokens\/vibe\?format=json/);
-- });
--
-- await check('a person\'s listing is never made official, and the official rows have no owner', async () => {
-- const mine = await fixtures.listed(t, alice, { title: 'Alice hand-made theme', kind: 'theme' });
-- await t.ctx.firstParty.runOnce({ sources: ['design', 'obs', 'games'], fetchImpl: fetch });
-- const after = await t.get(`/api/v1/listings/${mine.slug}`, { as: alice });
-- assert.strictEqual(after.status, 200, after.text);
-- assert.strictEqual(after.json().official, false);
-- assert.strictEqual(after.json().owner, alice.subject);
-- assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings WHERE official AND owner IS NOT NULL'), 0);
-- assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings WHERE official AND source IS NULL'), 0);
-- });
--
-- await check('nothing private from a source is stored or served', async () => {
-- const row = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
-- const dump = JSON.stringify(row) + (await t.get(`/api/v1/listings/${row.slug}`)).text;
-- assert.ok(!/designer@example\.test/.test(dump), 'no source email');
-- assert.ok(!/should_never_be_listed/.test(dump), 'no source credential');
-- assert.ok(!/owner_email/.test(dump));
-- });
--
-- await check('only validated hex colours reach the pages and the API', async () => {
-- await t.ctx.s.db.exec('UPDATE exchange_listings SET palette = $1::jsonb WHERE id = $2', [
-- JSON.stringify(['#0a0f1c', '#GGGGGG', 'javascript:alert(1)', '#AABBCC', '#123456', '#654321', '#999999', '#ABCDEF', '#000000']),
-- themeId,
-- ]);
-- const page = await t.get('/listings/vibe-theme');
-- assert.strictEqual(page.status, 200, page.text);
-- assert.match(page.text, /<i style="background:#0a0f1c"><\/i>/);
-- assert.match(page.text, /<i style="background:#aabbcc"><\/i>/);
-- assert.ok(!page.text.includes('#GGGGGG'), 'a non-hex value is dropped');
-- assert.ok(!page.text.includes('javascript:'), 'no style may carry a url or script');
-- assert.ok(!page.text.includes('background:#000000'), 'a seventh colour is dropped');
-- const api = (await t.get('/api/v1/listings/vibe-theme')).json();
-- assert.deepStrictEqual(api.palette, ['#0a0f1c', '#aabbcc', '#123456', '#654321', '#999999', '#abcdef']);
-- const cards = await t.get('/listings?kind=theme');
-- assert.ok(!cards.text.includes('#GGGGGG') && !cards.text.includes('javascript:'), 'the card is safe too');
-- });
--
-- await check('the listing page links to the real home and says who listed it', async () => {
-- const paper = await t.ctx.s.db.maybe("SELECT * FROM exchange_listings WHERE slug = 'paper-theme'");
-- assert.ok(paper, 'Paper is still listed');
-- const page = await t.get(`/listings/${paper.slug}`);
-- assert.strictEqual(page.status, 200);
-- assert.match(page.text, /https:\/\/openvibe\.design\/studio\?theme=paper/);
-- assert.match(page.text, /listed by OpenVibe/);
-- });
--
-- await check('the job refuses to start under drill', async () => {
-- assert.strictEqual(t.ctx.firstParty.start(), false);
-- });
-- } finally {
-- await t.close();
-- }
-- done();
--})();
ddiff --git a/test/helpers/ai.js b/test/helpers/ai.js
nnew file mode 100644
iindex 0000000..7fa81d4
---- /dev/null
++++ b/test/helpers/ai.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * A stand-in OpenVibe.AI client for the page tests: the shape of GET /api/v1/exchange/catalog and /usage, and a
++ * chat that answers in the run's voice. It records every chat call so a test can assert what was sent.
++ */
++function createFakeAi({ chat = null } = {}) {
++ const calls = [];
++ const catalog = {
++ generated_at: '2026-11-06T09:00:00.000Z', window_days: 7, count: 3,
++ families: [{ name: 'DeepSeek', models: 1, offers: 2 }, { name: 'Qwen', models: 1, offers: 1 }, { name: 'Llama', models: 1, offers: 1 }],
++ models: [
++ {
++ slug: 'deepseek/deepseek-chat', canonical: 'deepseek/deepseek-chat', name: 'DeepSeek Chat', family: 'DeepSeek',
++ variant: null, uncensored: false, type: 'chat', context: 131072, max_output: 8192,
++ capabilities: { tools: true, json: true, vision: false, stream: true }, modalities: ['text'], description: null,
++ requests_7d: 120, requests_today: 20, community: false,
++ offers: [
++ {
++ provider: 'deepseek', provider_name: 'DeepSeek', model: 'deepseek-chat', id: 'deepseek/deepseek-chat@v1',
++ trust: 'first-party', access: 'OWN_DEPLOYMENT', free: false, free_tier: true,
++ price: { in: 0.28, out: 0.42, cached: 0.028, per: '1M tokens', currency: 'USD' },
++ speed: { p50_ms: 400, p95_ms: 900 }, uptime: 0.99, requests_7d: 100, requests_today: 15,
++ data_policy: { trains: false, retention_days: 0, region: 'cn' }, updated_at: null,
++ },
++ {
++ provider: 'deepinfra', provider_name: 'DeepInfra', model: 'deepseek-chat', id: 'deepseek/deepseek-chat@v1',
++ trust: 'first-party', access: 'AUTHORIZED_WHOLESALE', free: false,
++ price: { in: 0.2, out: 0.3, cached: 0.02, per: '1M tokens', currency: 'USD' },
++ speed: { p50_ms: 700, p95_ms: 2000 }, uptime: 0.9, requests_7d: 20, requests_today: 5,
++ data_policy: null, updated_at: null,
++ },
++ ],
++ },
++ {
++ slug: 'qwen/qwen3-32b', canonical: 'qwen/qwen3-32b', name: 'Qwen3 32B', family: 'Qwen',
++ variant: null, uncensored: false, type: 'chat', context: 131072, max_output: 8192,
++ capabilities: { tools: true, json: true, vision: false, stream: true }, modalities: ['text'], description: null,
++ requests_7d: 40, requests_today: 3, community: false,
++ offers: [{
++ provider: 'novita', provider_name: 'Novita', model: 'qwen3-32b', id: 'qwen/qwen3-32b@v1',
++ trust: 'first-party', access: 'AUTHORIZED_WHOLESALE', free: false,
++ price: { in: 0.4, out: 0.9, cached: 0.04, per: '1M tokens', currency: 'USD' },
++ speed: { p50_ms: 520, p95_ms: 1200 }, uptime: 0.97, requests_7d: 40, requests_today: 3,
++ data_policy: { trains: false, retention_days: 30, region: 'sg' }, updated_at: '2026-11-06T00:00:00.000Z',
++ }],
++ },
++ {
++ slug: 'meta-llama/llama-3-8b-abliterated', canonical: 'meta-llama/llama-3-8b-abliterated', name: 'Llama 3 8B abliterated',
++ family: 'Llama', variant: 'abliterated', uncensored: true, type: 'chat', context: 8192, max_output: 4096,
++ capabilities: { tools: false, json: false, vision: false, stream: true }, modalities: ['text'], description: null,
++ requests_7d: 12, requests_today: 2, community: true,
++ offers: [{
++ provider: 'owner-box', provider_name: 'Owner’s machine', model: 'llama-3-8b-abliterated', id: null,
++ trust: 'community', access: 'OWN_DEPLOYMENT', free: true, price: null,
++ speed: { p50_ms: 900, p95_ms: 2400 }, uptime: 0.97, requests_7d: 12, requests_today: 2,
++ data_policy: null, updated_at: null,
++ }],
++ },
++ ],
++ };
++ const usage = {
++ generated_at: '2026-11-06T09:00:00.000Z', days: 30, from: '2026-10-07', to: '2026-11-06',
++ today: { requests: 8, tokens_in: 800, tokens_out: 200, cost_usd: 0.0012 },
++ totals: { requests: 40, tokens_in: 4000, tokens_out: 1000, cost_usd: 0.006 },
++ by_day: [{ day: '2026-11-05', requests: 20, tokens_in: 2000, tokens_out: 500, cost_usd: 0.003 }, { day: '2026-11-06', requests: 8, tokens_in: 800, tokens_out: 200, cost_usd: 0.0012 }],
++ by_model: [{ provider: 'deepseek', model: 'deepseek-chat', requests: 40, tokens_in: 4000, tokens_out: 1000, cost_usd: 0.006 }],
++ by_product: [{ product: 'live', requests: 30, tokens_in: 3000, tokens_out: 800, cost_usd: 0.005, models: [{ provider: 'deepseek', model: 'deepseek-chat', requests: 30 }] }],
++ };
++ return {
++ calls,
++ configured: true,
++ async catalog() { return catalog; },
++ async usage() { return usage; },
++ async chat(args) {
++ calls.push(args);
++ if (chat) return chat(args);
++ return { content: `An answer from ${args.model}.`, model: args.model, provider: 'deepseek', run_id: 'run_TEST', cost_usd: 0.00012, usage: { tokens_in: 10, tokens_out: 20 }, tokens_in: 10, tokens_out: 20, latency_ms: 420, why: ['cheapest healthy offer within your ceiling'] };
++ },
++ catalogData: catalog,
++ usageData: usage,
++ };
++}
++
++const downAi = {
++ configured: false,
++ async catalog() { return { generated_at: '2026-11-06T09:00:00.000Z', unavailable: true, count: 0, models: [], families: [], what: 'catalog' }; },
++ async usage() { return { generated_at: '2026-11-06T09:00:00.000Z', unavailable: true, days: 30, by_day: [], by_model: [], by_product: [], today: null, totals: null, what: 'usage' }; },
++ async chat() { const e = new Error('AI is not configured for this service'); e.code = 'ai.not_configured'; e.status = 503; throw e; },
++};
++
++module.exports = { createFakeAi, downAi };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex e335be2..ec5c8f5 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -41,7 +41,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl, openapiFetch: opts.openapiFetch });
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}`;
ddiff --git a/test/helpers/fixtures.js b/test/helpers/fixtures.js
ddeleted file mode 100644
iindex 95b40ec..0000000
---- a/test/helpers/fixtures.js
++++ /dev/null
@@@ -1,51 +0,0 @@
--'use strict';
--/** Shared fixtures: a valid CC0 template and helpers to create / publish it through the public API. */
--
--const BODY = Object.freeze({
-- title: 'Starter site template',
-- kind: 'template',
-- mode: 'free',
-- summary: 'A licensed, static starter site: one page, no tracking.',
-- description: 'Copy it into a Space, change the words, publish.',
-- license: 'CC0-1.0',
-- license_url: 'https://creativecommons.org/publicdomain/zero/1.0/',
-- version: '1.0.0',
-- manifest: { name: 'Starter site', entry: 'templates/starter/index.html', runtime: 'static' },
-- passport: { reads: ['search.read'], writes: ['space.files.write'], spends: [], contacts: ['https://openvibe.space'] },
--});
--
--function body(overrides = {}) {
-- return {
-- ...BODY,
-- ...overrides,
-- manifest: { ...BODY.manifest, ...(overrides.manifest || {}) },
-- passport: { ...BODY.passport, ...(overrides.passport || {}) },
-- };
--}
--
--async function create(t, user, overrides = {}) {
-- const r = await t.get('/api/v1/listings', { as: user, json: body(overrides) });
-- if (r.status !== 201) throw new Error(`create listing ${r.status}: ${r.text}`);
-- return r.json();
--}
--
--async function listed(t, user, overrides = {}) {
-- const l = await create(t, user, overrides);
-- const r = await t.get(`/api/v1/listings/${l.slug}/list`, { as: user, method: 'POST' });
-- if (r.status !== 200) throw new Error(`publish listing ${r.status}: ${r.text}`);
-- return r.json();
--}
--
--async function sandbox(t, user, slug) {
-- const r = await t.get(`/api/v1/listings/${slug}/sandbox`, { as: user, method: 'POST' });
-- if (r.status !== 201) throw new Error(`sandbox ${r.status}: ${r.text}`);
-- return r.json();
--}
--
--async function install(t, user, slug) {
-- const r = await t.get(`/api/v1/listings/${slug}/install`, { as: user, method: 'POST' });
-- if (r.status !== 201 && r.status !== 200) throw new Error(`install ${r.status}: ${r.text}`);
-- return r.json();
--}
--
--module.exports = { BODY, body, create, listed, sandbox, install };
ddiff --git a/test/home-real-first.test.js b/test/home-real-first.test.js
ddeleted file mode 100644
iindex 2923de0..0000000
---- a/test/home-real-first.test.js
++++ /dev/null
@@@ -1,17 +0,0 @@
--'use strict';
--// Exchange's home leads with what is real (the owner, 10-31: empty sections made the rooms look unfinished). With nothing
--// listed there is no empty "Fresh listings" box under the hero: one short "be the first" row follows the scenes.
--const assert = require('assert');
--const fs = require('fs');
--const path = require('path');
--const views = require('../server/views');
--
--const src = fs.readFileSync(path.join(__dirname, '..', 'server/views/index.js'), 'utf8');
--assert.match(src, /room\.slice\(0, at\) \+ live \+ room\.slice\(at, end\) \+ beFirst \+ room\.slice\(end\)/, 'the real above the scenes, the missing after them');
--assert.ok(!src.includes('Nothing is listed yet'), 'no empty box under the hero');
--
--const empty = views.home({ listings: [], counts: {} });
--assert.ok(!empty.includes('id="ex-listings"'), 'no empty Fresh listings');
--assert.ok(empty.indexOf('Be the first') > empty.indexOf('class="sc-room-scenes"'), 'the row follows the scenes');
--assert.ok(empty.includes('href="/submit">List something</a>'), 'it says how the first one starts');
--console.log('exchange home: real first, one "be the first" row while nothing is listed');
ddiff --git a/test/installs.test.js b/test/installs.test.js
ddeleted file mode 100644
iindex af54a91..0000000
---- a/test/installs.test.js
++++ /dev/null
@@@ -1,115 +0,0 @@
--'use strict';
--/**
-- * Sandbox, install and revoke: a public listing can be tried, a passed trial is required for the install, the
-- * install keeps the passport snapshot, a revoke is one tap and idempotent, and installing again needs a fresh
-- * trial. A draft cannot be tried; another person cannot read or revoke your install.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./helpers/boot');
--const fixtures = require('./helpers/fixtures');
--
--(async () => {
-- const t = await boot();
-- const alice = t.network.addUser('alice');
-- const bob = t.network.addUser('bob');
-- const carol = t.network.addUser('carol');
-- try {
-- const draft = await fixtures.create(t, alice);
-- const listing = await fixtures.listed(t, alice, { title: 'A sandboxable template' });
-- let trial = null;
-- let installed = null;
--
-- await check('a draft cannot be tried; a listed one gets a passed manifest dry run', async () => {
-- assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}/sandbox`, { as: bob, method: 'POST' })).status, 404);
-- const out = await fixtures.sandbox(t, bob, listing.slug);
-- trial = out.trial;
-- assert.match(trial.id, /^exs_[0-9A-HJKMNP-TV-Z]{26}$/);
-- assert.strictEqual(trial.status, 'passed');
-- assert.strictEqual(trial.checks.length, 4);
-- assert.ok(trial.checks.some((c) => c.name === 'execution' && /dry run/i.test(c.detail)));
-- assert.strictEqual(trial.passport.spends.length, 0);
-- const page = await t.get(`/sandbox/${trial.id}`, { as: bob });
-- assert.strictEqual(page.status, 200);
-- assert.ok(page.text.includes('id="ex-sandbox-trial"'));
-- assert.ok(page.text.includes('id="ex-passport"'));
-- assert.strictEqual((await t.get(`/sandbox/${trial.id}`, { as: carol })).status, 404);
-- });
--
-- await check('an install is refused until a trial passed, then keeps the passport snapshot', async () => {
-- const noTrial = await fixtures.listed(t, alice, { title: 'A no-trial template' });
-- const r = await t.get(`/api/v1/listings/${noTrial.slug}/install`, { as: carol, method: 'POST' });
-- assert.strictEqual(r.status, 409);
-- assert.strictEqual(r.json().code, 'exchange.sandbox_required');
-- const out = await fixtures.install(t, bob, listing.slug);
-- installed = out;
-- assert.match(installed.id, /^exi_[0-9A-HJKMNP-TV-Z]{26}$/);
-- assert.strictEqual(installed.status, 'active');
-- assert.deepStrictEqual(installed.passport.reads, ['search.read']);
-- assert.strictEqual(installed.listing.slug, listing.slug);
-- const list = await t.get('/api/v1/installs', { as: bob });
-- assert.strictEqual(list.json().count, 1);
-- assert.strictEqual(list.json().installs[0].id, installed.id);
-- const again = await t.get(`/api/v1/listings/${listing.slug}/install`, { as: bob, method: 'POST' });
-- assert.strictEqual(again.status, 200);
-- assert.strictEqual(again.json().id, installed.id);
-- assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_installs')), 1);
-- });
--
-- await check('another person cannot revoke your install; a revoke is one tap and idempotent', async () => {
-- assert.strictEqual((await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: carol, method: 'POST' })).status, 404);
-- const r = await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: bob, method: 'POST' });
-- assert.strictEqual(r.status, 200);
-- assert.strictEqual(r.json().status, 'revoked');
-- assert.ok(r.json().revoked_at);
-- const again = await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: bob, method: 'POST' });
-- assert.strictEqual(again.status, 200);
-- assert.strictEqual(again.json().status, 'revoked');
-- const list = await t.get('/api/v1/installs', { as: bob });
-- assert.strictEqual(list.json().installs[0].status, 'revoked');
-- assert.strictEqual((await t.get('/api/v1/installs')).status, 401);
-- });
--
-- await check('installing again needs a fresh trial; the first one was consumed', async () => {
-- const r = await t.get(`/api/v1/listings/${listing.slug}/install`, { as: bob, method: 'POST' });
-- assert.strictEqual(r.status, 409);
-- assert.strictEqual(r.json().code, 'exchange.sandbox_required');
-- const fresh = await fixtures.sandbox(t, bob, listing.slug);
-- assert.notStrictEqual(fresh.trial.id, trial.id);
-- const reinstalled = await fixtures.install(t, bob, listing.slug);
-- assert.strictEqual(reinstalled.status, 'active');
-- assert.notStrictEqual(reinstalled.id, installed.id);
-- const consumed = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_sandbox_trials WHERE consumed_at IS NOT NULL');
-- assert.strictEqual(Number(consumed), 2);
-- });
--
-- await check('an expired trial cannot install', async () => {
-- const l = await fixtures.listed(t, alice, { title: 'An expiring template' });
-- const out = await fixtures.sandbox(t, carol, l.slug);
-- await t.ctx.s.db.exec("UPDATE exchange_sandbox_trials SET created_at = now() - interval '2 hours', expires_at = now() - interval '1 minute' WHERE id = $1", [out.trial.id]);
-- const r = await t.get(`/api/v1/listings/${l.slug}/install`, { as: carol, method: 'POST' });
-- assert.strictEqual(r.status, 409);
-- });
--
-- await check('a session write from another site is refused', async () => {
-- const l = await fixtures.listed(t, alice, { title: 'A cross-site template' });
-- const r = await t.get(`/api/v1/listings/${l.slug}/sandbox`, { as: bob, method: 'POST', origin: 'https://evil.example' });
-- assert.strictEqual(r.status, 403);
-- assert.strictEqual(r.json().code, 'exchange.cross_site');
-- });
--
-- await check('the installs page is complete without JavaScript and lists active and revoked', async () => {
-- const page = await t.get('/installs', { as: bob });
-- assert.strictEqual(page.status, 200);
-- assert.ok(page.text.includes('id="ex-installs"'));
-- assert.ok(page.text.includes('id="ex-installs-active"'));
-- assert.ok(page.text.includes('id="ex-installs-revoked"'));
-- assert.ok(page.text.includes('ex-passport'));
-- assert.ok(page.text.includes('action="/installs/'));
-- assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
-- assert.strictEqual((await t.get('/installs')).status, 401);
-- });
-- } finally {
-- await t.close();
-- }
-- done();
--})();
ddiff --git a/test/migration.test.js b/test/migration.test.js
iindex 487bfe1..5c95f40 100644
---- a/test/migration.test.js
++++ b/test/migration.test.js
@@@ -13,7 +13,7 @@ const { boot, check, done } = require('./helpers/boot');
try {
await check('0001_initial.sql applies and creates the Exchange tables', async () => {
const names = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);
-- for (const table of ['exchange_listings', 'exchange_listing_tests', 'exchange_listing_events', 'exchange_sandbox_trials', 'exchange_installs', 'exchange_offers', 'exchange_outbox', 'account_data_events', 'telemetry_samples']) {
++ for (const table of ['exchange_listings', 'exchange_listing_tests', 'exchange_listing_events', 'exchange_sandbox_trials', 'exchange_installs', 'exchange_offers', 'exchange_outbox', 'exchange_chats', 'exchange_chat_messages', 'exchange_adult_confirmations', 'account_data_events', 'telemetry_samples']) {
assert.ok(names.includes(table), `missing ${table}`);
}
});
ddiff --git a/test/no-looks-guard.test.js b/test/no-looks-guard.test.js
ddeleted file mode 100644
iindex 5587d93..0000000
---- a/test/no-looks-guard.test.js
++++ /dev/null
@@@ -1,18 +0,0 @@
--'use strict';
--// Owner, 2026-10-31: AI may describe people; only sex acts and porn stay gated. No server file may call the old
--// looks filter again.
--const assert = require('assert');
--const fs = require('fs');
--const path = require('path');
--
--const root = path.join(__dirname, '..');
--const offenders = [];
--(function walk(dir) {
-- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
-- const p = path.join(dir, e.name);
-- if (e.isDirectory()) walk(p);
-- else if (e.name.endsWith('.js') && /\b(noLooks|noLooksTags|hasLooks)\s*\(|openvibe-sdk\/no-looks/.test(fs.readFileSync(p, 'utf8'))) offenders.push(path.relative(root, p));
-- }
--})(path.join(root, 'server'));
--assert.deepStrictEqual(offenders, [], 'no looks filter in server/');
--console.log('No looks filter: AI text describes people as written.');
ddiff --git a/test/offers.test.js b/test/offers.test.js
ddeleted file mode 100644
iindex f25382c..0000000
---- a/test/offers.test.js
++++ /dev/null
@@@ -1,114 +0,0 @@
--'use strict';
--/**
-- * Free and swap: a free listing takes a claim, a swap listing takes an offer with what is given in return; only the
-- * owner accepts or declines, only the sender withdraws; accepting closes the listing as given or swapped. No money
-- * in an offer, and a prohibited item is refused.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./helpers/boot');
--const fixtures = require('./helpers/fixtures');
--
--(async () => {
-- const t = await boot();
-- const alice = t.network.addUser('alice');
-- const bob = t.network.addUser('bob');
-- const carol = t.network.addUser('carol');
-- try {
-- const free = await fixtures.listed(t, alice, { title: 'A free starter theme', kind: 'theme' });
-- const swap = await fixtures.listed(t, alice, { title: 'A surf map to swap', kind: 'game_asset', mode: 'swap', wants: 'A type kit' });
-- let claim = null;
--
-- await check('a free listing takes a claim; the owner can read it and a duplicate is refused', async () => {
-- const r = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: bob, json: { body: 'I would use this for my first site.' } });
-- assert.strictEqual(r.status, 201, r.text);
-- claim = r.json();
-- assert.match(claim.id, /^exo_[0-9A-HJKMNP-TV-Z]{26}$/);
-- assert.strictEqual(claim.kind, 'claim');
-- assert.strictEqual(claim.status, 'open');
-- const dup = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: bob, json: { body: 'Again' } });
-- assert.strictEqual(dup.status, 409);
-- assert.strictEqual(dup.json().code, 'exchange.offer_exists');
-- const own = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: alice, json: { body: 'Mine' } });
-- assert.strictEqual(own.status, 400);
-- assert.strictEqual(own.json().code, 'exchange.own_listing');
-- const ownerRead = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: alice });
-- assert.strictEqual(ownerRead.status, 200);
-- assert.strictEqual(ownerRead.json().count, 1);
-- assert.strictEqual((await t.get(`/api/v1/listings/${free.slug}/offers`, { as: carol })).status, 403);
-- const mine = await t.get('/api/v1/offers', { as: bob });
-- assert.strictEqual(mine.json().sent.length, 1);
-- const owner = await t.get('/api/v1/offers', { as: alice });
-- assert.strictEqual(owner.json().received.length, 1);
-- });
--
-- await check('only the owner accepts; accepting closes the free listing as given and tombstones it', async () => {
-- const notOwner = await t.get(`/api/v1/offers/${claim.id}/accept`, { as: bob, method: 'POST' });
-- assert.strictEqual(notOwner.status, 403);
-- const accepted = await t.get(`/api/v1/offers/${claim.id}/accept`, { as: alice, method: 'POST' });
-- assert.strictEqual(accepted.status, 200, accepted.text);
-- assert.strictEqual(accepted.json().offer.status, 'accepted');
-- assert.strictEqual(accepted.json().listing.status, 'closed');
-- assert.strictEqual(accepted.json().listing.closed_reason, 'given');
-- assert.strictEqual((await t.get(`/api/v1/listings/${free.slug}`)).status, 404);
-- const topics = (await t.ctx.s.db.many('SELECT topic FROM exchange_outbox ORDER BY created_at')).map((x) => x.topic);
-- assert.ok(topics.includes('exchange.index_document.deleted'));
-- });
--
-- await check('a swap offer must say what is given; accepting closes it as swapped', async () => {
-- const noGive = await t.get(`/api/v1/listings/${swap.slug}/offers`, { as: carol, json: { body: 'I want this' } });
-- assert.strictEqual(noGive.status, 400);
-- const r = await t.get(`/api/v1/listings/${swap.slug}/offers`, { as: carol, json: { body: 'I have just the kit', give: 'A 12-column grid kit and an icon set' } });
-- assert.strictEqual(r.status, 201, r.text);
-- const offer = r.json();
-- assert.strictEqual(offer.kind, 'swap');
-- const accepted = await t.get(`/api/v1/offers/${offer.id}/accept`, { as: alice, method: 'POST' });
-- assert.strictEqual(accepted.status, 200);
-- assert.strictEqual(accepted.json().listing.closed_reason, 'swapped');
-- });
--
-- await check('withdraw and decline work for the right person; a decided offer is unchanged', async () => {
-- const l = await fixtures.listed(t, alice, { title: 'A withdrawable theme' });
-- const r = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: bob, json: { body: 'I would like it' } });
-- const offer = r.json();
-- assert.strictEqual((await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: carol, method: 'POST' })).status, 403);
-- const w = await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: bob, method: 'POST' });
-- assert.strictEqual(w.status, 200);
-- assert.strictEqual(w.json().offer.status, 'withdrawn');
-- const again = await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: bob, method: 'POST' });
-- assert.strictEqual(again.status, 200);
-- assert.strictEqual(again.json().offer.status, 'withdrawn');
--
-- const r2 = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'Me instead' } });
-- const o2 = r2.json();
-- assert.strictEqual((await t.get(`/api/v1/offers/${o2.id}/decline`, { as: bob, method: 'POST' })).status, 403);
-- const d = await t.get(`/api/v1/offers/${o2.id}/decline`, { as: alice, method: 'POST' });
-- assert.strictEqual(d.status, 200);
-- assert.strictEqual(d.json().offer.status, 'declined');
-- });
--
-- await check('an offer carrying money or a prohibited item is refused', async () => {
-- const l = await fixtures.listed(t, alice, { title: 'A refusal theme' });
-- const money = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: bob, json: { body: 'I will pay you $30 by paypal.' } });
-- assert.strictEqual(money.status, 400);
-- assert.strictEqual(money.json().code, 'exchange.prohibited');
-- const weapon = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'I can swap a firearm.' } });
-- assert.strictEqual(weapon.status, 400);
-- assert.strictEqual(weapon.json().code, 'exchange.prohibited');
-- const shortGive = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'A claim with a tiny note', give: 'ok' } });
-- assert.strictEqual(shortGive.status, 400);
-- assert.strictEqual(shortGive.json().code, 'exchange.invalid');
-- });
--
-- await check('the offers page is server-rendered and lists both directions', async () => {
-- const page = await t.get('/offers', { as: alice });
-- assert.strictEqual(page.status, 200);
-- assert.ok(page.text.includes('id="ex-offers"'));
-- assert.ok(page.text.includes('id="ex-offers-received"'));
-- assert.ok(page.text.includes('id="ex-offers-sent"'));
-- assert.strictEqual((await t.get('/offers')).status, 401);
-- });
-- } finally {
-- await t.close();
-- }
-- done();
--})();
ddiff --git a/test/openapi-root.test.js b/test/openapi-root.test.js
ddeleted file mode 100644
iindex d80e562..0000000
---- a/test/openapi-root.test.js
++++ /dev/null
@@@ -1,47 +0,0 @@
--'use strict';
--// SEO-3: /openapi.json answered 404 here (11-03). It is this service's API as OpenAPI 3.1 (routes: 'serve' is the
--// document /api/v1/openapi.json already served; 'routes' is read from the routes the app mounts).
--const assert = require('assert');
--const { boot } = require('./helpers/boot');
--
--(async () => {
-- const t = await boot();
-- let failed = 0;
-- try {
-- const r = await t.get('/openapi.json');
-- assert.strictEqual(r.status, 200, r.text.slice(0, 200));
-- assert.match(String(r.headers.get('content-type')), /^application\/json/);
-- assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'any agent may read it');
-- const doc = JSON.parse(r.text);
-- assert.match(doc.openapi, /^3\.1/);
-- assert.ok(doc.info && doc.info.title && doc.info.version, 'a title and a version');
-- const paths = Object.keys(doc.paths || {});
-- assert.ok(paths.length >= 2, `paths: ${paths.join(' ')}`);
-- for (const p of paths) {
-- for (const [m, op] of Object.entries(doc.paths[p])) {
-- if (!['get', 'put', 'post', 'delete', 'patch'].includes(m)) continue;
-- assert.ok(op.responses && Object.keys(op.responses).length, `${m} ${p} has responses`);
-- for (const name of [...p.matchAll(/\{([^}]+)\}/g)].map((x) => x[1])) {
-- assert.ok((op.parameters || []).some((q) => q.name === name && q.in === 'path') || (doc.paths[p].parameters || []).some((q) => q.name === name), `${m} ${p} declares {${name}}`);
-- }
-- }
-- }
-- if ('routes' === 'routes') {
-- assert.ok(paths.every((p) => p.startsWith('/api/')), 'only the API');
-- assert.ok(!paths.some((p) => /\/(?:health|ready)$|openapi\.json$/.test(p)), 'no probes, not itself');
-- assert.ok(Object.values(doc.paths).some((item) => Object.values(item).some((op) => op.summary)), 'the header comments give summaries');
-- const again = JSON.parse((await t.get('/openapi.json')).text);
-- assert.deepStrictEqual(Object.keys(again.paths), paths, 'the same document each time');
-- } else {
-- const api = await t.get('/api/v1/openapi.json');
-- assert.deepStrictEqual(JSON.parse(api.text), doc, 'the same document as /api/v1/openapi.json');
-- }
-- console.log(` ✓ /openapi.json: ${paths.length} paths`);
-- } catch (e) {
-- failed++;
-- console.log(` ✗ /openapi.json\n ${String(e.stack || e).split('\n').slice(0, 6).join('\n ')}`);
-- } finally {
-- await t.close();
-- }
-- process.exit(failed ? 1 : 0);
--})();
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 21257d3..5a19eca 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -1,175 +1,211 @@
'use strict';
/**
-- * Server-rendered pages: the home (hero, three scenes, fresh listings), browse and filters, one listing with its
-- * passport and tests, the submit form, the sandbox trial, installations, offers, safety, developers, 404, cache
-- * headers, content-hashed one-comment assets, and the discovery artifacts.
++ * The new Exchange's public pages: the home with live numbers, the model list, one model with every offer, the
++ * API page, selling compute, the uncensored gate and AI in OpenVibe. Every number comes from the injected AI
++ * client; nothing from Exchange v1 appears. A page with AI unreachable still renders and says so.
*/
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
--const fixtures = require('./helpers/fixtures');
--const { assetVersion } = require('../server/render/layout');
--
--function jsonLd(html) {
-- return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
--}
++const { createFakeAi, downAi } = require('./helpers/ai');
(async () => {
-- const t = await boot();
-- const alice = t.network.addUser('alice');
-- const bob = t.network.addUser('bob');
-- const listing = await fixtures.listed(t, alice, {
-- title: 'A page-tested template',
-- summary: 'A template that the pages can show.',
-- description: 'Line one.\nLine two.',
-- });
++ const ai = createFakeAi();
++ const t = await boot({ ai });
try {
-- await check('home: the hero, the three scenes and a fresh listing, public with Vary: Cookie', async () => {
++ await check('the home is the new Exchange, with live numbers', async () => {
const r = await t.get('/');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.headers.get('cache-control'), /^public, max-age=60/);
++ assert.ok(r.text.includes('Every AI model, one account.'), 'the new promise');
++ assert.ok(!r.text.includes('Trade what people make'), 'v1’s promise is gone');
++ assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Llama 3 8B abliterated'));
++ assert.ok(r.text.includes('Popular now') && r.text.includes('Uncensored (18+)') && r.text.includes('Run on people’s machines'));
++ assert.ok(r.text.includes('models') && r.text.includes('providers') && r.text.includes('cheapest in, per million'), 'the live numbers');
++ assert.ok(r.text.includes('messages free every day'), 'the free allowance');
++ assert.ok(!r.text.includes('requests today'), 'fewer than 1,000 requests today is not shown');
++ assert.ok(r.text.includes('A side-by-side answer') && r.text.includes('example words · live prices'), 'the preview is marked an example');
++ assert.ok(r.text.includes('api.openvibe.exchange/v1/chat/completions'), 'the endpoint');
++ assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
++ assert.ok(r.text.includes('<main id="main"') && r.text.includes('<footer id="ov-footer"'));
++ });
++
++ await check('models lists every model with its cheapest offer', async () => {
++ const r = await t.get('/models');
assert.strictEqual(r.status, 200);
-- assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
-- assert.strictEqual(r.headers.get('vary'), 'Cookie');
-- for (const marker of ['id="ex-home"', 'id="ex-listings"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', 'Trade what people make', 'A page-tested template', '/submit', '/safety']) {
-- assert.ok(r.text.includes(marker), marker);
-- }
-- const types = jsonLd(r.text).map((x) => x['@type']);
-- assert.ok(types.includes('WebSite'), types.join(','));
-- assert.ok(types.includes('WebApplication'), types.join(','));
-- assert.strictEqual((await t.get('/', { as: alice })).headers.get('cache-control'), 'private, no-store');
++ assert.ok(r.text.includes('DeepSeek Chat'));
++ assert.ok(r.text.includes('$0.20 in'), 'the cheapest offer, not the dearest');
++ const filtered = await t.get('/models?q=llama');
++ assert.ok(filtered.text.includes('Llama 3 8B abliterated'));
++ assert.ok(!filtered.text.includes('DeepSeek Chat'), 'the search filters');
++ const family = await t.get('/models?family=DeepSeek');
++ assert.ok(family.text.includes('DeepSeek Chat'));
++ assert.ok(!family.text.includes('Llama 3 8B'));
});
-- await check('/listings filters and lists; a public listing page carries the passport and tests', async () => {
-- const browse = await t.get('/listings?mode=free&kind=template');
-- assert.strictEqual(browse.status, 200);
-- assert.strictEqual(browse.headers.get('cache-control'), 'public, max-age=60');
-- assert.ok(browse.text.includes('id="ex-listings-page"'));
-- assert.ok(browse.text.includes('id="ex-filters"'));
-- assert.ok(browse.text.includes('A page-tested template'));
-- const detail = await t.get(`/listings/${listing.slug}`);
-- assert.strictEqual(detail.status, 200);
-- assert.strictEqual(detail.headers.get('cache-control'), 'public, max-age=60');
-- for (const marker of ['id="ex-listing"', 'id="ex-about"', 'id="ex-passport"', 'id="ex-tests"', 'id="ex-sandbox"', 'id="ex-install"', 'id="ex-manifest"', 'ex-passport-reads', 'CC0-1.0', 'Line one.']) {
-- assert.ok(detail.text.includes(marker), marker);
-- }
-- assert.ok(!detail.text.includes('id="ex-offer-form"'), 'a guest gets a sign-in prompt, not the form');
-- const signed = await t.get(`/listings/${listing.slug}`, { as: bob });
-- assert.strictEqual(signed.status, 200);
-- assert.ok(signed.text.includes('id="ex-offer-form"'));
-- assert.ok(signed.text.includes('Run the sandbox'));
-- assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');
++ await check('one model: every offer, its price, speed, uptime, data policy, Try it, curl and SDK', async () => {
++ const r = await t.get('/m/deepseek/deepseek-chat');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('DeepSeek') && r.text.includes('DeepInfra'), 'both offers');
++ assert.ok(r.text.includes('no training') && r.text.includes('no retention') && r.text.includes('cn'), 'the data policy');
++ assert.ok(r.text.includes('99.0%'), 'uptime');
++ assert.ok(r.text.includes('400 ms'), 'p50');
++ assert.ok(r.text.includes('Sign in to try it'), 'a signed-out try asks for sign-in');
++ assert.ok(r.text.includes('Private'), 'the privacy label on an offer');
++ assert.ok(r.text.includes('Standard') || r.text.includes('No training'));
++ assert.ok(r.text.includes('Presets') && r.text.includes('openvibe/economy'), 'the presets box');
++ assert.ok(r.text.includes('deepseek/deepseek-chat@v1'), 'the exact id in the snippet');
++ assert.ok(r.text.includes('import OpenAI from'), 'the SDK line');
++ const miss = await t.get('/m/nope/nope');
++ assert.strictEqual(miss.status, 404);
});
-- await check('the submit form is plain HTML; a form POST saves a draft and redirects to it', async () => {
-- const form = await t.get('/submit');
-- assert.strictEqual(form.status, 200);
-- for (const marker of ['id="ex-submit"', 'id="ex-listing-form"', 'name="passport_reads"', 'name="manifest_entry"', 'name="mode"', 'nothing', 'no money']) assert.ok(form.text.toLowerCase().includes(marker.toLowerCase()), marker);
-- const r = await t.get('/submit', {
-- as: alice,
-- form: {
-- title: 'A form-made theme', kind: 'theme', mode: 'free', summary: 'A theme made through the form.',
-- description: 'It has a description.', license: 'MIT', version: '1.0.0',
-- manifest_name: 'Form theme', manifest_entry: 'theme.json', manifest_runtime: 'css',
-- passport_reads: 'search.read\nspace.files.read', passport_writes: 'space.files.write', passport_contacts: 'https://openvibe.space',
-- },
-- });
-- assert.strictEqual(r.status, 303, r.text);
-- assert.match(r.headers.get('location'), /^\/listings\/a-form-made-theme\?submitted=1$/);
-- const page = await t.get(r.headers.get('location'), { as: alice });
-- assert.strictEqual(page.status, 200);
-- assert.ok(page.text.includes('id="ex-owner"'));
-- assert.ok(page.text.includes('Draft'));
-- assert.ok(page.text.includes('id="ex-passport"'));
-- assert.strictEqual((await t.get('/submit', { as: alice, form: { title: 'x' } })).status, 400);
++ await check('the API page: the endpoint, keys, model ids and usage', async () => {
++ const r = await t.get('/api');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('https://api.openvibe.exchange/v1/chat/completions'));
++ assert.ok(r.text.includes('https://api.openvibe.exchange/v1/messages'));
++ assert.ok(r.text.includes('openvibe/auto'));
++ assert.ok(r.text.includes('openvibe.network/developers'), 'keys are made on Network');
++ assert.ok(r.text.includes('2026-11-05') && r.text.includes('Requests'), 'usage by day');
});
-- await check('a form POST from another site is refused', async () => {
-- const r = await t.get('/submit', {
-- as: alice, origin: 'https://evil.example',
-- form: { title: 'A cross-site theme', kind: 'theme', mode: 'free', summary: 'A theme.', license: 'MIT', version: '1.0.0', manifest_name: 'X', manifest_entry: 'x' },
-- });
-- assert.strictEqual(r.status, 403);
-- assert.ok(r.text.includes('id="ex-not-found"') || r.text.includes('That came from somewhere else'));
++ await check('sell: the offer flow, and what is not sold, said plainly', async () => {
++ const r = await t.get('/sell');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Offer to the network'));
++ assert.ok(r.text.includes('Personal subscription plans'));
++ assert.ok(r.text.includes('Personal API keys'));
++ assert.ok(r.text.includes('What is sold'));
++ assert.ok(r.text.includes('Owner’s machine') || r.text.includes('No community capacity yet'));
++ assert.ok(r.text.includes('Idle-only') && r.text.includes('Kudos') && r.text.includes('sees the prompt'), 'idle-only, kudos and the privacy sentence');
++ assert.ok(r.text.includes('@name’s'), 'the seller card');
++ });
++
++ await check('uncensored: sign-in first, then the 18+ gate and the floor', async () => {
++ const r = await t.get('/uncensored');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Sign in with OpenVibe'), 'signed out: sign in first');
++ assert.ok(r.text.includes('No sexual content involving minors'));
++ assert.ok(r.text.includes('No non-consensual sexual content of real people'));
++ assert.ok(r.text.includes('No help with weapons of mass harm'));
++ });
++
++ await check('AI in OpenVibe: which product runs which model, and the daily cost', async () => {
++ const r = await t.get('/ai');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('OpenVibe.Live'), 'the product’s full name, not “live”');
++ assert.ok(r.text.includes('deepseek-chat'));
++ assert.ok(r.text.includes('$0.0002'), 'the cost per day (0.006 over 30 days)');
});
-- await check('the sandbox form redirects to a stable trial page; install and revoke work by form', async () => {
-- const sb = await t.get(`/listings/${listing.slug}/sandbox`, { as: bob, method: 'POST' });
-- assert.strictEqual(sb.status, 303);
-- assert.match(sb.headers.get('location'), /^\/sandbox\/exs_/);
-- const page = await t.get(sb.headers.get('location'), { as: bob });
-- assert.strictEqual(page.status, 200);
-- assert.ok(page.text.includes('id="ex-sandbox-trial"'));
-- assert.ok(page.text.includes('The dry run passed'));
-- const ins = await t.get(`/listings/${listing.slug}/install`, { as: bob, method: 'POST' });
-- assert.strictEqual(ins.status, 303);
-- assert.strictEqual(ins.headers.get('location'), '/installs?installed=1');
-- const installs = await t.get('/installs', { as: bob });
-- assert.ok(installs.text.includes('id="ex-installs"'));
-- assert.ok(installs.text.includes('Revoke'));
-- const id = await t.ctx.s.db.value("SELECT id FROM exchange_installs WHERE status = 'active' LIMIT 1");
-- const rev = await t.get(`/installs/${id}/revoke`, { as: bob, method: 'POST' });
-- assert.strictEqual(rev.status, 303);
-- assert.strictEqual(rev.headers.get('location'), '/installs?revoked=1');
-- assert.strictEqual((await t.ctx.s.db.value('SELECT status FROM exchange_installs WHERE id = $1', [id])), 'revoked');
++ await check('Free to try is shown only where an offer says free_tier', async () => {
++ const u = t.network.addUser('free');
++ for (const p of ['/', '/models', '/m/deepseek/deepseek-chat']) {
++ const r = await t.get(p);
++ assert.ok(r.text.includes('Free to try'), `${p} shows the badge`);
++ }
++ const paid = await t.get('/m/qwen/qwen3-32b');
++ assert.ok(!paid.text.includes('Free to try'), 'no badge when the field is absent');
++ const chat = await t.get('/chat/new', { as: u });
++ assert.ok(chat.text.includes('Free to try'), 'the picker marks the free-tier models');
++ assert.ok(chat.text.includes('need your own balance or key'), 'and says what the others need');
});
-- await check('the claim form creates a free claim; the owner sees it on the listing page', async () => {
-- const claim = await t.get(`/listings/${listing.slug}/offers`, { as: bob, form: { body: 'I would like to use this.' } });
-- assert.strictEqual(claim.status, 303);
-- assert.strictEqual(claim.headers.get('location'), `/listings/${listing.slug}?offered=1`);
-- const owner = await t.get(`/listings/${listing.slug}`, { as: alice });
-- assert.ok(owner.text.includes('id="ex-offers"'));
-- assert.ok(owner.text.includes('I would like to use this.'));
-- assert.ok(owner.text.includes('Accept the claim'));
++ await check('status: uptime per offer, the changelog and incidents', async () => {
++ const r = await t.get('/status');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Uptime per offer') && r.text.includes('Model changelog') && r.text.includes('Incidents'));
++ assert.ok(r.text.includes('99.0%') && r.text.includes('no traffic'));
});
-- await check('safety, developers, 404 and the submit sign-in prompt', async () => {
-- const safety = await t.get('/safety');
-- assert.strictEqual(safety.status, 200);
-- assert.ok(safety.text.includes('id="ex-safety"'));
-- assert.ok(safety.text.includes('No money, anywhere'));
-- const dev = await t.get('/developers');
-- assert.strictEqual(dev.status, 200);
-- assert.ok(dev.text.includes('id="ex-developers"'));
-- assert.ok(dev.text.includes('/api/v1/listings'));
-- assert.ok(dev.text.includes('network.grants.write'));
-- const nf = await t.get('/no/such/page');
-- assert.strictEqual(nf.status, 404);
-- assert.match(nf.headers.get('content-type'), /^text\/html/);
-- assert.ok(nf.text.includes('id="ex-not-found"'));
-- const prompt = await t.get('/submit', { as: null });
-- assert.ok(prompt.text.includes('id="ex-listing-form"'), 'a guest can read the form');
-- const install = await t.get('/installs');
-- assert.strictEqual(install.status, 401);
-- assert.ok(install.text.includes('Sign in with OpenVibe'));
++ await check('an unknown page is 404, and a personal page is never cached', async () => {
++ const r = await t.get('/nope');
++ assert.strictEqual(r.status, 404);
++ assert.ok(r.text.includes('Not found'));
});
-- await check('assets are content-hashed and served; robots and sitemap list public listings', async () => {
-- const v = assetVersion('css/exchange.css');
-- const css = await t.get(`/css/exchange.css?v=${v}`);
-- assert.strictEqual(css.status, 200);
-- assert.strictEqual(css.headers.get('cache-control'), 'public, max-age=31536000, immutable');
-- assert.match(css.text, /OpenVibe\.Exchange: the room's own pieces/, 'the designed stylesheet');
-- const wrong = await t.get('/css/exchange.css?v=deadbeefdeadbeef');
-- assert.strictEqual(wrong.status, 200);
-- assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
-- const js = await t.get('/js/exchange.js');
-- assert.strictEqual(js.status, 200);
-- assert.match(js.headers.get('content-type'), /javascript/);
-- assert.strictEqual(js.text.trim().split('\n').length, 1);
-- const robots = await t.get('/robots.txt');
-- assert.match(robots.text, /Disallow: \/api\//);
-- const map = await t.get('/sitemap.xml');
-- assert.strictEqual(map.status, 200);
-- assert.ok(map.text.includes(`https://openvibe.exchange/listings/${listing.slug}`));
-- const llms = await t.get('/llms.txt');
-- assert.strictEqual(llms.status, 200);
-- assert.ok(llms.text.includes('OpenVibe.Exchange'));
-- assert.ok(llms.text.includes('\n## Optional\n'));
-- assert.ok(llms.text.includes('(https://openvibe.exchange/index.md)'));
-- const full = await t.get('/llms-full.txt');
-- assert.ok(full.text.includes(listing.slug));
++ await check('the agent layer serves the new pages as Markdown twins', async () => {
++ const r = await t.get('/index.md');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /text\/markdown/);
++ assert.ok(r.text.includes('Every AI model, one account.'));
++ });
++ } finally { await t.close(); }
++
++ await (async () => {
++ let calls = 0;
++ const openapiFetch = async () => {
++ calls += 1;
++ return {
++ ok: true,
++ json: async () => ({ openapi: '3.1.0', info: { title: 'OpenVibe.AI API' }, servers: [{ url: 'https://somewhere.else' }], paths: { '/v1/chat/completions': { post: {} } } }),
++ };
++ };
++ const o = await boot({ ai: createFakeAi(), openapiFetch });
++ try {
++ await check('/openapi.json is the API host’s document, on the API host, cached ten minutes', async () => {
++ const r = await o.get('/openapi.json');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /application\/json/);
++ assert.match(r.headers.get('cache-control'), /max-age=600/);
++ const doc = JSON.parse(r.text);
++ assert.strictEqual(doc.servers[0].url, 'https://api.openvibe.exchange');
++ assert.ok(doc.paths['/v1/chat/completions'], 'the API host’s paths, not Exchange v1’s');
++ await o.get('/openapi.json');
++ assert.strictEqual(calls, 1, 'read once, then cached');
++ });
++ } finally { await o.close(); }
++
++ const broken = await boot({ ai: createFakeAi(), openapiFetch: async () => { throw new Error('down'); } });
++ try {
++ await check('/openapi.json still answers when the API host is down', async () => {
++ const r = await broken.get('/openapi.json');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('cache-control'), /max-age=60/);
++ const doc = JSON.parse(r.text);
++ assert.strictEqual(doc.servers[0].url, 'https://api.openvibe.exchange');
++ });
++ } finally { await broken.close(); }
++ })();
++
++ await (async () => {
++ const catalog = createFakeAi().catalog();
++ const internal = {
++ slug: 'local/bge-small-en-v1.5', canonical: 'local/bge-small-en-v1.5', name: 'bge-small-en-v1.5',
++ family: 'Local model (OpenAI-compatible server on this host)', type: 'embedding', kind: 'embedding',
++ capabilities: {}, offers: [{ provider: 'local', provider_name: 'Local model (OpenAI-compatible server on this host)', model: 'bge', trust: 'first-party', free: true, price: null, speed: {}, uptime: null, requests_7d: 0, data_policy: null }], requests_7d: 0,
++ };
++ const chatKind = {
++ slug: 'deepseek/deepseek-chat', canonical: 'deepseek/deepseek-chat', name: 'DeepSeek Chat', family: 'DeepSeek', kind: 'chat',
++ capabilities: { tools: true }, offers: [{ provider: 'shared', provider_name: "Shared provider (Live's shared key)", model: 'deepseek-chat', id: 'deepseek/deepseek-chat@v1', trust: 'first-party', free: false, price: { in: 0.2, out: 0.3 }, speed: { p50_ms: 400, p95_ms: 800 }, uptime: 0.99, requests_7d: 5, data_policy: { trains: false, retention_days: 0 } }], requests_7d: 5,
++ };
++ const c = await boot({ ai: { ...createFakeAi(), catalog: async () => ({ generated_at: '', models: [internal, chatKind], families: [], count: 2 }) } });
++ try {
++ await check('only chat models are listed, and a provider’s internal label is never printed', async () => {
++ for (const p of ['/', '/models', '/api', '/status']) {
++ const r = await c.get(p);
++ assert.strictEqual(r.status, 200, p);
++ assert.ok(!/Local model \(OpenAI-compatible|Shared provider \(Live/.test(r.text), `${p} prints no internal label`);
++ assert.ok(!r.text.includes('bge-small'), `${p} sells no embedding`);
++ }
++ const m = await c.get('/m/deepseek/deepseek-chat');
++ assert.ok(m.text.includes('OpenVibe'), 'the shared capacity is said as OpenVibe');
++ });
++ } finally { await c.close(); }
++ })();
++
++ const down = await boot({ ai: downAi });
++ try {
++ await check('AI unreachable: every page still renders and says the numbers are not live', async () => {
++ for (const p of ['/', '/models', '/api', '/sell', '/ai', '/status']) {
++ const r = await down.get(p);
++ assert.strictEqual(r.status, 200, p);
++ assert.ok(!/NaN|undefined/.test(r.text.replace(/undefined"/g, '')), `${p} invents no number`);
++ }
++ const home = await down.get('/');
++ assert.ok(home.text.includes('not reachable') || home.text.includes('not available'), 'the honest notice');
++ assert.ok(!home.text.includes('requests today'), 'no invented live number');
});
-- } finally {
-- await t.close();
-- }
++ } finally { await down.close(); }
done();
})();
ddiff --git a/test/retired/README.md b/test/retired/README.md
nnew file mode 100644
iindex 0000000..42dd70e
---- /dev/null
++++ b/test/retired/README.md
@@@ -0,0 +1,9 @@
++# Exchange v1's tests, kept with the retirement
++
++These files test Exchange v1: free and swap listings, their permission passports, sandbox trials, installs,
++offers, the first-party catalogue and the public API. v1 is retired (2026-11-06): its pages answer 410, its
++API is gone, and its tables are kept read-only. The files stay here, unrun, as the record of what v1 did;
++`test/retirement.test.js` proves the retirement itself.
++
++They are not part of `npm test` (the runner reads `test/*.test.js` only), and they depend on v1 modules this
++repository no longer has.
ddiff --git a/test/retired/api.test.js b/test/retired/api.test.js
nnew file mode 100644
iindex 0000000..c9c3cfd
---- /dev/null
++++ b/test/retired/api.test.js
@@@ -0,0 +1,163 @@
++'use strict';
++/**
++ * The JSON API: a draft is created and is not public; publishing it needs the conformance tests; the public list
++ * pages with an opaque cursor and filters; detail/passport/tests shapes; validation refusals; owner and staff
++ * visibility; close and hide. There is no price in any shape.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const fixtures = require('./helpers/fixtures');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ const mod = t.network.addUser('mod', { role: 'global_mod' });
++ try {
++ let draft = null;
++ await check('POST /listings saves a draft with its passport and tests; it is not public yet', async () => {
++ draft = await fixtures.create(t, alice);
++ assert.match(draft.id, /^exl_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(draft.slug, 'starter-site-template');
++ assert.strictEqual(draft.status, 'draft');
++ assert.strictEqual(draft.mode, 'free');
++ assert.deepStrictEqual(draft.passport.spends, []);
++ assert.strictEqual(draft.tests.verified, true);
++ assert.strictEqual(draft.tests.count, 4);
++ assert.ok(!('price' in draft));
++ const pub = await t.get('/api/v1/listings');
++ assert.strictEqual(pub.json().count, 0);
++ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`)).status, 404);
++ });
++
++ await check('the owner and staff see a draft; another person does not', async () => {
++ const owner = await t.get(`/api/v1/listings/${draft.slug}`, { as: alice });
++ assert.strictEqual(owner.status, 200);
++ assert.strictEqual(owner.json().status, 'draft');
++ assert.strictEqual(owner.json().owner, alice.subject);
++ assert.strictEqual(owner.json().mine, true);
++ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`, { as: bob })).status, 404);
++ const staff = await t.get(`/api/v1/listings/${draft.slug}`, { as: mod });
++ assert.strictEqual(staff.status, 200);
++ assert.strictEqual(staff.json().staff, true);
++ });
++
++ await check('publishing makes it public with the passport, licence and versioned tests', async () => {
++ const pub = await t.get(`/api/v1/listings/${draft.slug}/list`, { as: alice, method: 'POST' });
++ assert.strictEqual(pub.status, 200, pub.text);
++ assert.strictEqual(pub.json().status, 'listed');
++ const list = await t.get('/api/v1/listings?mode=free&kind=template');
++ assert.strictEqual(list.json().count, 1);
++ assert.strictEqual(list.headers.get('cache-control'), 'public, max-age=60');
++ const one = list.json().listings[0];
++ assert.strictEqual(one.slug, draft.slug);
++ assert.strictEqual(one.tests.verified, true);
++ assert.deepStrictEqual(one.passport.reads, ['search.read']);
++ const detail = await t.get(`/api/v1/listings/${draft.slug}`);
++ assert.strictEqual(detail.status, 200);
++ assert.strictEqual(detail.headers.get('cache-control'), 'public, max-age=60');
++ assert.strictEqual(detail.json().license, 'CC0-1.0');
++ assert.strictEqual(detail.json().manifest.entry, 'templates/starter/index.html');
++ assert.strictEqual(detail.json().tests.rows.length, 4);
++ const passport = await t.get(`/api/v1/listings/${draft.slug}/passport`);
++ assert.strictEqual(passport.status, 200);
++ assert.deepStrictEqual(passport.json().passport.writes, ['space.files.write']);
++ assert.deepStrictEqual(passport.json().passport.spends, []);
++ const tests = await t.get(`/api/v1/listings/${draft.slug}/tests`);
++ assert.strictEqual(tests.json().verified, true);
++ assert.deepStrictEqual(tests.json().tests.map((x) => x.name).sort(), ['licence', 'manifest', 'no_money', 'passport']);
++ });
++
++ await check('the list pages with an opaque cursor and filters, and refuses a garbage cursor', async () => {
++ const two = await fixtures.listed(t, alice, { title: 'A calm theme kit', kind: 'theme', mode: 'swap', wants: 'A type kit' });
++ const three = await fixtures.listed(t, alice, { title: 'A surf map', kind: 'game_asset' });
++ const one = await t.get('/api/v1/listings?limit=2');
++ assert.strictEqual(one.json().count, 2);
++ assert.ok(one.json().next_cursor);
++ const next = await t.get(`/api/v1/listings?limit=2&cursor=${encodeURIComponent(one.json().next_cursor)}`);
++ assert.strictEqual(next.status, 200);
++ assert.strictEqual(next.json().count, 1);
++ assert.strictEqual(next.json().next_cursor, null);
++ assert.strictEqual((await t.get('/api/v1/listings?cursor=garbage')).status, 400);
++ assert.strictEqual((await t.get('/api/v1/listings?kind=nope')).status, 400);
++ assert.strictEqual((await t.get('/api/v1/listings?mode=paid')).status, 400);
++ const swaps = await t.get('/api/v1/listings?mode=swap');
++ assert.strictEqual(swaps.json().count, 1);
++ assert.strictEqual(swaps.json().listings[0].slug, two.slug);
++ const q = await t.get('/api/v1/listings?q=surf');
++ assert.strictEqual(q.json().count, 1);
++ assert.strictEqual(q.json().listings[0].slug, three.slug);
++ });
++
++ await check('a bad listing is refused with a code, in the API and the form alike', async () => {
++ const post = (body) => t.get('/api/v1/listings', { as: alice, json: body });
++ const base = fixtures.body();
++ assert.strictEqual((await post({ ...base, title: '' })).status, 400);
++ assert.strictEqual((await post({ ...base, spends: [] })).status, 400);
++ const money = await post({ ...base, summary: 'Yours for $20, cash only.' });
++ assert.strictEqual(money.status, 400);
++ assert.strictEqual(money.json().code, 'exchange.prohibited');
++ const spend = await post({ ...base, passport: { ...base.passport, spends: ['billing.spend'] } });
++ assert.strictEqual(spend.status, 400);
++ assert.strictEqual(spend.json().code, 'exchange.no_money');
++ const secret = await post({ ...base, manifest: { ...base.manifest, token: 'ghp_123' } });
++ assert.strictEqual(secret.status, 400);
++ const secretValue = await post({ ...base, manifest: { ...base.manifest, entry: 'api_key=sk-live-123' } });
++ assert.strictEqual(secretValue.status, 400);
++ assert.strictEqual(secretValue.json().code, 'exchange.manifest_secret');
++ const freeWants = await post({ ...base, mode: 'free', wants: 'A type kit' });
++ assert.strictEqual(freeWants.status, 400);
++ const swapNoWants = await post({ ...base, mode: 'swap', wants: '' });
++ assert.strictEqual(swapNoWants.status, 400);
++ const badVersion = await post({ ...base, version: 'latest' });
++ assert.strictEqual(badVersion.status, 400);
++ });
++
++ await check('a malformed body and an unknown API route are problem+json', async () => {
++ const bad = await t.get('/api/v1/listings', { as: alice, body: '{', headers: { 'content-type': 'application/json' } });
++ assert.strictEqual(bad.status, 400);
++ assert.match(bad.headers.get('content-type'), /application\/problem\+json/);
++ assert.strictEqual(bad.json().code, 'request.malformed_json');
++ const nope = await t.get('/api/v1/nope');
++ assert.strictEqual(nope.status, 404);
++ assert.strictEqual(nope.json().code, 'route.not_found');
++ });
++
++ await check('/me/listings has every status of yours', async () => {
++ const draft2 = await fixtures.create(t, alice, { title: 'A second draft' });
++ const mine = await t.get('/api/v1/me/listings', { as: alice });
++ assert.strictEqual(mine.status, 200);
++ assert.ok(mine.json().listings.some((x) => x.slug === draft2.slug && x.status === 'draft'));
++ assert.strictEqual((await t.get('/api/v1/me/listings')).status, 401);
++ });
++
++ await check('close is the owner\'s, removes it from the public list, and is idempotent', async () => {
++ const close = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: bob, method: 'POST' });
++ assert.strictEqual(close.status, 404);
++ const ok = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: alice, method: 'POST', json: { reason: 'withdrawn' } });
++ assert.strictEqual(ok.status, 200);
++ assert.strictEqual(ok.json().status, 'closed');
++ assert.strictEqual(ok.json().closed_reason, 'withdrawn');
++ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`)).status, 404);
++ const again = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: alice, method: 'POST', json: {} });
++ assert.strictEqual(again.status, 200);
++ const pub = await t.get('/api/v1/listings?limit=50');
++ assert.ok(!pub.json().listings.some((x) => x.slug === draft.slug));
++ });
++
++ await check('staff can hide a listing; the owner still sees it', async () => {
++ const l = await fixtures.listed(t, alice, { title: 'A hideable template' });
++ assert.strictEqual((await t.get(`/api/v1/listings/${l.slug}/hide`, { as: alice, method: 'POST' })).status, 403);
++ const hide = await t.get(`/api/v1/listings/${l.slug}/hide`, { as: mod, method: 'POST' });
++ assert.strictEqual(hide.status, 200);
++ assert.strictEqual(hide.json().status, 'hidden');
++ assert.strictEqual((await t.get(`/api/v1/listings/${l.slug}`)).status, 404);
++ const owner = await t.get(`/api/v1/listings/${l.slug}`, { as: alice });
++ assert.strictEqual(owner.status, 200);
++ assert.strictEqual(owner.json().status, 'hidden');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/retired/contrast.test.js b/test/retired/contrast.test.js
nnew file mode 100644
iindex 0000000..332a15f
---- /dev/null
++++ b/test/retired/contrast.test.js
@@@ -0,0 +1,14 @@
++'use strict';
++// WCAG 2.1 AA: the passport stamp's small line was 3.8:1 at .8 opacity. It now sits at .95.
++// node test/contrast.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'exchange.css'), 'utf8');
++assert.ok(
++ css.includes('.ex-motif-passport small { font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; opacity: .95; }'),
++ 'the passport small line is .95, not .8'
++);
++assert.ok(!css.includes('.ex-motif-passport small { font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; opacity: .8; }'), 'the .8 stamp line is gone');
++console.log('contrast: the passport stamp reads');
ddiff --git a/test/retired/first-party.test.js b/test/retired/first-party.test.js
nnew file mode 100644
iindex 0000000..769bffa
---- /dev/null
++++ b/test/retired/first-party.test.js
@@@ -0,0 +1,284 @@
++'use strict';
++/**
++ * The first-party catalogue: Design's built-in themes and their token files, Live's OBS scenes and alerts, and Games'
++ * public worlds. The job is idempotent, keeps each thing's real home, marks it official without inventing a person,
++ * retires what a source no longer lists, and never touches a person's own listing.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const fixtures = require('./helpers/fixtures');
++
++const THEMES = {
++ themes: [
++ {
++ id: 'vibe', name: 'Vibe', slug: 'vibe', mode: 'dark',
++ description: 'The default OpenVibe theme.', is_builtin: 1,
++ variables: {
++ '--bg-primary': '#0a0f1c', '--bg-secondary': '#101828', '--bg-card': '#131c2e',
++ '--text-primary': '#e6edf7', '--text-secondary': '#96a7c2', '--accent': '#3b82f6', '--border': '#1f2d47',
++ },
++ preview_colors: { bg: '#0A0F1C', accent: '#3B82F6', text: '#E6EDF7' },
++ owner_email: '[email protected]', api_key: 'should_never_be_listed',
++ },
++ {
++ id: 'paper', name: 'Paper', slug: 'paper', mode: 'light', description: 'A quiet light theme.', is_builtin: true,
++ variables: { '--bg-primary': '#fffbeb', '--bg-card': '#fffdf3', '--text-primary': '#1f1f1f', '--accent': '#644ac9', '--text-secondary': 'warm grey', '--border': '#e3dab8' },
++ },
++ { id: 'made-by-a-person', name: 'Mine', slug: 'mine', mode: 'dark', description: 'A community theme.', is_builtin: 0, variables: {} },
++ ],
++};
++
++const TARGETS = {
++ targets: [
++ { id: 'css', label: 'CSS variables', ext: 'css', lang: 'css' },
++ { id: 'json', label: 'Design tokens', ext: 'tokens.json', lang: 'json' },
++ ],
++};
++
++const WORLDS = {
++ worlds: [
++ { id: 'wabc123def0', title: 'Floating islands', summary: 'A world of bridges in the sky.', owner: 'maker', plays: 12, online: 0, play_url: '/play?world=wabc123def0' },
++ ],
++ page: 1,
++ more: false,
++};
++
++function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGames = false, failTargets = false } = {}) {
++ return async (url) => {
++ const u = new URL(url);
++ if (u.hostname === 'openvibe.design' && u.pathname === '/api/themes') return { ok: true, status: 200, json: async () => themes };
++ if (u.hostname === 'openvibe.design' && u.pathname === '/api/v1/tokens') {
++ if (failTargets) return { ok: false, status: 503, json: async () => ({}) };
++ return { ok: true, status: 200, json: async () => targets };
++ }
++ if (u.hostname === 'openvibe.games' && u.pathname === '/api/worlds') {
++ if (failGames) throw new Error('Games is restarting');
++ return { ok: true, status: 200, json: async () => worlds };
++ }
++ return { ok: false, status: 404, json: async () => ({}) };
++ };
++}
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ try {
++ const fetch = fakeFetch();
++ let themeId = null;
++ let revision = null;
++
++ await check('a first pass lists each theme once, with its token files, plus the OBS templates and a public world', async () => {
++ const out = await t.ctx.firstParty.runOnce({ sources: ['design', 'obs', 'games'], fetchImpl: fetch });
++ assert.strictEqual(out.sources.design.themes.ok, true, JSON.stringify(out.sources.design));
++ assert.strictEqual(out.sources.design.themes.imported, 2, 'only the two built-in themes');
++ assert.strictEqual(out.sources.design.removed_tokens, 0);
++ assert.strictEqual(out.sources.obs.imported, 4, 'three scenes and the alerts overlay');
++ assert.strictEqual(out.sources.games.imported, 1);
++
++ const list = await t.get('/api/v1/listings?limit=50');
++ assert.strictEqual(list.status, 200, list.text);
++ assert.strictEqual(list.json().count, 7);
++ const rows = list.json().listings;
++ for (const row of rows) {
++ assert.strictEqual(row.official, true, `${row.slug} is official`);
++ assert.match(row.home_url, /^https:\/\//, `${row.slug} keeps a real home`);
++ assert.strictEqual(row.status, 'listed');
++ assert.strictEqual(row.mode, 'free');
++ assert.ok(!('owner' in row), 'the public list never carries an owner');
++ }
++
++ const theme = rows.find((r) => r.slug === 'vibe-theme');
++ assert.ok(theme, 'the Vibe theme is listed');
++ themeId = theme.id;
++ revision = theme.revision;
++ assert.strictEqual(theme.kind, 'theme');
++ assert.strictEqual(theme.creator, 'OpenVibe');
++ assert.strictEqual(theme.home_url, 'https://openvibe.design/studio?theme=vibe');
++ assert.strictEqual(theme.manifest.entry, theme.home_url);
++ assert.strictEqual(theme.license, 'AGPL-3.0-only');
++ assert.deepStrictEqual(theme.files, [
++ { format: 'css', label: 'CSS variables', url: 'https://openvibe.design/api/v1/tokens/vibe?format=css' },
++ { format: 'json', label: 'Design tokens', url: 'https://openvibe.design/api/v1/tokens/vibe?format=json' },
++ ], 'the theme carries its token files');
++ assert.deepStrictEqual(theme.palette, ['#0a0f1c', '#131c2e', '#e6edf7', '#3b82f6', '#96a7c2', '#1f2d47'], 'six preview colours, in order');
++ const paper = rows.find((r) => r.slug === 'paper-theme');
++ assert.deepStrictEqual(paper.palette, ['#fffbeb', '#fffdf3', '#1f1f1f', '#644ac9', '#e3dab8'], 'only valid hex, up to six');
++ assert.ok(!rows.some((r) => r.home_url.includes('?format=')), 'no token file is a listing of its own');
++ assert.strictEqual(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'design.tokens'"), 0);
++
++ const world = rows.find((r) => r.slug === 'floating-islands');
++ assert.ok(world, 'the public world is listed');
++ assert.strictEqual(world.kind, 'game_asset');
++ assert.strictEqual(world.creator, 'maker', 'the real maker is credited on a network listing');
++ assert.strictEqual(world.home_url, 'https://openvibe.games/play?world=wabc123def0');
++
++ assert.ok(rows.some((r) => r.home_url === 'https://openvibe.live/obs/scene/starting/openvibe'));
++ assert.ok(rows.some((r) => r.home_url === 'https://openvibe.live/obs/scene/brb/openvibe'));
++ assert.ok(rows.some((r) => r.home_url === 'https://openvibe.live/obs/scene/ending/openvibe'));
++ assert.ok(rows.some((r) => r.home_url === 'https://openvibe.live/obs/alerts/openvibe?demo=1'));
++ assert.ok(!rows.some((r) => r.home_url.includes('/mine')), 'a community theme is not relisted as first-party');
++ });
++
++ await check('the palette is in the API and drawn on the card and the listing page', async () => {
++ const api = await t.get('/api/v1/listings?limit=50');
++ const theme = api.json().listings.find((r) => r.slug === 'vibe-theme');
++ assert.deepStrictEqual(theme.palette, ['#0a0f1c', '#131c2e', '#e6edf7', '#3b82f6', '#96a7c2', '#1f2d47']);
++ const page = await t.get('/listings/vibe-theme');
++ assert.strictEqual(page.status, 200, page.text);
++ assert.match(page.text, /class="ex-palette"/);
++ for (const colour of theme.palette) assert.ok(page.text.includes(`<i style="background:${colour}"></i>`), colour);
++ const card = await t.get('/listings?kind=theme');
++ assert.strictEqual(card.status, 200, card.text);
++ assert.match(card.text, /<i style="background:#0a0f1c"><\/i>/);
++ assert.match(card.text, /<i style="background:#3b82f6"><\/i>/);
++ });
++
++ await check('a second pass changes nothing and creates no duplicate', async () => {
++ const before = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings');
++ const out = await t.ctx.firstParty.runOnce({ sources: ['design', 'obs', 'games'], fetchImpl: fetch });
++ assert.strictEqual(out.sources.design.themes.imported, 2);
++ assert.strictEqual(out.sources.design.removed_tokens, 0);
++ assert.strictEqual(out.sources.games.imported, 1);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings'), before);
++ const dupes = await t.ctx.s.db.many('SELECT source, source_id, COUNT(*)::int AS n FROM exchange_listings WHERE source IS NOT NULL GROUP BY 1, 2 HAVING COUNT(*) > 1');
++ assert.deepStrictEqual(dupes, []);
++ const theme = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
++ assert.strictEqual(theme.revision, revision, 'an unchanged row is untouched');
++ });
++
++ await check('the old one-listing-per-token-file rows are removed, idempotently', async () => {
++ const legacy = await t.ctx.entries.upsertFirstParty({
++ source: 'design.tokens',
++ sourceId: 'vibe:css',
++ creator: 'OpenVibe',
++ body: {
++ title: 'Vibe tokens — CSS variables', kind: 'template', mode: 'free',
++ summary: 'An old per-file listing.', license: 'AGPL-3.0-only', version: '1.0.0',
++ home_url: 'https://openvibe.design/api/v1/tokens/vibe?format=css',
++ manifest: { name: 'Vibe CSS', entry: 'https://openvibe.design/api/v1/tokens/vibe?format=css', runtime: 'css' },
++ passport: { reads: [], writes: [], spends: [], contacts: ['https://openvibe.design'] },
++ },
++ });
++ assert.strictEqual(legacy.ok, true, JSON.stringify(legacy));
++ const out = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fetch });
++ assert.strictEqual(out.sources.design.removed_tokens, 1);
++ assert.strictEqual(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'design.tokens'"), 0);
++ const again = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fetch });
++ assert.strictEqual(again.sources.design.removed_tokens, 0, 'a second run removes nothing');
++ });
++
++ await check('a token endpoint that is down leaves the theme downloads as they were', async () => {
++ const before = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
++ const out = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fakeFetch({ failTargets: true }) });
++ assert.strictEqual(out.sources.design.themes.ok, true);
++ const after = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
++ assert.deepStrictEqual(after.files, before.files);
++ assert.strictEqual(after.revision, before.revision, 'no needless rewrite');
++ });
++
++ await check('a changed theme updates the same listing and bumps its revision', async () => {
++ const themes = JSON.parse(JSON.stringify(THEMES));
++ themes.themes[0].description = 'The default OpenVibe theme, now with a new blurb.';
++ const out = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fakeFetch({ themes }) });
++ assert.strictEqual(out.sources.design.themes.changed, 1);
++ const theme = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
++ assert.strictEqual(theme.summary, 'The default OpenVibe theme, now with a new blurb.');
++ assert.strictEqual(Number(theme.revision), Number(revision) + 1);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = $1', ['design.theme']), 2);
++ });
++
++ await check('a theme the source no longer lists is retired', async () => {
++ const themes = { themes: [THEMES.themes[1]] }; // Paper only
++ const out = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fakeFetch({ themes }) });
++ assert.strictEqual(out.sources.design.themes.retired, 1);
++ const oldTheme = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
++ assert.strictEqual(oldTheme.status, 'closed');
++ assert.strictEqual(oldTheme.closed_reason, 'withdrawn');
++ const list = await t.get('/api/v1/listings?limit=50');
++ assert.ok(!list.json().listings.some((r) => r.id === themeId));
++ });
++
++ await check('a retired item that comes back is relisted under the same row', async () => {
++ const out = await t.ctx.firstParty.runOnce({ sources: ['design'], fetchImpl: fakeFetch() });
++ assert.strictEqual(out.sources.design.themes.imported, 2);
++ const back = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
++ assert.strictEqual(back.status, 'listed');
++ assert.strictEqual(back.id, themeId, 'same row, same home, no duplicate');
++ assert.strictEqual(back.closed_at, null);
++ });
++
++ await check('a source that fails retires nothing', async () => {
++ const before = await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'games.world' AND status = 'listed'");
++ const out = await t.ctx.firstParty.runOnce({ sources: ['games'], fetchImpl: fakeFetch({ failGames: true }) });
++ assert.strictEqual(out.sources.games.ok, false);
++ assert.match(out.sources.games.error, /restarting/);
++ assert.strictEqual(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'games.world' AND status = 'listed'"), before);
++ });
++
++ await check('the first page is interleaved by kind, and the theme page shows its downloads', async () => {
++ const page = await t.get('/api/v1/listings?limit=4');
++ assert.strictEqual(page.status, 200, page.text);
++ const kinds = new Set(page.json().listings.map((r) => r.kind));
++ assert.ok(kinds.size >= 2, `one kind must not fill the first page: ${[...kinds].join(', ')}`);
++
++ const html = await t.get('/listings/vibe-theme');
++ assert.strictEqual(html.status, 200, html.text);
++ assert.match(html.text, /Downloads/);
++ assert.match(html.text, /https:\/\/openvibe\.design\/api\/v1\/tokens\/vibe\?format=css/);
++ assert.match(html.text, /https:\/\/openvibe\.design\/api\/v1\/tokens\/vibe\?format=json/);
++ });
++
++ await check('a person\'s listing is never made official, and the official rows have no owner', async () => {
++ const mine = await fixtures.listed(t, alice, { title: 'Alice hand-made theme', kind: 'theme' });
++ await t.ctx.firstParty.runOnce({ sources: ['design', 'obs', 'games'], fetchImpl: fetch });
++ const after = await t.get(`/api/v1/listings/${mine.slug}`, { as: alice });
++ assert.strictEqual(after.status, 200, after.text);
++ assert.strictEqual(after.json().official, false);
++ assert.strictEqual(after.json().owner, alice.subject);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings WHERE official AND owner IS NOT NULL'), 0);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings WHERE official AND source IS NULL'), 0);
++ });
++
++ await check('nothing private from a source is stored or served', async () => {
++ const row = await t.ctx.s.db.maybe('SELECT * FROM exchange_listings WHERE id = $1', [themeId]);
++ const dump = JSON.stringify(row) + (await t.get(`/api/v1/listings/${row.slug}`)).text;
++ assert.ok(!/designer@example\.test/.test(dump), 'no source email');
++ assert.ok(!/should_never_be_listed/.test(dump), 'no source credential');
++ assert.ok(!/owner_email/.test(dump));
++ });
++
++ await check('only validated hex colours reach the pages and the API', async () => {
++ await t.ctx.s.db.exec('UPDATE exchange_listings SET palette = $1::jsonb WHERE id = $2', [
++ JSON.stringify(['#0a0f1c', '#GGGGGG', 'javascript:alert(1)', '#AABBCC', '#123456', '#654321', '#999999', '#ABCDEF', '#000000']),
++ themeId,
++ ]);
++ const page = await t.get('/listings/vibe-theme');
++ assert.strictEqual(page.status, 200, page.text);
++ assert.match(page.text, /<i style="background:#0a0f1c"><\/i>/);
++ assert.match(page.text, /<i style="background:#aabbcc"><\/i>/);
++ assert.ok(!page.text.includes('#GGGGGG'), 'a non-hex value is dropped');
++ assert.ok(!page.text.includes('javascript:'), 'no style may carry a url or script');
++ assert.ok(!page.text.includes('background:#000000'), 'a seventh colour is dropped');
++ const api = (await t.get('/api/v1/listings/vibe-theme')).json();
++ assert.deepStrictEqual(api.palette, ['#0a0f1c', '#aabbcc', '#123456', '#654321', '#999999', '#abcdef']);
++ const cards = await t.get('/listings?kind=theme');
++ assert.ok(!cards.text.includes('#GGGGGG') && !cards.text.includes('javascript:'), 'the card is safe too');
++ });
++
++ await check('the listing page links to the real home and says who listed it', async () => {
++ const paper = await t.ctx.s.db.maybe("SELECT * FROM exchange_listings WHERE slug = 'paper-theme'");
++ assert.ok(paper, 'Paper is still listed');
++ const page = await t.get(`/listings/${paper.slug}`);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /https:\/\/openvibe\.design\/studio\?theme=paper/);
++ assert.match(page.text, /listed by OpenVibe/);
++ });
++
++ await check('the job refuses to start under drill', async () => {
++ assert.strictEqual(t.ctx.firstParty.start(), false);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/retired/fixtures.js b/test/retired/fixtures.js
nnew file mode 100644
iindex 0000000..95b40ec
---- /dev/null
++++ b/test/retired/fixtures.js
@@@ -0,0 +1,51 @@
++'use strict';
++/** Shared fixtures: a valid CC0 template and helpers to create / publish it through the public API. */
++
++const BODY = Object.freeze({
++ title: 'Starter site template',
++ kind: 'template',
++ mode: 'free',
++ summary: 'A licensed, static starter site: one page, no tracking.',
++ description: 'Copy it into a Space, change the words, publish.',
++ license: 'CC0-1.0',
++ license_url: 'https://creativecommons.org/publicdomain/zero/1.0/',
++ version: '1.0.0',
++ manifest: { name: 'Starter site', entry: 'templates/starter/index.html', runtime: 'static' },
++ passport: { reads: ['search.read'], writes: ['space.files.write'], spends: [], contacts: ['https://openvibe.space'] },
++});
++
++function body(overrides = {}) {
++ return {
++ ...BODY,
++ ...overrides,
++ manifest: { ...BODY.manifest, ...(overrides.manifest || {}) },
++ passport: { ...BODY.passport, ...(overrides.passport || {}) },
++ };
++}
++
++async function create(t, user, overrides = {}) {
++ const r = await t.get('/api/v1/listings', { as: user, json: body(overrides) });
++ if (r.status !== 201) throw new Error(`create listing ${r.status}: ${r.text}`);
++ return r.json();
++}
++
++async function listed(t, user, overrides = {}) {
++ const l = await create(t, user, overrides);
++ const r = await t.get(`/api/v1/listings/${l.slug}/list`, { as: user, method: 'POST' });
++ if (r.status !== 200) throw new Error(`publish listing ${r.status}: ${r.text}`);
++ return r.json();
++}
++
++async function sandbox(t, user, slug) {
++ const r = await t.get(`/api/v1/listings/${slug}/sandbox`, { as: user, method: 'POST' });
++ if (r.status !== 201) throw new Error(`sandbox ${r.status}: ${r.text}`);
++ return r.json();
++}
++
++async function install(t, user, slug) {
++ const r = await t.get(`/api/v1/listings/${slug}/install`, { as: user, method: 'POST' });
++ if (r.status !== 201 && r.status !== 200) throw new Error(`install ${r.status}: ${r.text}`);
++ return r.json();
++}
++
++module.exports = { BODY, body, create, listed, sandbox, install };
ddiff --git a/test/retired/home-real-first.test.js b/test/retired/home-real-first.test.js
nnew file mode 100644
iindex 0000000..2923de0
---- /dev/null
++++ b/test/retired/home-real-first.test.js
@@@ -0,0 +1,17 @@
++'use strict';
++// Exchange's home leads with what is real (the owner, 10-31: empty sections made the rooms look unfinished). With nothing
++// listed there is no empty "Fresh listings" box under the hero: one short "be the first" row follows the scenes.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const views = require('../server/views');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'server/views/index.js'), 'utf8');
++assert.match(src, /room\.slice\(0, at\) \+ live \+ room\.slice\(at, end\) \+ beFirst \+ room\.slice\(end\)/, 'the real above the scenes, the missing after them');
++assert.ok(!src.includes('Nothing is listed yet'), 'no empty box under the hero');
++
++const empty = views.home({ listings: [], counts: {} });
++assert.ok(!empty.includes('id="ex-listings"'), 'no empty Fresh listings');
++assert.ok(empty.indexOf('Be the first') > empty.indexOf('class="sc-room-scenes"'), 'the row follows the scenes');
++assert.ok(empty.includes('href="/submit">List something</a>'), 'it says how the first one starts');
++console.log('exchange home: real first, one "be the first" row while nothing is listed');
ddiff --git a/test/retired/installs.test.js b/test/retired/installs.test.js
nnew file mode 100644
iindex 0000000..af54a91
---- /dev/null
++++ b/test/retired/installs.test.js
@@@ -0,0 +1,115 @@
++'use strict';
++/**
++ * Sandbox, install and revoke: a public listing can be tried, a passed trial is required for the install, the
++ * install keeps the passport snapshot, a revoke is one tap and idempotent, and installing again needs a fresh
++ * trial. A draft cannot be tried; another person cannot read or revoke your install.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const fixtures = require('./helpers/fixtures');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ const carol = t.network.addUser('carol');
++ try {
++ const draft = await fixtures.create(t, alice);
++ const listing = await fixtures.listed(t, alice, { title: 'A sandboxable template' });
++ let trial = null;
++ let installed = null;
++
++ await check('a draft cannot be tried; a listed one gets a passed manifest dry run', async () => {
++ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}/sandbox`, { as: bob, method: 'POST' })).status, 404);
++ const out = await fixtures.sandbox(t, bob, listing.slug);
++ trial = out.trial;
++ assert.match(trial.id, /^exs_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(trial.status, 'passed');
++ assert.strictEqual(trial.checks.length, 4);
++ assert.ok(trial.checks.some((c) => c.name === 'execution' && /dry run/i.test(c.detail)));
++ assert.strictEqual(trial.passport.spends.length, 0);
++ const page = await t.get(`/sandbox/${trial.id}`, { as: bob });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('id="ex-sandbox-trial"'));
++ assert.ok(page.text.includes('id="ex-passport"'));
++ assert.strictEqual((await t.get(`/sandbox/${trial.id}`, { as: carol })).status, 404);
++ });
++
++ await check('an install is refused until a trial passed, then keeps the passport snapshot', async () => {
++ const noTrial = await fixtures.listed(t, alice, { title: 'A no-trial template' });
++ const r = await t.get(`/api/v1/listings/${noTrial.slug}/install`, { as: carol, method: 'POST' });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json().code, 'exchange.sandbox_required');
++ const out = await fixtures.install(t, bob, listing.slug);
++ installed = out;
++ assert.match(installed.id, /^exi_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(installed.status, 'active');
++ assert.deepStrictEqual(installed.passport.reads, ['search.read']);
++ assert.strictEqual(installed.listing.slug, listing.slug);
++ const list = await t.get('/api/v1/installs', { as: bob });
++ assert.strictEqual(list.json().count, 1);
++ assert.strictEqual(list.json().installs[0].id, installed.id);
++ const again = await t.get(`/api/v1/listings/${listing.slug}/install`, { as: bob, method: 'POST' });
++ assert.strictEqual(again.status, 200);
++ assert.strictEqual(again.json().id, installed.id);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_installs')), 1);
++ });
++
++ await check('another person cannot revoke your install; a revoke is one tap and idempotent', async () => {
++ assert.strictEqual((await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: carol, method: 'POST' })).status, 404);
++ const r = await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: bob, method: 'POST' });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json().status, 'revoked');
++ assert.ok(r.json().revoked_at);
++ const again = await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: bob, method: 'POST' });
++ assert.strictEqual(again.status, 200);
++ assert.strictEqual(again.json().status, 'revoked');
++ const list = await t.get('/api/v1/installs', { as: bob });
++ assert.strictEqual(list.json().installs[0].status, 'revoked');
++ assert.strictEqual((await t.get('/api/v1/installs')).status, 401);
++ });
++
++ await check('installing again needs a fresh trial; the first one was consumed', async () => {
++ const r = await t.get(`/api/v1/listings/${listing.slug}/install`, { as: bob, method: 'POST' });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json().code, 'exchange.sandbox_required');
++ const fresh = await fixtures.sandbox(t, bob, listing.slug);
++ assert.notStrictEqual(fresh.trial.id, trial.id);
++ const reinstalled = await fixtures.install(t, bob, listing.slug);
++ assert.strictEqual(reinstalled.status, 'active');
++ assert.notStrictEqual(reinstalled.id, installed.id);
++ const consumed = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_sandbox_trials WHERE consumed_at IS NOT NULL');
++ assert.strictEqual(Number(consumed), 2);
++ });
++
++ await check('an expired trial cannot install', async () => {
++ const l = await fixtures.listed(t, alice, { title: 'An expiring template' });
++ const out = await fixtures.sandbox(t, carol, l.slug);
++ await t.ctx.s.db.exec("UPDATE exchange_sandbox_trials SET created_at = now() - interval '2 hours', expires_at = now() - interval '1 minute' WHERE id = $1", [out.trial.id]);
++ const r = await t.get(`/api/v1/listings/${l.slug}/install`, { as: carol, method: 'POST' });
++ assert.strictEqual(r.status, 409);
++ });
++
++ await check('a session write from another site is refused', async () => {
++ const l = await fixtures.listed(t, alice, { title: 'A cross-site template' });
++ const r = await t.get(`/api/v1/listings/${l.slug}/sandbox`, { as: bob, method: 'POST', origin: 'https://evil.example' });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'exchange.cross_site');
++ });
++
++ await check('the installs page is complete without JavaScript and lists active and revoked', async () => {
++ const page = await t.get('/installs', { as: bob });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('id="ex-installs"'));
++ assert.ok(page.text.includes('id="ex-installs-active"'));
++ assert.ok(page.text.includes('id="ex-installs-revoked"'));
++ assert.ok(page.text.includes('ex-passport'));
++ assert.ok(page.text.includes('action="/installs/'));
++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual((await t.get('/installs')).status, 401);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/retired/no-looks-guard.test.js b/test/retired/no-looks-guard.test.js
nnew file mode 100644
iindex 0000000..5587d93
---- /dev/null
++++ b/test/retired/no-looks-guard.test.js
@@@ -0,0 +1,18 @@
++'use strict';
++// Owner, 2026-10-31: AI may describe people; only sex acts and porn stay gated. No server file may call the old
++// looks filter again.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const root = path.join(__dirname, '..');
++const offenders = [];
++(function walk(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) walk(p);
++ else if (e.name.endsWith('.js') && /\b(noLooks|noLooksTags|hasLooks)\s*\(|openvibe-sdk\/no-looks/.test(fs.readFileSync(p, 'utf8'))) offenders.push(path.relative(root, p));
++ }
++})(path.join(root, 'server'));
++assert.deepStrictEqual(offenders, [], 'no looks filter in server/');
++console.log('No looks filter: AI text describes people as written.');
ddiff --git a/test/retired/offers.test.js b/test/retired/offers.test.js
nnew file mode 100644
iindex 0000000..f25382c
---- /dev/null
++++ b/test/retired/offers.test.js
@@@ -0,0 +1,114 @@
++'use strict';
++/**
++ * Free and swap: a free listing takes a claim, a swap listing takes an offer with what is given in return; only the
++ * owner accepts or declines, only the sender withdraws; accepting closes the listing as given or swapped. No money
++ * in an offer, and a prohibited item is refused.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const fixtures = require('./helpers/fixtures');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ const carol = t.network.addUser('carol');
++ try {
++ const free = await fixtures.listed(t, alice, { title: 'A free starter theme', kind: 'theme' });
++ const swap = await fixtures.listed(t, alice, { title: 'A surf map to swap', kind: 'game_asset', mode: 'swap', wants: 'A type kit' });
++ let claim = null;
++
++ await check('a free listing takes a claim; the owner can read it and a duplicate is refused', async () => {
++ const r = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: bob, json: { body: 'I would use this for my first site.' } });
++ assert.strictEqual(r.status, 201, r.text);
++ claim = r.json();
++ assert.match(claim.id, /^exo_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(claim.kind, 'claim');
++ assert.strictEqual(claim.status, 'open');
++ const dup = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: bob, json: { body: 'Again' } });
++ assert.strictEqual(dup.status, 409);
++ assert.strictEqual(dup.json().code, 'exchange.offer_exists');
++ const own = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: alice, json: { body: 'Mine' } });
++ assert.strictEqual(own.status, 400);
++ assert.strictEqual(own.json().code, 'exchange.own_listing');
++ const ownerRead = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: alice });
++ assert.strictEqual(ownerRead.status, 200);
++ assert.strictEqual(ownerRead.json().count, 1);
++ assert.strictEqual((await t.get(`/api/v1/listings/${free.slug}/offers`, { as: carol })).status, 403);
++ const mine = await t.get('/api/v1/offers', { as: bob });
++ assert.strictEqual(mine.json().sent.length, 1);
++ const owner = await t.get('/api/v1/offers', { as: alice });
++ assert.strictEqual(owner.json().received.length, 1);
++ });
++
++ await check('only the owner accepts; accepting closes the free listing as given and tombstones it', async () => {
++ const notOwner = await t.get(`/api/v1/offers/${claim.id}/accept`, { as: bob, method: 'POST' });
++ assert.strictEqual(notOwner.status, 403);
++ const accepted = await t.get(`/api/v1/offers/${claim.id}/accept`, { as: alice, method: 'POST' });
++ assert.strictEqual(accepted.status, 200, accepted.text);
++ assert.strictEqual(accepted.json().offer.status, 'accepted');
++ assert.strictEqual(accepted.json().listing.status, 'closed');
++ assert.strictEqual(accepted.json().listing.closed_reason, 'given');
++ assert.strictEqual((await t.get(`/api/v1/listings/${free.slug}`)).status, 404);
++ const topics = (await t.ctx.s.db.many('SELECT topic FROM exchange_outbox ORDER BY created_at')).map((x) => x.topic);
++ assert.ok(topics.includes('exchange.index_document.deleted'));
++ });
++
++ await check('a swap offer must say what is given; accepting closes it as swapped', async () => {
++ const noGive = await t.get(`/api/v1/listings/${swap.slug}/offers`, { as: carol, json: { body: 'I want this' } });
++ assert.strictEqual(noGive.status, 400);
++ const r = await t.get(`/api/v1/listings/${swap.slug}/offers`, { as: carol, json: { body: 'I have just the kit', give: 'A 12-column grid kit and an icon set' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const offer = r.json();
++ assert.strictEqual(offer.kind, 'swap');
++ const accepted = await t.get(`/api/v1/offers/${offer.id}/accept`, { as: alice, method: 'POST' });
++ assert.strictEqual(accepted.status, 200);
++ assert.strictEqual(accepted.json().listing.closed_reason, 'swapped');
++ });
++
++ await check('withdraw and decline work for the right person; a decided offer is unchanged', async () => {
++ const l = await fixtures.listed(t, alice, { title: 'A withdrawable theme' });
++ const r = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: bob, json: { body: 'I would like it' } });
++ const offer = r.json();
++ assert.strictEqual((await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: carol, method: 'POST' })).status, 403);
++ const w = await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: bob, method: 'POST' });
++ assert.strictEqual(w.status, 200);
++ assert.strictEqual(w.json().offer.status, 'withdrawn');
++ const again = await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: bob, method: 'POST' });
++ assert.strictEqual(again.status, 200);
++ assert.strictEqual(again.json().offer.status, 'withdrawn');
++
++ const r2 = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'Me instead' } });
++ const o2 = r2.json();
++ assert.strictEqual((await t.get(`/api/v1/offers/${o2.id}/decline`, { as: bob, method: 'POST' })).status, 403);
++ const d = await t.get(`/api/v1/offers/${o2.id}/decline`, { as: alice, method: 'POST' });
++ assert.strictEqual(d.status, 200);
++ assert.strictEqual(d.json().offer.status, 'declined');
++ });
++
++ await check('an offer carrying money or a prohibited item is refused', async () => {
++ const l = await fixtures.listed(t, alice, { title: 'A refusal theme' });
++ const money = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: bob, json: { body: 'I will pay you $30 by paypal.' } });
++ assert.strictEqual(money.status, 400);
++ assert.strictEqual(money.json().code, 'exchange.prohibited');
++ const weapon = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'I can swap a firearm.' } });
++ assert.strictEqual(weapon.status, 400);
++ assert.strictEqual(weapon.json().code, 'exchange.prohibited');
++ const shortGive = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'A claim with a tiny note', give: 'ok' } });
++ assert.strictEqual(shortGive.status, 400);
++ assert.strictEqual(shortGive.json().code, 'exchange.invalid');
++ });
++
++ await check('the offers page is server-rendered and lists both directions', async () => {
++ const page = await t.get('/offers', { as: alice });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('id="ex-offers"'));
++ assert.ok(page.text.includes('id="ex-offers-received"'));
++ assert.ok(page.text.includes('id="ex-offers-sent"'));
++ assert.strictEqual((await t.get('/offers')).status, 401);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/retired/openapi-root.test.js b/test/retired/openapi-root.test.js
nnew file mode 100644
iindex 0000000..d80e562
---- /dev/null
++++ b/test/retired/openapi-root.test.js
@@@ -0,0 +1,47 @@
++'use strict';
++// SEO-3: /openapi.json answered 404 here (11-03). It is this service's API as OpenAPI 3.1 (routes: 'serve' is the
++// document /api/v1/openapi.json already served; 'routes' is read from the routes the app mounts).
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ let failed = 0;
++ try {
++ const r = await t.get('/openapi.json');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.match(String(r.headers.get('content-type')), /^application\/json/);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'any agent may read it');
++ const doc = JSON.parse(r.text);
++ assert.match(doc.openapi, /^3\.1/);
++ assert.ok(doc.info && doc.info.title && doc.info.version, 'a title and a version');
++ const paths = Object.keys(doc.paths || {});
++ assert.ok(paths.length >= 2, `paths: ${paths.join(' ')}`);
++ for (const p of paths) {
++ for (const [m, op] of Object.entries(doc.paths[p])) {
++ if (!['get', 'put', 'post', 'delete', 'patch'].includes(m)) continue;
++ assert.ok(op.responses && Object.keys(op.responses).length, `${m} ${p} has responses`);
++ for (const name of [...p.matchAll(/\{([^}]+)\}/g)].map((x) => x[1])) {
++ assert.ok((op.parameters || []).some((q) => q.name === name && q.in === 'path') || (doc.paths[p].parameters || []).some((q) => q.name === name), `${m} ${p} declares {${name}}`);
++ }
++ }
++ }
++ if ('routes' === 'routes') {
++ assert.ok(paths.every((p) => p.startsWith('/api/')), 'only the API');
++ assert.ok(!paths.some((p) => /\/(?:health|ready)$|openapi\.json$/.test(p)), 'no probes, not itself');
++ assert.ok(Object.values(doc.paths).some((item) => Object.values(item).some((op) => op.summary)), 'the header comments give summaries');
++ const again = JSON.parse((await t.get('/openapi.json')).text);
++ assert.deepStrictEqual(Object.keys(again.paths), paths, 'the same document each time');
++ } else {
++ const api = await t.get('/api/v1/openapi.json');
++ assert.deepStrictEqual(JSON.parse(api.text), doc, 'the same document as /api/v1/openapi.json');
++ }
++ console.log(` ✓ /openapi.json: ${paths.length} paths`);
++ } catch (e) {
++ failed++;
++ console.log(` ✗ /openapi.json\n ${String(e.stack || e).split('\n').slice(0, 6).join('\n ')}`);
++ } finally {
++ await t.close();
++ }
++ process.exit(failed ? 1 : 0);
++})();
ddiff --git a/test/retired/pages.test.js b/test/retired/pages.test.js
nnew file mode 100644
iindex 0000000..21257d3
---- /dev/null
++++ b/test/retired/pages.test.js
@@@ -0,0 +1,175 @@
++'use strict';
++/**
++ * Server-rendered pages: the home (hero, three scenes, fresh listings), browse and filters, one listing with its
++ * passport and tests, the submit form, the sandbox trial, installations, offers, safety, developers, 404, cache
++ * headers, content-hashed one-comment assets, and the discovery artifacts.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const fixtures = require('./helpers/fixtures');
++const { assetVersion } = require('../server/render/layout');
++
++function jsonLd(html) {
++ return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++}
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ const listing = await fixtures.listed(t, alice, {
++ title: 'A page-tested template',
++ summary: 'A template that the pages can show.',
++ description: 'Line one.\nLine two.',
++ });
++ try {
++ await check('home: the hero, the three scenes and a fresh listing, public with Vary: Cookie', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
++ assert.strictEqual(r.headers.get('vary'), 'Cookie');
++ for (const marker of ['id="ex-home"', 'id="ex-listings"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', 'Trade what people make', 'A page-tested template', '/submit', '/safety']) {
++ assert.ok(r.text.includes(marker), marker);
++ }
++ const types = jsonLd(r.text).map((x) => x['@type']);
++ assert.ok(types.includes('WebSite'), types.join(','));
++ assert.ok(types.includes('WebApplication'), types.join(','));
++ assert.strictEqual((await t.get('/', { as: alice })).headers.get('cache-control'), 'private, no-store');
++ });
++
++ await check('/listings filters and lists; a public listing page carries the passport and tests', async () => {
++ const browse = await t.get('/listings?mode=free&kind=template');
++ assert.strictEqual(browse.status, 200);
++ assert.strictEqual(browse.headers.get('cache-control'), 'public, max-age=60');
++ assert.ok(browse.text.includes('id="ex-listings-page"'));
++ assert.ok(browse.text.includes('id="ex-filters"'));
++ assert.ok(browse.text.includes('A page-tested template'));
++ const detail = await t.get(`/listings/${listing.slug}`);
++ assert.strictEqual(detail.status, 200);
++ assert.strictEqual(detail.headers.get('cache-control'), 'public, max-age=60');
++ for (const marker of ['id="ex-listing"', 'id="ex-about"', 'id="ex-passport"', 'id="ex-tests"', 'id="ex-sandbox"', 'id="ex-install"', 'id="ex-manifest"', 'ex-passport-reads', 'CC0-1.0', 'Line one.']) {
++ assert.ok(detail.text.includes(marker), marker);
++ }
++ assert.ok(!detail.text.includes('id="ex-offer-form"'), 'a guest gets a sign-in prompt, not the form');
++ const signed = await t.get(`/listings/${listing.slug}`, { as: bob });
++ assert.strictEqual(signed.status, 200);
++ assert.ok(signed.text.includes('id="ex-offer-form"'));
++ assert.ok(signed.text.includes('Run the sandbox'));
++ assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');
++ });
++
++ await check('the submit form is plain HTML; a form POST saves a draft and redirects to it', async () => {
++ const form = await t.get('/submit');
++ assert.strictEqual(form.status, 200);
++ for (const marker of ['id="ex-submit"', 'id="ex-listing-form"', 'name="passport_reads"', 'name="manifest_entry"', 'name="mode"', 'nothing', 'no money']) assert.ok(form.text.toLowerCase().includes(marker.toLowerCase()), marker);
++ const r = await t.get('/submit', {
++ as: alice,
++ form: {
++ title: 'A form-made theme', kind: 'theme', mode: 'free', summary: 'A theme made through the form.',
++ description: 'It has a description.', license: 'MIT', version: '1.0.0',
++ manifest_name: 'Form theme', manifest_entry: 'theme.json', manifest_runtime: 'css',
++ passport_reads: 'search.read\nspace.files.read', passport_writes: 'space.files.write', passport_contacts: 'https://openvibe.space',
++ },
++ });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.match(r.headers.get('location'), /^\/listings\/a-form-made-theme\?submitted=1$/);
++ const page = await t.get(r.headers.get('location'), { as: alice });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('id="ex-owner"'));
++ assert.ok(page.text.includes('Draft'));
++ assert.ok(page.text.includes('id="ex-passport"'));
++ assert.strictEqual((await t.get('/submit', { as: alice, form: { title: 'x' } })).status, 400);
++ });
++
++ await check('a form POST from another site is refused', async () => {
++ const r = await t.get('/submit', {
++ as: alice, origin: 'https://evil.example',
++ form: { title: 'A cross-site theme', kind: 'theme', mode: 'free', summary: 'A theme.', license: 'MIT', version: '1.0.0', manifest_name: 'X', manifest_entry: 'x' },
++ });
++ assert.strictEqual(r.status, 403);
++ assert.ok(r.text.includes('id="ex-not-found"') || r.text.includes('That came from somewhere else'));
++ });
++
++ await check('the sandbox form redirects to a stable trial page; install and revoke work by form', async () => {
++ const sb = await t.get(`/listings/${listing.slug}/sandbox`, { as: bob, method: 'POST' });
++ assert.strictEqual(sb.status, 303);
++ assert.match(sb.headers.get('location'), /^\/sandbox\/exs_/);
++ const page = await t.get(sb.headers.get('location'), { as: bob });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('id="ex-sandbox-trial"'));
++ assert.ok(page.text.includes('The dry run passed'));
++ const ins = await t.get(`/listings/${listing.slug}/install`, { as: bob, method: 'POST' });
++ assert.strictEqual(ins.status, 303);
++ assert.strictEqual(ins.headers.get('location'), '/installs?installed=1');
++ const installs = await t.get('/installs', { as: bob });
++ assert.ok(installs.text.includes('id="ex-installs"'));
++ assert.ok(installs.text.includes('Revoke'));
++ const id = await t.ctx.s.db.value("SELECT id FROM exchange_installs WHERE status = 'active' LIMIT 1");
++ const rev = await t.get(`/installs/${id}/revoke`, { as: bob, method: 'POST' });
++ assert.strictEqual(rev.status, 303);
++ assert.strictEqual(rev.headers.get('location'), '/installs?revoked=1');
++ assert.strictEqual((await t.ctx.s.db.value('SELECT status FROM exchange_installs WHERE id = $1', [id])), 'revoked');
++ });
++
++ await check('the claim form creates a free claim; the owner sees it on the listing page', async () => {
++ const claim = await t.get(`/listings/${listing.slug}/offers`, { as: bob, form: { body: 'I would like to use this.' } });
++ assert.strictEqual(claim.status, 303);
++ assert.strictEqual(claim.headers.get('location'), `/listings/${listing.slug}?offered=1`);
++ const owner = await t.get(`/listings/${listing.slug}`, { as: alice });
++ assert.ok(owner.text.includes('id="ex-offers"'));
++ assert.ok(owner.text.includes('I would like to use this.'));
++ assert.ok(owner.text.includes('Accept the claim'));
++ });
++
++ await check('safety, developers, 404 and the submit sign-in prompt', async () => {
++ const safety = await t.get('/safety');
++ assert.strictEqual(safety.status, 200);
++ assert.ok(safety.text.includes('id="ex-safety"'));
++ assert.ok(safety.text.includes('No money, anywhere'));
++ const dev = await t.get('/developers');
++ assert.strictEqual(dev.status, 200);
++ assert.ok(dev.text.includes('id="ex-developers"'));
++ assert.ok(dev.text.includes('/api/v1/listings'));
++ assert.ok(dev.text.includes('network.grants.write'));
++ const nf = await t.get('/no/such/page');
++ assert.strictEqual(nf.status, 404);
++ assert.match(nf.headers.get('content-type'), /^text\/html/);
++ assert.ok(nf.text.includes('id="ex-not-found"'));
++ const prompt = await t.get('/submit', { as: null });
++ assert.ok(prompt.text.includes('id="ex-listing-form"'), 'a guest can read the form');
++ const install = await t.get('/installs');
++ assert.strictEqual(install.status, 401);
++ assert.ok(install.text.includes('Sign in with OpenVibe'));
++ });
++
++ await check('assets are content-hashed and served; robots and sitemap list public listings', async () => {
++ const v = assetVersion('css/exchange.css');
++ const css = await t.get(`/css/exchange.css?v=${v}`);
++ assert.strictEqual(css.status, 200);
++ assert.strictEqual(css.headers.get('cache-control'), 'public, max-age=31536000, immutable');
++ assert.match(css.text, /OpenVibe\.Exchange: the room's own pieces/, 'the designed stylesheet');
++ const wrong = await t.get('/css/exchange.css?v=deadbeefdeadbeef');
++ assert.strictEqual(wrong.status, 200);
++ assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
++ const js = await t.get('/js/exchange.js');
++ assert.strictEqual(js.status, 200);
++ assert.match(js.headers.get('content-type'), /javascript/);
++ assert.strictEqual(js.text.trim().split('\n').length, 1);
++ const robots = await t.get('/robots.txt');
++ assert.match(robots.text, /Disallow: \/api\//);
++ const map = await t.get('/sitemap.xml');
++ assert.strictEqual(map.status, 200);
++ assert.ok(map.text.includes(`https://openvibe.exchange/listings/${listing.slug}`));
++ const llms = await t.get('/llms.txt');
++ assert.strictEqual(llms.status, 200);
++ assert.ok(llms.text.includes('OpenVibe.Exchange'));
++ assert.ok(llms.text.includes('\n## Optional\n'));
++ assert.ok(llms.text.includes('(https://openvibe.exchange/index.md)'));
++ const full = await t.get('/llms-full.txt');
++ assert.ok(full.text.includes(listing.slug));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/retired/safety.test.js b/test/retired/safety.test.js
nnew file mode 100644
iindex 0000000..39c91a6
---- /dev/null
++++ b/test/retired/safety.test.js
@@@ -0,0 +1,44 @@
++'use strict';
++/**
++ * The free/swap safety rules: the fixed prohibited-items list, the no-money rule, and cleanText. A legitimate
++ * phrase such as "a cash register icon pack" is not refused; a price or a payment request is.
++ */
++const assert = require('assert');
++const { findProhibited, refusals, cleanText, SAFETY, PROHIBITED, MONEY_AMOUNT } = require('../server/safety');
++const { check, done } = require('./helpers/boot');
++
++(async () => {
++ await check('the prohibited list catches the plain cases', async () => {
++ assert.strictEqual(findProhibited('A shotgun for swap').code, 'weapons');
++ assert.strictEqual(findProhibited('Cocaine, cheap').code, 'drugs');
++ assert.strictEqual(findProhibited('A ransomware builder').code, 'malware');
++ assert.strictEqual(findProhibited('Escort services').code, 'sexual');
++ assert.strictEqual(findProhibited('A forged passport').code, 'forged');
++ assert.strictEqual(findProhibited('A kitten').code, 'animals');
++ assert.strictEqual(findProhibited('Yours for $20').code, 'money');
++ assert.strictEqual(findProhibited('Pay by PayPal').code, 'money');
++ assert.ok(PROHIBITED.length >= 6);
++ assert.ok(SAFETY.includes('no payments') || SAFETY.includes('has no payments'));
++ });
++
++ await check('ordinary maker copy is not refused', async () => {
++ assert.strictEqual(findProhibited('A cash register icon pack for a shop theme'), null);
++ assert.strictEqual(findProhibited('A 12-column grid and a type kit'), null);
++ assert.strictEqual(findProhibited('A surf map with three ramps'), null);
++ assert.strictEqual(findProhibited(''), null);
++ assert.ok(MONEY_AMOUNT.test('€15'));
++ assert.ok(!MONEY_AMOUNT.test('version 15'));
++ });
++
++ await check('refusals carry the API code, and cleanText normalises plain text', async () => {
++ const hit = refusals('A theme', 'and a firearm');
++ assert.strictEqual(hit.status, 400);
++ assert.strictEqual(hit.code, 'exchange.prohibited');
++ assert.ok(hit.message.includes('weapons'));
++ assert.strictEqual(refusals('A theme'), null);
++ assert.strictEqual(cleanText(' a\r\nb\u0000 ', 20), 'a\nb');
++ assert.strictEqual(cleanText('abcdef', 3), 'abc');
++ });
++
++ done();
++})();
ddiff --git a/test/retired/search-relay.test.js b/test/retired/search-relay.test.js
nnew file mode 100644
iindex 0000000..a1915d2
---- /dev/null
++++ b/test/retired/search-relay.test.js
@@@ -0,0 +1,70 @@
++'use strict';
++/**
++ * The queued Search documents reach OpenVibe.Search (server/app.js, openvibe-publishing/search-relay): with
++ * EXCHANGE_SEARCH_URL set, publishing a listing PUTs its document to Search's owner API with a service token for
++ * openvibe.search, and closing it DELETEs it at the listing's new revision; a delivered row leaves the queue. Without
++ * the URL the relay is off and the rows wait.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++const fixtures = require('./helpers/fixtures');
++
++function fakeSearch() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', body: body ? JSON.parse(body) : null });
++ res.writeHead(200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ outcome: 'applied' }));
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) })));
++}
++
++(async () => {
++ const search = await fakeSearch();
++ const t = await boot({ env: { EXCHANGE_SEARCH_URL: search.url } });
++ try {
++ const alice = t.network.addUser('alice');
++ const l = await fixtures.listed(t, alice, { title: 'A relayed template' });
++
++ await check('a listed listing is PUT to Search with a service token, and the row leaves the queue', async () => {
++ assert.strictEqual(t.ctx.searchRelay.enabled, true);
++ const out = await t.ctx.searchRelay.drain();
++ assert.ok(out.sent >= 1, JSON.stringify(out));
++ const put = search.calls.find((c) => c.method === 'PUT' && c.url === `/api/v1/documents/exchange/listing/${l.id}`);
++ assert.ok(put, `a PUT for ${l.id}: ${JSON.stringify(search.calls.map((c) => `${c.method} ${c.url}`))}`);
++ assert.match(put.auth, /^Bearer \S+/);
++ assert.strictEqual(put.body.canonical_url, `https://openvibe.exchange/listings/${l.slug}`);
++ assert.strictEqual(put.body.title, 'A relayed template');
++ assert.strictEqual((await t.ctx.searchRelay.status()).pending, 0);
++ });
++
++ await check('closing it DELETEs the document at the listing\'s new revision', async () => {
++ const before = search.calls.length;
++ const r = await t.get(`/api/v1/listings/${l.slug}/close`, { as: alice, method: 'POST', json: { reason: 'withdrawn' } });
++ assert.strictEqual(r.status, 200);
++ await t.ctx.searchRelay.drain();
++ const del = search.calls.slice(before).find((c) => c.method === 'DELETE');
++ assert.ok(del, 'a DELETE');
++ assert.strictEqual(del.url, `/api/v1/documents/exchange/listing/${l.id}?revision=${r.json().revision}`);
++ });
++ } finally {
++ await t.close();
++ await search.close();
++ }
++
++ const off = await boot();
++ try {
++ await check('without EXCHANGE_SEARCH_URL the relay is off', async () => {
++ assert.strictEqual(off.ctx.searchRelay.enabled, false);
++ assert.strictEqual(await off.ctx.searchRelay.drain(), null);
++ });
++ } finally {
++ await off.close();
++ }
++ done();
++})();
ddiff --git a/test/retired/search.test.js b/test/retired/search.test.js
nnew file mode 100644
iindex 0000000..99dc339
---- /dev/null
++++ b/test/retired/search.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * The Search Fabric document: owner exchange, type listing, id exl_…, canonical /listings/<slug>, title/summary,
++ * facets kind/mode/licence/creator and the passport permissions, authorship human, the listing's revision. A draft
++ * writes nothing; publishing upserts; closing or hiding writes a valid tombstone.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++const fixtures = require('./helpers/fixtures');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const mod = t.network.addUser('mod', { role: 'global_mod' });
++ try {
++ const l = await fixtures.listed(t, alice, { title: 'A searchable template' });
++
++ await check('publishing writes a valid search.index-document@1', async () => {
++ const row = await t.ctx.s.db.maybe("SELECT topic, payload FROM exchange_outbox WHERE topic = 'exchange.index_document.upserted' ORDER BY created_at DESC LIMIT 1");
++ assert.ok(row, 'an upsert row exists');
++ const doc = row.payload;
++ const v = contracts.validate('search.index-document@1', doc);
++ assert.strictEqual(v.valid, true, JSON.stringify(v.errors));
++ assert.strictEqual(doc.owner, 'exchange');
++ assert.strictEqual(doc.type, 'listing');
++ assert.strictEqual(doc.id, l.id);
++ assert.strictEqual(doc.revision, l.revision);
++ assert.strictEqual(doc.deleted, false);
++ assert.strictEqual(doc.visibility, 'public');
++ assert.strictEqual(doc.publication_state, 'published');
++ assert.strictEqual(doc.canonical_url, `https://openvibe.exchange/listings/${l.slug}`);
++ assert.strictEqual(doc.title, 'A searchable template');
++ assert.strictEqual(doc.facets.kind, 'template');
++ assert.strictEqual(doc.facets.mode, 'free');
++ assert.strictEqual(doc.facets.license, 'CC0-1.0');
++ assert.deepStrictEqual(doc.facets.can_read, ['search.read']);
++ assert.strictEqual(doc.authorship, 'human');
++ assert.ok(doc.body.includes('search.read'));
++ assert.ok(doc.body.includes('Licence: CC0-1.0'));
++ });
++
++ await check('a draft writes no document; closing sends a valid tombstone with the next revision', async () => {
++ const draft = await fixtures.create(t, alice, { title: 'A never-listed theme' });
++ assert.strictEqual((await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE payload->>\'id\' = $1', [draft.id])), 0);
++ const r = await t.get(`/api/v1/listings/${l.slug}/close`, { as: alice, method: 'POST', json: { reason: 'withdrawn' } });
++ assert.strictEqual(r.status, 200);
++ const tomb = await t.ctx.s.db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' ORDER BY created_at DESC LIMIT 1");
++ assert.strictEqual(contracts.validate('search.index-document@1', tomb.payload).valid, true, JSON.stringify(contracts.validate('search.index-document@1', tomb.payload).errors));
++ assert.strictEqual(tomb.payload.deleted, true);
++ assert.strictEqual(tomb.payload.id, l.id);
++ assert.strictEqual(tomb.payload.revision, r.json().revision);
++ });
++
++ await check('a staff hide also writes a tombstone and the public list stays empty', async () => {
++ const l2 = await fixtures.listed(t, alice, { title: 'A hideable search theme' });
++ const r = await t.get(`/api/v1/listings/${l2.slug}/hide`, { as: mod, method: 'POST' });
++ assert.strictEqual(r.status, 200);
++ const row = await t.ctx.s.db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' AND payload->>'id' = $1", [l2.id]);
++ assert.ok(row);
++ assert.strictEqual(row.payload.revision, r.json().revision);
++ const pub = await t.get('/api/v1/listings?limit=50');
++ assert.strictEqual(pub.json().count, 0);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/retired/seed-proof.test.js b/test/retired/seed-proof.test.js
nnew file mode 100644
iindex 0000000..a14e3d9
---- /dev/null
++++ b/test/retired/seed-proof.test.js
@@@ -0,0 +1,44 @@
++'use strict';
++/**
++ * The first proof: a licensed template listed, tried in the sandbox, installed with its passport's grants and
++ * revoked cleanly; a second run changes nothing. The seed uses the same store functions as the API and pages.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { runSeed, SLUG, LICENSE } = require('../scripts/seed-proof');
++
++(async () => {
++ const t = await boot();
++ try {
++ let out = null;
++ await check('the seed lists a CC0 template, tries it, installs and revokes it', async () => {
++ out = await runSeed({ config: t.config, s: t.ctx.s, log: { log() {} } });
++ assert.strictEqual(out.created, true);
++ assert.strictEqual(out.listing.slug, SLUG);
++ assert.strictEqual(out.listing.status, 'listed');
++ assert.strictEqual(out.listing.mode, 'free');
++ assert.strictEqual(out.listing.license, LICENSE);
++ assert.strictEqual(out.listing.tests.verified, true);
++ assert.strictEqual(out.trial.status, 'passed');
++ assert.deepStrictEqual(out.install.passport.reads, ['search.read']);
++ assert.strictEqual(out.revoked.status, 'revoked');
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listing_tests WHERE listing_id = $1 AND status = \'passed\'', [out.listing.id])), 4);
++ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE topic = 'exchange.index_document.upserted'")), 1);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings')), 1);
++ });
++
++ await check('the public page and API show it; a second run adds nothing', async () => {
++ assert.strictEqual((await t.get(`/listings/${SLUG}`)).status, 200);
++ const api = await t.get(`/api/v1/listings/${SLUG}`);
++ assert.strictEqual(api.status, 200);
++ assert.strictEqual(api.json().license, LICENSE);
++ const again = await runSeed({ config: t.config, s: t.ctx.s, log: { log() {} } });
++ assert.strictEqual(again.created, false);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings')), 1);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_installs')), 1);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/retirement.test.js b/test/retirement.test.js
nnew file mode 100644
iindex 0000000..d257b12
---- /dev/null
++++ b/test/retirement.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * Exchange v1 is retired (design 2026-11-06): every v1 page answers 410 with a pointer, its API answers 410
++ * problem+json, and its tables stay exactly as they were — readable, never written by the site. What v1 held
++ * moved: agents and MCP tools to Codes and Connect, templates to Space and Report, themes to Design.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++
++const V1_PAGES = ['/listings', '/listings/a-template', '/submit', '/sandbox/tr_x', '/installs', '/offers', '/safety', '/developers'];
++
++(async () => {
++ const t = await boot({ ai: createFakeAi() });
++ try {
++ await check('every v1 page is 410 and names where the thing lives now', async () => {
++ for (const p of V1_PAGES) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 410, p);
++ assert.match(r.headers.get('cache-control'), /max-age=86400/);
++ assert.ok(r.text.includes('retired'), `${p} says retired`);
++ assert.ok(r.text.includes('openvibe.design') || r.text.includes('openvibe.codes') || r.text.includes('/'), `${p} points somewhere`);
++ assert.ok(!r.text.includes('id="ex-listings"'), `${p} is not the old page`);
++ }
++ });
++
++ await check('v1’s API is 410 problem+json with a pointer', async () => {
++ for (const p of ['/api/v1/ping', '/api/v1/listings', '/api/v1/listings/a-template']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 410, p);
++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
++ assert.strictEqual(r.json().code, 'exchange.retired');
++ assert.match(r.json().detail, /api\.openvibe\.exchange/);
++ }
++ });
++
++ await check('the v1 tables are still there, and a row written before the retirement still reads', async () => {
++ const db = t.ctx.s.db;
++ const lid = `exl_${ids.ulid()}`;
++ const slug = `kept-${ids.ulid().slice(-8).toLowerCase()}`;
++ await db.exec(`INSERT INTO exchange_listings (id, slug, title, mode, summary, license, version, creator, kind, status, listed_at, manifest, passport)
++ VALUES ($1, $2, 'A kept listing', 'free', 'Still readable.', 'CC0-1.0', '1.0.0', 'A maker', 'template', 'listed', now(), '{"name":"x","entry":"y"}'::jsonb, '{"reads":[],"writes":[],"spends":[],"contacts":[]}'::jsonb)`, [lid, slug]);
++ const row = await db.maybe('SELECT title, status FROM exchange_listings WHERE id = $1', [lid]);
++ assert.deepStrictEqual({ title: row.title, status: row.status }, { title: 'A kept listing', status: 'listed' });
++ });
++
++ await check('the site writes nothing to a v1 table', async () => {
++ const db = t.ctx.s.db;
++ const count = async (table) => Number(await db.value(`SELECT COUNT(*)::int AS n FROM ${table}`));
++ const before = { listings: await count('exchange_listings'), events: await count('exchange_listing_events'), offers: await count('exchange_offers'), installs: await count('exchange_installs') };
++ const alice = t.network.addUser('alice');
++ for (const p of ['/', '/models', '/api', '/sell', '/uncensored', '/ai']) await t.get(p, { as: alice });
++ for (const p of ['/chat/send']) await t.get(p, { as: alice, form: { text: 'hello' } });
++ assert.deepStrictEqual({ listings: await count('exchange_listings'), events: await count('exchange_listing_events'), offers: await count('exchange_offers'), installs: await count('exchange_installs') }, before);
++ });
++ } finally { await t.close(); }
++ done();
++})();
ddiff --git a/test/safety.test.js b/test/safety.test.js
ddeleted file mode 100644
iindex 39c91a6..0000000
---- a/test/safety.test.js
++++ /dev/null
@@@ -1,44 +0,0 @@
--'use strict';
--/**
-- * The free/swap safety rules: the fixed prohibited-items list, the no-money rule, and cleanText. A legitimate
-- * phrase such as "a cash register icon pack" is not refused; a price or a payment request is.
-- */
--const assert = require('assert');
--const { findProhibited, refusals, cleanText, SAFETY, PROHIBITED, MONEY_AMOUNT } = require('../server/safety');
--const { check, done } = require('./helpers/boot');
--
--(async () => {
-- await check('the prohibited list catches the plain cases', async () => {
-- assert.strictEqual(findProhibited('A shotgun for swap').code, 'weapons');
-- assert.strictEqual(findProhibited('Cocaine, cheap').code, 'drugs');
-- assert.strictEqual(findProhibited('A ransomware builder').code, 'malware');
-- assert.strictEqual(findProhibited('Escort services').code, 'sexual');
-- assert.strictEqual(findProhibited('A forged passport').code, 'forged');
-- assert.strictEqual(findProhibited('A kitten').code, 'animals');
-- assert.strictEqual(findProhibited('Yours for $20').code, 'money');
-- assert.strictEqual(findProhibited('Pay by PayPal').code, 'money');
-- assert.ok(PROHIBITED.length >= 6);
-- assert.ok(SAFETY.includes('no payments') || SAFETY.includes('has no payments'));
-- });
--
-- await check('ordinary maker copy is not refused', async () => {
-- assert.strictEqual(findProhibited('A cash register icon pack for a shop theme'), null);
-- assert.strictEqual(findProhibited('A 12-column grid and a type kit'), null);
-- assert.strictEqual(findProhibited('A surf map with three ramps'), null);
-- assert.strictEqual(findProhibited(''), null);
-- assert.ok(MONEY_AMOUNT.test('€15'));
-- assert.ok(!MONEY_AMOUNT.test('version 15'));
-- });
--
-- await check('refusals carry the API code, and cleanText normalises plain text', async () => {
-- const hit = refusals('A theme', 'and a firearm');
-- assert.strictEqual(hit.status, 400);
-- assert.strictEqual(hit.code, 'exchange.prohibited');
-- assert.ok(hit.message.includes('weapons'));
-- assert.strictEqual(refusals('A theme'), null);
-- assert.strictEqual(cleanText(' a\r\nb\u0000 ', 20), 'a\nb');
-- assert.strictEqual(cleanText('abcdef', 3), 'abc');
-- });
--
-- done();
--})();
ddiff --git a/test/search-relay.test.js b/test/search-relay.test.js
ddeleted file mode 100644
iindex a1915d2..0000000
---- a/test/search-relay.test.js
++++ /dev/null
@@@ -1,70 +0,0 @@
--'use strict';
--/**
-- * The queued Search documents reach OpenVibe.Search (server/app.js, openvibe-publishing/search-relay): with
-- * EXCHANGE_SEARCH_URL set, publishing a listing PUTs its document to Search's owner API with a service token for
-- * openvibe.search, and closing it DELETEs it at the listing's new revision; a delivered row leaves the queue. Without
-- * the URL the relay is off and the rows wait.
-- */
--const assert = require('assert');
--const http = require('http');
--const { boot, check, done } = require('./helpers/boot');
--const fixtures = require('./helpers/fixtures');
--
--function fakeSearch() {
-- const calls = [];
-- const server = http.createServer((req, res) => {
-- let body = '';
-- req.on('data', (c) => { body += c; });
-- req.on('end', () => {
-- calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', body: body ? JSON.parse(body) : null });
-- res.writeHead(200, { 'content-type': 'application/json' });
-- res.end(JSON.stringify({ outcome: 'applied' }));
-- });
-- });
-- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) })));
--}
--
--(async () => {
-- const search = await fakeSearch();
-- const t = await boot({ env: { EXCHANGE_SEARCH_URL: search.url } });
-- try {
-- const alice = t.network.addUser('alice');
-- const l = await fixtures.listed(t, alice, { title: 'A relayed template' });
--
-- await check('a listed listing is PUT to Search with a service token, and the row leaves the queue', async () => {
-- assert.strictEqual(t.ctx.searchRelay.enabled, true);
-- const out = await t.ctx.searchRelay.drain();
-- assert.ok(out.sent >= 1, JSON.stringify(out));
-- const put = search.calls.find((c) => c.method === 'PUT' && c.url === `/api/v1/documents/exchange/listing/${l.id}`);
-- assert.ok(put, `a PUT for ${l.id}: ${JSON.stringify(search.calls.map((c) => `${c.method} ${c.url}`))}`);
-- assert.match(put.auth, /^Bearer \S+/);
-- assert.strictEqual(put.body.canonical_url, `https://openvibe.exchange/listings/${l.slug}`);
-- assert.strictEqual(put.body.title, 'A relayed template');
-- assert.strictEqual((await t.ctx.searchRelay.status()).pending, 0);
-- });
--
-- await check('closing it DELETEs the document at the listing\'s new revision', async () => {
-- const before = search.calls.length;
-- const r = await t.get(`/api/v1/listings/${l.slug}/close`, { as: alice, method: 'POST', json: { reason: 'withdrawn' } });
-- assert.strictEqual(r.status, 200);
-- await t.ctx.searchRelay.drain();
-- const del = search.calls.slice(before).find((c) => c.method === 'DELETE');
-- assert.ok(del, 'a DELETE');
-- assert.strictEqual(del.url, `/api/v1/documents/exchange/listing/${l.id}?revision=${r.json().revision}`);
-- });
-- } finally {
-- await t.close();
-- await search.close();
-- }
--
-- const off = await boot();
-- try {
-- await check('without EXCHANGE_SEARCH_URL the relay is off', async () => {
-- assert.strictEqual(off.ctx.searchRelay.enabled, false);
-- assert.strictEqual(await off.ctx.searchRelay.drain(), null);
-- });
-- } finally {
-- await off.close();
-- }
-- done();
--})();
ddiff --git a/test/search.test.js b/test/search.test.js
ddeleted file mode 100644
iindex 99dc339..0000000
---- a/test/search.test.js
++++ /dev/null
@@@ -1,69 +0,0 @@
--'use strict';
--/**
-- * The Search Fabric document: owner exchange, type listing, id exl_…, canonical /listings/<slug>, title/summary,
-- * facets kind/mode/licence/creator and the passport permissions, authorship human, the listing's revision. A draft
-- * writes nothing; publishing upserts; closing or hiding writes a valid tombstone.
-- */
--const assert = require('assert');
--const contracts = require('openvibe-contracts');
--const { boot, check, done } = require('./helpers/boot');
--const fixtures = require('./helpers/fixtures');
--
--(async () => {
-- const t = await boot();
-- const alice = t.network.addUser('alice');
-- const mod = t.network.addUser('mod', { role: 'global_mod' });
-- try {
-- const l = await fixtures.listed(t, alice, { title: 'A searchable template' });
--
-- await check('publishing writes a valid search.index-document@1', async () => {
-- const row = await t.ctx.s.db.maybe("SELECT topic, payload FROM exchange_outbox WHERE topic = 'exchange.index_document.upserted' ORDER BY created_at DESC LIMIT 1");
-- assert.ok(row, 'an upsert row exists');
-- const doc = row.payload;
-- const v = contracts.validate('search.index-document@1', doc);
-- assert.strictEqual(v.valid, true, JSON.stringify(v.errors));
-- assert.strictEqual(doc.owner, 'exchange');
-- assert.strictEqual(doc.type, 'listing');
-- assert.strictEqual(doc.id, l.id);
-- assert.strictEqual(doc.revision, l.revision);
-- assert.strictEqual(doc.deleted, false);
-- assert.strictEqual(doc.visibility, 'public');
-- assert.strictEqual(doc.publication_state, 'published');
-- assert.strictEqual(doc.canonical_url, `https://openvibe.exchange/listings/${l.slug}`);
-- assert.strictEqual(doc.title, 'A searchable template');
-- assert.strictEqual(doc.facets.kind, 'template');
-- assert.strictEqual(doc.facets.mode, 'free');
-- assert.strictEqual(doc.facets.license, 'CC0-1.0');
-- assert.deepStrictEqual(doc.facets.can_read, ['search.read']);
-- assert.strictEqual(doc.authorship, 'human');
-- assert.ok(doc.body.includes('search.read'));
-- assert.ok(doc.body.includes('Licence: CC0-1.0'));
-- });
--
-- await check('a draft writes no document; closing sends a valid tombstone with the next revision', async () => {
-- const draft = await fixtures.create(t, alice, { title: 'A never-listed theme' });
-- assert.strictEqual((await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE payload->>\'id\' = $1', [draft.id])), 0);
-- const r = await t.get(`/api/v1/listings/${l.slug}/close`, { as: alice, method: 'POST', json: { reason: 'withdrawn' } });
-- assert.strictEqual(r.status, 200);
-- const tomb = await t.ctx.s.db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' ORDER BY created_at DESC LIMIT 1");
-- assert.strictEqual(contracts.validate('search.index-document@1', tomb.payload).valid, true, JSON.stringify(contracts.validate('search.index-document@1', tomb.payload).errors));
-- assert.strictEqual(tomb.payload.deleted, true);
-- assert.strictEqual(tomb.payload.id, l.id);
-- assert.strictEqual(tomb.payload.revision, r.json().revision);
-- });
--
-- await check('a staff hide also writes a tombstone and the public list stays empty', async () => {
-- const l2 = await fixtures.listed(t, alice, { title: 'A hideable search theme' });
-- const r = await t.get(`/api/v1/listings/${l2.slug}/hide`, { as: mod, method: 'POST' });
-- assert.strictEqual(r.status, 200);
-- const row = await t.ctx.s.db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' AND payload->>'id' = $1", [l2.id]);
-- assert.ok(row);
-- assert.strictEqual(row.payload.revision, r.json().revision);
-- const pub = await t.get('/api/v1/listings?limit=50');
-- assert.strictEqual(pub.json().count, 0);
-- });
-- } finally {
-- await t.close();
-- }
-- done();
--})();
ddiff --git a/test/seed-proof.test.js b/test/seed-proof.test.js
ddeleted file mode 100644
iindex a14e3d9..0000000
---- a/test/seed-proof.test.js
++++ /dev/null
@@@ -1,44 +0,0 @@
--'use strict';
--/**
-- * The first proof: a licensed template listed, tried in the sandbox, installed with its passport's grants and
-- * revoked cleanly; a second run changes nothing. The seed uses the same store functions as the API and pages.
-- */
--const assert = require('assert');
--const { boot, check, done } = require('./helpers/boot');
--const { runSeed, SLUG, LICENSE } = require('../scripts/seed-proof');
--
--(async () => {
-- const t = await boot();
-- try {
-- let out = null;
-- await check('the seed lists a CC0 template, tries it, installs and revokes it', async () => {
-- out = await runSeed({ config: t.config, s: t.ctx.s, log: { log() {} } });
-- assert.strictEqual(out.created, true);
-- assert.strictEqual(out.listing.slug, SLUG);
-- assert.strictEqual(out.listing.status, 'listed');
-- assert.strictEqual(out.listing.mode, 'free');
-- assert.strictEqual(out.listing.license, LICENSE);
-- assert.strictEqual(out.listing.tests.verified, true);
-- assert.strictEqual(out.trial.status, 'passed');
-- assert.deepStrictEqual(out.install.passport.reads, ['search.read']);
-- assert.strictEqual(out.revoked.status, 'revoked');
-- assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listing_tests WHERE listing_id = $1 AND status = \'passed\'', [out.listing.id])), 4);
-- assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE topic = 'exchange.index_document.upserted'")), 1);
-- assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings')), 1);
-- });
--
-- await check('the public page and API show it; a second run adds nothing', async () => {
-- assert.strictEqual((await t.get(`/listings/${SLUG}`)).status, 200);
-- const api = await t.get(`/api/v1/listings/${SLUG}`);
-- assert.strictEqual(api.status, 200);
-- assert.strictEqual(api.json().license, LICENSE);
-- const again = await runSeed({ config: t.config, s: t.ctx.s, log: { log() {} } });
-- assert.strictEqual(again.created, false);
-- assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings')), 1);
-- assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_installs')), 1);
-- });
-- } finally {
-- await t.close();
-- }
-- done();
--})();
ddiff --git a/test/sso.test.js b/test/sso.test.js
iindex fd02f77..0511cc7 100644
---- a/test/sso.test.js
++++ b/test/sso.test.js
@@@ -99,7 +99,7 @@ const { sanitizeNext } = require('../server/auth/sso');
});
await check('public pages are complete without JavaScript', async () => {
-- for (const p of ['/', '/listings', '/safety', '/developers', '/submit']) {
++ for (const p of ['/', '/models', '/api', '/sell', '/uncensored', '/ai']) {
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.match(r.text, /<noscript><nav/);
@@@ -108,14 +108,15 @@ const { sanitizeNext } = require('../server/auth/sso');
}
});
-- await check('the API answers /ping and unknown routes are problem+json 404', async () => {
-- const ping = await t.get('/api/v1/ping');
-- assert.strictEqual(ping.status, 200);
-- assert.deepStrictEqual(ping.json(), { ok: true, service: 'exchange' });
-- const gone = await t.get('/api/v1/nope');
-- assert.strictEqual(gone.status, 404);
++ await check('v1’s API answers 410 with a pointer, and unknown routes are problem+json 404', async () => {
++ const gone = await t.get('/api/v1/ping');
++ assert.strictEqual(gone.status, 410);
assert.match(gone.headers.get('content-type'), /application\/problem\+json/);
-- assert.strictEqual(gone.json().code, 'route.not_found');
++ assert.strictEqual(gone.json().code, 'exchange.retired');
++ assert.match(gone.json().detail, /every model, one account|api\.openvibe\.exchange/);
++ const missing = await t.get('/api/nope');
++ assert.strictEqual(missing.status, 404);
++ assert.strictEqual(missing.json().code, 'route.not_found');
});
await check('a FedCM assertion and an app token are not sessions', async () => {