openvibers/openvibe.quest Commits

Quest 0.2.0: the quest log, the events consumer and OpenCoins

OP OpenVibers committed 8 Oct 2026 · ede8ddc6bc · 26 files, +1900 −97

OpenVibe.Quest is the network's shared quest and rewards system: one
quest log across every OpenVibe site.

- A catalog of 12 first-party quests in code, grouped by the site whose
  events each one counts, each with a badge and OpenCoins.
- OpenVibe.Events delivers the domain events the sites already emit to
  POST /internal/events, exactly as OpenVibe.Space does it: raw body,
  parseDelivery requireV2, the openvibe-sdk inbox for once-only
  application, forwarding headers refused, 503 without
  QUEST_EVENTS_SECRET. The person is taken from the field the event's
  own schema defines; an event that names no usr_ subject, a guest or a
  service is ignored.
- quest_progress, quest_completions, quest_badges and the receipts
  (migrations/0002_quest.sql), keyed by the canonical subject and safe
  under a redelivery.
- OpenCoins through OpenVibe.Network's wallet, idempotency_key = the
  completion id: off by default (QUEST_COINS), per-person and
  network-wide daily caps, a failure recorded with its reason and
  retried by a timer. Rewards are badges and OpenCoins — loyalty, never
  money, never Vibes.
- API: GET /api/v1/quests, /quests/:id, /me/quests, and
  /profiles/:subject/badges (id, name and date only).
- Pages: home, a quest, your quest log with plain <progress> bars, the
  rules, and a sitemap listing every quest page. Complete without
  JavaScript.
- Tests: test/events-consumer.test.js and test/quests-api.test.js; the
  mock Network now also serves /internal/identity/resolve and
  /internal/coins/credit, checked as Network checks them.

Changes

ddiff --git a/.env.example b/.env.example
iindex a7ab91c..cf1e318 100644
---- a/.env.example
++++ b/.env.example
@@@ -26,3 +26,20 @@ OV_OAUTH_CLIENT_SECRET=
# optional: defaults to true in production
# COOKIE_SECURE=true
++
++# OpenVibe.Events → Quest (server/events-consumer.js): POST /internal/events. The secret is what signs a delivery
++# (X-OpenVibe-Signature-V2, 32+ characters; comma-separated so one can be rotated in). Unset turns the consumer
++# off (503) and the quest log then never moves. QUEST_EVENTS_URL is OpenVibe.Events' base URL, used once at boot
++# to create the subscriptions for the events the catalog counts; unset leaves them to be created by hand.
++#QUEST_EVENTS_SECRET=
++#QUEST_EVENTS_URL=
++#QUEST_EVENTS_ENDPOINT=
++
++# OpenCoins (server/quests/coins.js). Rewards are badges and OpenCoins — loyalty points, never money, never Vibes.
++# QUEST_COINS is off by default: nothing is credited, a completion's coins are skipped, and the pages say the
++# rewards start soon. 'on' credits through OpenVibe.Network's wallet within the two daily caps, using this
++# service's client credentials. QUEST_COINS_RETRY_MS is how often a failed or unfinished credit is retried.
++QUEST_COINS=off
++#QUEST_COINS_DAILY_CAP=50
++#QUEST_COINS_GLOBAL_DAILY_CAP=5000
++#QUEST_COINS_RETRY_MS=300000
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 646936c..4bd8462 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,29 @@
What changed in OpenVibe.Quest, newest first. Each site also publishes its patch notes at /updates.
++## 0.2.0 — 2026-10-08
++
++- **The product.** One quest log across the whole network:
++ - a catalog of 12 first-party quests in code (`server/quests/catalog.js`), grouped by the site whose events each
++ one counts, each with a badge and OpenCoins;
++ - `POST /internal/events` (`server/events-consumer.js`), exactly as OpenVibe.Space does it — raw body, signature
++ v2, the `openvibe-sdk` inbox for once-only application, forwarding headers refused, 503 without
++ `QUEST_EVENTS_SECRET` — with the person taken from the field each event's own schema defines
++ (`server/quests/apply.js`) and an ignored outcome for anything that names nobody Quest may credit;
++ - `quest_progress`, `quest_completions`, `quest_badges` and the Events receipts (`migrations/0002_quest.sql`), all
++ keyed by the canonical subject and safe under a redelivery;
++ - OpenCoins through OpenVibe.Network's wallet (`server/quests/coins.js`): `idempotency_key` is the completion id,
++ `QUEST_COINS` is off by default, per-person and network-wide daily caps, a failure recorded with its reason and
++ retried by a timer;
++ - the API: `GET /api/v1/quests`, `/quests/:id`, `/me/quests`, `/profiles/:subject/badges`;
++ - pages: home, a quest, your quest log (plain `<progress>` bars), the rules, and a sitemap that lists every quest
++ page — all server-rendered and complete without JavaScript;
++ - the test suite grew two files: `test/events-consumer.test.js` and `test/quests-api.test.js`, and the mock
++ Network now serves `/internal/identity/resolve` and `/internal/coins/credit` the way Network checks them.
++- The home page grew by about 9 KB with the catalog and the event list; the perf budget was raised to match
++ (`test/perf-budget.test.js`, measured 34.0 KB raw / 7.7 KB brotli).
++- nginx: `/internal/` is answered 404 from outside; Events posts to the loopback address directly.
++
## 0.1.0 — 2026-10-08
- **First release:** the service starts from the OpenVibe skeleton — sign-in with OpenVibe.Network (OAuth 2 + PKCE), server-rendered pages through the OpenVibe Frame, the `/api/v1` mount with per-caller limits, crawl artifacts, PostgreSQL migrations, the deploy files and the test suite.
ddiff --git a/README.md b/README.md
iindex 34730bc..949af82 100644
---- a/README.md
++++ b/README.md
@@@ -2,48 +2,96 @@
> Quests across the whole network.
--**Status:** skeleton. It runs, it is tested, and its pages and API are live — but the product itself is not written yet.
++**Status:** first release. The quest log, the events consumer, the OpenCoins crediting and the pages are written and
++tested; it is not deployed, and OpenCoins are off until the feature is turned on.
**Domain:** `openvibe.quest` · **Port:** 4980 · **Service id:** `quest` · **Env prefix:** `QUEST`
**License:** AGPL-3.0 (same as every OpenVibe service).
--## What is already here
++## What it is
++
++One quest log across every OpenVibe site. A quest counts something a person really did somewhere in the network —
++went live, started a thread, said hello in a public chat room, edited a wiki page — and pays a **badge** on their
++profile. When OpenCoins are on it also credits **OpenCoins** through OpenVibe.Network's wallet. OpenCoins are
++loyalty points, **never money**, and they are **never Vibes** (Vibes are real money and belong to the sites that
++pay them out); nothing here reads or moves money.
++
++Quest runs no activities of its own. It listens to the domain events the sites already emit and nothing else: no
++page views, no clicks, no watch time, and no content a person wrote — not a message's text, not a thread's title.
++`/how-it-works` prints the complete list of what is listened to, generated from the same catalog the consumer
++subscribes to.
++
++## What works
| Piece | Where | What it does |
|---|---|---|
--| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` |
--| App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler |
--| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `quest_at` / `quest_rt` cookies; `/auth/me` for the shared navbar |
--| Signing key | [server/auth/keys.js](server/auth/keys.js) | The Network JWKS through `openvibe-sdk/auth`, kept fresh, verified offline |
--| Who is calling | [server/http/principal.js](server/http/principal.js) | `req.principal`: a person, an app/agent/service with a capability, or anonymous |
--| API | [server/http/api.js](server/http/api.js) | `GET /api/v1/ping`; the router, the problem+json errors and the guards are wired for the product's routes |
--| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | The home page and `/updates`, server-rendered through `openvibe-shared/shell`, readable without JavaScript |
--| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml`, `llms.txt`, `llms-full.txt` and the home page's JSON-LD |
--| Limits | [server/http/caller-limits.js](server/http/caller-limits.js), [deploy/nginx/](deploy/nginx/) | Per-caller limits at the API and per-address limits at nginx |
--| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |
--| Database | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`; `NNNN_*.sql` applied at boot; PGlite in development |
--| Process | [server/index.js](server/index.js) | Listens on `PORT`, and stops gracefully through `openvibe-sdk/service` |
--| Deploy | [deploy/nginx/openvibe.quest.conf](deploy/nginx/openvibe.quest.conf), [deploy/systemd/openvibe-quest.service](deploy/systemd/openvibe-quest.service) | nginx vhost and systemd unit (port 4980, `/opt/openvibe.quest`, `/etc/openvibe/quest.env`) |
--| Tests | [test/](test/) | `npm test`: every `test/*.test.js` in its own process, on a temp PGlite database with an in-process mock of OpenVibe.Network |
++| Catalog | [server/quests/catalog.js](server/quests/catalog.js) | 12 first-party quests in code, one per real thing a person can do, plus the whole list of events Quest listens to (and the one it does not). A quest may not count an event the catalog does not document: that fails at load |
++| Events consumer | [server/events-consumer.js](server/events-consumer.js) | `POST /internal/events`, exactly as OpenVibe.Space does it: raw body, `parseDelivery(…, { requireV2: true })`, the `openvibe-sdk` inbox for once-only application, forwarding headers refused, 503 without `QUEST_EVENTS_SECRET`. It subscribes to the catalog's events at boot (idempotent, off without `QUEST_EVENTS_URL`) |
++| What moves a quest | [server/quests/apply.js](server/quests/apply.js) | The person is taken from the field the event's own schema defines (`channel.subject`, `follower`, `author`, `user_subject`, `player.subject`, `owner`; the envelope actor only where the schema says the actor *is* that person). No subject, a `gst_` guest, or a service/app/mod principal → ignored, nothing written |
++| Quest log | [server/quests/store.js](server/quests/store.js), [migrations/0002_quest.sql](migrations/0002_quest.sql) | Progress per step, one completion per person per quest (unique), one badge per person, and the Events receipts. Two deliveries of the same event change nothing; so does a replay of the whole request |
++| OpenCoins | [server/quests/coins.js](server/quests/coins.js) | Credits a completion through OpenVibe.Network's wallet with `idempotency_key = <completion id>`. Off by default, per-person and network-wide daily caps, failures recorded with their reason and retried by a timer |
++| API | [server/http/api.js](server/http/api.js) | `GET /quests`, `GET /quests/:id`, `GET /me/quests`, `GET /profiles/:subject/badges` (see the table below) |
++| Pages | [server/http/pages.js](server/http/pages.js), [server/render/quests.js](server/render/quests.js) | Home (the quests grouped by site, how progress works), a quest, your quest log (plain `<progress>` bars), the rules, the update log — all server-rendered, all readable without JavaScript |
++| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt` (the private log disallowed), `sitemap.xml` (every quest page), `llms.txt`, `llms-full.txt`, JSON-LD |
++| Foundation | `server/app.js`, `server/auth/`, `server/db.js`, `server/observability.js`, `deploy/` | The skeleton's plumbing, unchanged: Network SSO with PKCE, per-caller limits, PostgreSQL migrations, truthful `/api/ready`, nginx and systemd files |
## API
| Route | Who | |
|---|---|---|
| `GET /api/v1/ping` | anyone | `{ ok: true, service: "quest" }` |
++| `GET /api/v1/quests` | anyone | the catalog grouped by site (`sites[].quests`), a flat `quests[]`, and whether OpenCoins are live with their caps |
++| `GET /api/v1/quests/:id` | anyone | one quest: its steps, its events, its reward |
++| `GET /api/v1/me/quests` | a signed-in person | your log: progress per step, completions (with what happened to their coins) and your badges |
++| `GET /api/v1/profiles/:subject/badges` | anyone | a person's badges: id, name and date — nothing else, ever |
++
++Public reads need no token. `/me/quests` takes a person's Network token as a Bearer or this site's session, and
++answers only that person's rows. Errors are RFC 9457 `application/problem+json` with a stable `code`. Requests are
++counted per caller (a person as themselves, anyone else by address) with the default read numbers —
++[server/http/caller-limits.js](server/http/caller-limits.js) `BUDGETS` is empty because no route is expensive yet.
++
++## How progress happens
++
++OpenVibe.Events delivers an event when a site really does something. Quest counts these, and only these:
++
++| Event | From | The person is |
++|---|---|---|
++| `live.stream.started` | OpenVibe.Live | the channel's user subject (else the envelope actor) |
++| `network.follow.created` | OpenVibe.Network | the follower |
++| `community.thread.created` / `community.post.created` / `community.comment.created` | OpenVibe.Community | the author |
++| `space.thread.created` | OpenVibe.Space | the author |
++| `chat.room.message.created` | OpenVibe.Chat | `payload.user_subject` (public rooms only; a DM never reaches Quest) |
++| `wiki.revision.created` | OpenVibe.Wiki | the author (a service edit is ignored) |
++| `games.player.joined` | OpenVibe.Games | the player's account subject (a guest is ignored) |
++| `tools.job.created` | OpenVibe.Tools | the owner (an anonymous session is ignored) |
++
++A quest completes when every step reaches its count. The completion, its badge and (when OpenCoins are on) its
++coins are recorded once; a redelivery of the same event, or the whole request again, changes nothing.
--Everything else the site serves is a page. The product adds its routes in [server/http/api.js](server/http/api.js), naming
--each route's capability in [server/http/principal.js](server/http/principal.js) (`CAPABILITIES`) and its numbers in
--[server/http/caller-limits.js](server/http/caller-limits.js) (`BUDGETS`).
++## Data sources and their terms
++
++| Source | Used for | Terms as applied |
++|---|---|---|
++| **OpenVibe.Events** (`openvibe.events`) | the domain events that move the log, `POST /internal/events`, loopback only | Signed deliveries only: `X-OpenVibe-Signature-V2` (HMAC-SHA256 over `<timestamp>.<raw body>`), a 300-second replay window, `requireV2`, the secret from `QUEST_EVENTS_SECRET` (32+ characters, comma-separated for rotation). A delivery that carries a forwarding header is refused: nginx never proxies this route. Once-only application is the `openvibe-sdk` inbox, in the same transaction as the writes |
++| **openvibe-contracts** v0.115.0 | the shape of every event and of the two Network calls | Every field read is the one the schema at `contracts/events/payloads/<type>.v1.json` defines; no field is inferred and nothing about a person is taken from a name or from the request |
++| **OpenVibe.Network** (`openvibe.network`) | resolving a subject to its wallet id, and crediting OpenCoins | `GET /internal/identity/resolve` (capability `identity.subject.resolve`, `identity.resolve-result@1`) gives `network_user_id`; `POST /internal/coins/credit` (capability `network.coins.credit`, body `network.coins-change-request@1`, answer `network.coins-balance-result@1`) credits, with `app_id: quest` — a service token may only name its own app id. Both use this service's own client credentials over the internal URL; no token is logged |
++| **OpenVibe sites** (Live, Community, Space, Chat, Wiki, Games, Tools, Network) | nothing is fetched from them | Quest makes no outbound request to any site, and no URL from a request ever decides one: the only host it calls is `OV_NETWORK_INTERNAL_URL` |
--**Who can call it:** a person, with their Network token as a Bearer or this site's session; or an app, agent or service
--with a Network token for audience `openvibe.quest` that holds the route's capability. A write made with the session cookie
--must come from `openvibe.quest` itself. **Errors** are RFC 9457 `application/problem+json` with a stable `code`.
++Rewards are badges and OpenCoins. Nothing here is money, and nothing here is Vibes.
## Configuration
--See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `quest` OAuth client on the
--Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the
--Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`).
++See [.env.example](.env.example) for every name. Required in production: `OV_OAUTH_CLIENT_SECRET`, `BASE_URL`,
++`DATABASE_URL` and `DATABASE_DIRECT_URL`. The product adds:
++
++| Variable | Default | What it does |
++|---|---|---|
++| `QUEST_EVENTS_SECRET` | unset | signs the Events deliveries (32+ characters; comma-separated for rotation). Unset turns the consumer off (503) and the quest log then never moves |
++| `QUEST_EVENTS_URL` | unset | OpenVibe.Events' base URL, used once at boot to create the subscriptions for the catalog's events |
++| `QUEST_EVENTS_ENDPOINT` | `http://127.0.0.1:<port>/internal/events` | the endpoint the subscriptions name |
++| `QUEST_COINS` | `off` | `on` credits rewards through Network's wallet; `off` records completions with `coins_state=skipped` and the pages say the rewards start soon |
++| `QUEST_COINS_DAILY_CAP` | `50` | OpenCoins per person per UTC day; over it a completion is skipped with the reason |
++| `QUEST_COINS_GLOBAL_DAILY_CAP` | `5000` | OpenCoins for everyone together per UTC day |
++| `QUEST_COINS_RETRY_MS` | `300000` | how often a failed or unfinished credit is retried (only while `QUEST_COINS=on`) |
## Development
@@@ -56,12 +104,20 @@ fnm exec --using=22 npm run dev # http://localhost:4980
Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run
test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
++The mock Network in [test/helpers/mocks.js](test/helpers/mocks.js) also serves `/internal/identity/resolve` and
++`/internal/coins/credit`, checked as Network checks them (a `svc:quest` token with the capability, its own app id,
++idempotent by key), so the OpenCoins tests need no real wallet.
++
## Deploy (for the lead)
- **Deploy:** `sudo ovhost deploy quest` on the host (git checkout at `/opt/openvibe.quest`, unit
`openvibe-quest.service` on 127.0.0.1:4980, env `/etc/openvibe/quest.env`, database `ov_quest` on the data role).
- **nginx:** [deploy/nginx/openvibe.quest.conf](deploy/nginx/openvibe.quest.conf), installed with `ov-vhost-install`.
++ `/internal/` is answered 404 from outside; Events posts to the loopback address directly.
- **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart.
++- **Before OpenCoins can be turned on**, OpenVibe.Network must grant the `quest` OAuth client
++ `identity.subject.resolve` and `network.coins.credit` (its service-principal grants), and `QUEST_EVENTS_SECRET`
++ and a subscription for the catalog's events must exist at OpenVibe.Events.
- Register the service and its capabilities in **OpenVibe.Contracts** (`contracts-service: quest` in CI) and with
**OpenVibe.Services** before the first deploy.
@@@ -70,10 +126,14 @@ test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/work
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.
--- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.
--- Request bodies are never logged.
--- Nothing in a request may decide a URL this service fetches: a caller's URL goes to OpenVibe.Tools, whose own guard
-- decides what may be fetched.
++- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is
++ logged. A wallet or identity failure is recorded as Network's own short code, never as a token or a body.
++- Request bodies are never logged. The Events route is the one place a body is read raw, and only to verify its
++ signature.
++- Nothing in a request may decide a URL this service fetches: the only host Quest calls is the configured
++ `OV_NETWORK_INTERNAL_URL`.
++- A person's log is theirs: `/me/quests` answers their rows only, and the public badge endpoint answers an id, a
++ name and a date and nothing else.
---
ddiff --git a/STATUS.json b/STATUS.json
iindex 777c5f2..9adc181 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,8 +1,8 @@
{
-- "stage": "skeleton",
++ "stage": "alpha",
"code": true,
"wave": "W20",
-- "plan": "OpenVibe.Quest starts from the OpenVibe service skeleton; the product's own plan is written here when it is",
++ "plan": "OpenVibe.Quest — the network's shared quest and rewards system: one quest log across every OpenVibe site, rewards as badges and OpenCoins (loyalty, never money), community-made quests later",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4980",
"domain": "openvibe.quest",
@@@ -14,33 +14,38 @@
"openvibe-sdk v0.35.2",
"openvibe-shared v2.14.0"
],
-- "summary": "OpenVibe.Quest — Quests across the whole network. A new OpenVibe service on the shared skeleton, not deployed yet: sign-in with OpenVibe.Network (OAuth 2 + PKCE, httpOnly session cookies), server-rendered pages through the OpenVibe Frame, the /api/v1 mount with per-caller limits, crawl artifacts, PostgreSQL migrations applied at boot, the nginx and systemd deploy files and a test suite on PGlite with a mock Network.",
++ "summary": "OpenVibe.Quest: one quest log across every OpenVibe site. OpenVibe.Events delivers the domain events the sites already emit to POST /internal/events (signed v2, once-only through the openvibe-sdk inbox), the person taken from the field each event's own schema defines, and a progress count, a completion and a badge move because of it. 12 first-party quests in code, grouped by the site whose events each counts. Rewards are badges and OpenCoins — credited through OpenVibe.Network's wallet, off by default (QUEST_COINS), with per-person and network-wide daily caps, failures recorded and retried. Public API and server-rendered pages, complete without JavaScript. Not deployed; OpenCoins are off until Network grants the two capabilities.",
"works": [
-- "Sign-in with OpenVibe.Network (PKCE S256, server-side code exchange, httpOnly session cookies)",
-- "Server-rendered pages: the home page and /updates, through the OpenVibe Frame, complete without JavaScript",
-- "Crawl artifacts: robots.txt, sitemap.xml, llms.txt, llms-full.txt and the home page's JSON-LD",
-- "The API mount at /api/v1 with the problem+json error shape, the per-address limits and the per-caller limits",
-- "Capability verification for app, agent and service tokens (audience openvibe.quest) and for a person's own token",
-- "Truthful readiness (/api/ready: the database is required) and Prometheus metrics on loopback",
-- "PostgreSQL with migrations/NNNN_*.sql (openvibe-sdk/db); PGlite in development",
-- "Deploy files: deploy/nginx/openvibe.quest.conf and deploy/systemd/openvibe-quest.service",
-- "Tests on a temp PGlite database with an in-process mock of OpenVibe.Network"
++ "The quest catalog in code (12 quests, 8 sites) and the whole list of events it listens to, with a load-time check that no quest counts an undocumented event",
++ "POST /internal/events exactly as OpenVibe.Space does it: raw body, parseDelivery requireV2, the openvibe-sdk inbox, forwarding headers refused, 503 without QUEST_EVENTS_SECRET",
++ "Subscriptions created at boot for the catalog's events (idempotent, off without QUEST_EVENTS_URL / QUEST_EVENTS_SECRET)",
++ "The person taken from the event's own schema field (channel.subject, follower, author, user_subject, player.subject, owner); an event with no usr_ subject, a guest or a service is ignored",
++ "Progress per step, one completion per person per quest and one badge per person, all once-only under a redelivery",
++ "OpenCoins through OpenVibe.Network's wallet (network.coins.credit, app_id quest), idempotency_key = the completion id, subject resolved to the wallet's user id through identity.resolve",
++ "QUEST_COINS off by default (coins_state=skipped, the pages say the rewards start soon); per-person (50) and network-wide (5000) daily caps; a failure recorded with its reason and retried by a timer",
++ "GET /api/v1/quests, /quests/:id, /me/quests (a signed-in person's own log), /profiles/:subject/badges (id, name and date only)",
++ "Pages: home (the quests grouped by site, how progress works), a quest, your quest log (plain <progress> bars), the rules, the update log; robots, sitemap with every quest page, llms.txt, llms-full.txt",
++ "Tests on temp PGlite databases with a mock Network that also serves the wallet: signature refusal, idempotent replay, counting to completion, badge once, coins off, coins on with caps and a retry, events with no subject ignored, /me requires sign-in, hostile titles escaped"
],
"notYet": [
-- "The product itself: its tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js",
-- "Its service and capability manifests in openvibe-contracts (contracts-service is quest)",
-- "A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host"
++ "A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host, and its capabilities are not in openvibe-contracts yet (no quest.* manifest exists)",
++ "OpenCoins are off in production until OpenVibe.Network grants the quest client identity.subject.resolve and network.coins.credit",
++ "Community-made quests (streamers, communities, mods): they need moderation, which this release does not have",
++ "Repeatable quests and seasonal resets: every quest completes once per person",
++ "Account deletion and merges (ADR-033, ADR-029): quest_progress, quest_completions and quest_badges rows are not removed or re-keyed when a Network account is deleted or merged",
++ "Quest chains, leaderboards and per-community quests; a streamer's getting-started steps still live on OpenVibe.Live"
],
"capabilitiesRegistered": false,
"serviceManifestRegistered": false,
"deployed": false,
"placeholderInSites": false,
"features": [
-- "Network sign-in",
++ "quest catalog",
++ "OpenVibe.Events consumer",
++ "OpenCoins through Network's wallet",
++ "public API",
"server-rendered pages",
-- "api mount",
-- "limits and readiness",
-- "deploy files"
++ "crawl artifacts"
],
"updated": "2026-10-08"
}
ddiff --git a/deploy/nginx/openvibe.quest.conf b/deploy/nginx/openvibe.quest.conf
iindex bfc9889..8ef9fa8 100644
---- a/deploy/nginx/openvibe.quest.conf
++++ b/deploy/nginx/openvibe.quest.conf
@@@ -99,6 +99,12 @@ server {
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4980/metrics) ──
location = /metrics { return 404; }
++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
++ # Events posts to http://127.0.0.1:4980/internal/events directly. Never proxied from outside; and because
++ # every proxied request here carries X-Forwarded-For, a delivery that did come through nginx would be
++ # refused by the consumer anyway (server/events-consumer.js).
++ location /internal/ { return 404; }
++
# ── Pages, docs, robots.txt, sitemap, static assets ─────
location / {
proxy_pass http://127.0.0.1:4980;
ddiff --git a/migrations/0002_quest.sql b/migrations/0002_quest.sql
nnew file mode 100644
iindex 0000000..32e6b8b
---- /dev/null
++++ b/migrations/0002_quest.sql
@@@ -0,0 +1,64 @@
++-- phase: expand
++-- OpenVibe.Quest: the quest log. Applied at boot by openvibe-sdk/db (NNNN_*.sql, in order).
++-- Nothing written here is ever a credential, a key or a token.
++--
++-- quest_progress one row per (person, quest, step): how far along they are
++-- quest_completions one row per (person, quest): the completion, its badge and its OpenCoins
++-- quest_badges one row per (person, badge): a badge is awarded once and never taken back
++-- quest_event_inbox the receipts of the OpenVibe.Events deliveries this service applied
++--
++-- A subject is a canonical usr_… id (identity.subject-ref@1); never a service-local id.
++--
++-- Coins: OpenCoins are loyalty points, never money. coins_state is
++-- pending the completion happened and a credit is still to be made
++-- credited the Network wallet took the credit (coins_at is when)
++-- skipped no credit will be made (QUEST_COINS is off, or a daily cap was reached; coins_error says which)
++-- failed the wallet refused or was unreachable (coins_error says why; the retry timer comes back to it)
++--
++-- Applying one Events delivery is idempotent twice over: quest_event_inbox claims (consumer, event_id) in the
++-- same transaction as the writes, and UNIQUE (subject, quest_id) makes a completion happen once.
++
++CREATE TABLE quest_progress (
++ subject text COLLATE "C" NOT NULL, -- usr_<ULID>
++ quest_id text COLLATE "C" NOT NULL, -- the catalog's slug
++ step integer NOT NULL, -- the index of the step in the quest
++ count integer NOT NULL DEFAULT 0, -- events seen for this step, capped at the step's target
++ updated_at text COLLATE "C" NOT NULL, -- ISO 8601 UTC
++ PRIMARY KEY (subject, quest_id, step)
++);
++
++CREATE TABLE quest_completions (
++ id text COLLATE "C" PRIMARY KEY, -- qcp_<ULID>
++ subject text COLLATE "C" NOT NULL,
++ quest_id text COLLATE "C" NOT NULL,
++ completed_at text COLLATE "C" NOT NULL,
++ coins integer NOT NULL DEFAULT 0, -- what the catalog promises, never more
++ coins_state text COLLATE "C" NOT NULL, -- pending | credited | skipped | failed
++ coins_error text, -- why it was skipped or failed; null when credited
++ coins_at text COLLATE "C", -- when the credit landed (the day counts against the caps)
++ coins_attempts integer NOT NULL DEFAULT 0,
++ coins_balance integer, -- the wallet's balance after a credit, as Network answered it
++ CONSTRAINT quest_completions_state CHECK (coins_state IN ('pending', 'credited', 'skipped', 'failed')),
++ CONSTRAINT quest_completions_once UNIQUE (subject, quest_id)
++);
++-- The retry timer's queue: completions whose credit is still owed.
++CREATE INDEX quest_completions_owed ON quest_completions (completed_at) WHERE coins_state IN ('pending', 'failed');
++-- The daily caps sum the coins that landed today.
++CREATE INDEX quest_completions_coins_day ON quest_completions (coins_at) WHERE coins_state = 'credited';
++
++CREATE TABLE quest_badges (
++ subject text COLLATE "C" NOT NULL,
++ badge_id text COLLATE "C" NOT NULL, -- the catalog's badge id
++ quest_id text COLLATE "C" NOT NULL,
++ awarded_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (subject, badge_id)
++);
++CREATE INDEX quest_badges_by_subject ON quest_badges (subject, awarded_at DESC);
++
++-- The openvibe-sdk inbox (inboxSchema('quest_event_inbox')): the receipt of every delivery this service applied.
++CREATE TABLE IF NOT EXISTS quest_event_inbox (
++ consumer text NOT NULL,
++ event_id text NOT NULL,
++ processed_at bigint NOT NULL,
++ PRIMARY KEY (consumer, event_id)
++);
ddiff --git a/package.json b/package.json
iindex cabc1da..5dfaae1 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-quest",
-- "version": "0.1.0",
++ "version": "0.2.0",
"private": true,
"description": "OpenVibe.Quest — Quests across the whole network.",
"main": "server/index.js",
ddiff --git a/public/css/app.css b/public/css/app.css
iindex ef7388f..3cfc09d 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -42,3 +42,24 @@ form.inline { display: inline; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
button.sc-btn { background: transparent; }
button.sc-btn.sc-primary { border: 0; }
++
++/* ── OpenVibe.Quest: the quest pages (server/http/pages.js, server/render/quests.js) ──
++ Structure only — the design pass comes later. Every class below is one the pages render. */
++p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 780px; }
++.site-group { margin: 1.2rem 0; }
++.site-group small { color: var(--text-muted, #7386a3); font-weight: 400; }
++.quest-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .7rem; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
++.quest { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: .7rem .9rem; background: var(--bg-card, #131c2e); }
++.quest-title { font-weight: 650; }
++.quest-why { margin: .25rem 0; color: var(--text-secondary, #96a7c2); font-size: .93rem; }
++.quest-steps, .quest-reward { margin: .3rem 0; font-size: .87rem; }
++.quest-steps .step { display: inline-block; margin-right: .6rem; }
++.steps { margin: .4rem 0; }
++.done { color: var(--success, #22c55e); font-weight: 700; }
++.progress { display: grid; grid-template-columns: minmax(8rem, 1fr) 2fr auto; gap: .5rem; align-items: center; margin: .3rem 0; font-size: .9rem; }
++.progress progress { width: 100%; }
++.progress-count { color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
++.badges { list-style: none; display: flex; flex-wrap: wrap; gap: .6rem; padding: 0; }
++.badges li { border: 1px solid var(--border-light, #2c3d5c); border-radius: 999px; padding: .2rem .8rem; }
++.badges small { color: var(--text-muted, #7386a3); }
++.quest-block { margin: .8rem 0; }
ddiff --git a/server/app.js b/server/app.js
iindex 4410307..6c55136 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -28,6 +28,8 @@ const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
++const { createCoins } = require('./quests/coins');
++const { createEventsConsumer } = require('./events-consumer');
const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { assetVersion, send } = require('./render/layout');
@@@ -49,7 +51,9 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
-- const ctx = { config, s, keys, sso, principal, log };
++ const coins = createCoins({ config, s, fetchImpl, log });
++ const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, now: s.now, log });
++ const ctx = { config, s, keys, sso, principal, coins, eventsConsumer, log };
const app = express();
app.disable('x-powered-by');
@@@ -89,7 +93,7 @@ async function createApp(opts = {}) {
app.use(cookieParser());
// ── Machine endpoints ───────────────────────────────────
-- app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-quest', version: VERSION }));
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-quest', version: VERSION, events: eventsConsumer.stats() }));
release.mount(app, { registry: metrics.registry });
const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
app.get('/api/ready', readiness.handler);
@@@ -102,6 +106,9 @@ async function createApp(opts = {}) {
app.use('/auth', sso.routes());
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'quest', service: 'quest', host: 'openvibe.quest', name: 'OpenVibe.Quest', profile: 'ugc' })); }
++ // ── OpenVibe.Events → the quest log (loopback only; the raw body is the signature's) ──
++ app.use('/internal/events', eventsConsumer.router);
++
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use(express.static(PUBLIC_DIR, {
ddiff --git a/server/config.js b/server/config.js
iindex 7d1a874..fc98200 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -52,6 +52,26 @@ function load(env = process.env) {
sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++
++ // OpenVibe.Events → this service (server/events-consumer.js). The secret is what signs a delivery
++ // (comma-separated for rotation, 32+ characters each); unset turns the consumer off (503) and Quest
++ // then simply never moves. The url is where subscriptions are created at boot, off when unset.
++ events: {
++ secrets: String(env.QUEST_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
++ url: trim(env.QUEST_EVENTS_URL || env.EVENTS_URL || ''),
++ endpoint: env.QUEST_EVENTS_ENDPOINT || '',
++ },
++
++ // OpenCoins (server/quests/coins.js). Rewards are badges and OpenCoins — loyalty points, never money, and
++ // never Vibes. QUEST_COINS is off by default: with 'off' nothing is credited, a completion's coins are
++ // 'skipped' and the pages say the rewards start soon. 'on' credits through OpenVibe.Network's wallet
++ // within the two daily caps. retryMs is how often a failed or unfinished credit is retried.
++ coins: {
++ mode: String(env.QUEST_COINS || 'off').toLowerCase() === 'on' ? 'on' : 'off',
++ dailyCap: Math.max(0, int(env.QUEST_COINS_DAILY_CAP, 50)),
++ globalDailyCap: Math.max(0, int(env.QUEST_COINS_GLOBAL_DAILY_CAP, 5000)),
++ retryMs: Math.max(10_000, int(env.QUEST_COINS_RETRY_MS, 300_000)),
++ },
};
}
ddiff --git a/server/events-consumer.js b/server/events-consumer.js
nnew file mode 100644
iindex 0000000..b4a3852
---- /dev/null
++++ b/server/events-consumer.js
@@@ -0,0 +1,118 @@
++'use strict';
++
++/**
++ * OpenVibe.Events → Quest: POST /internal/events, the endpoint of this service's Events subscriptions
++ * (consumer `quest`). The quest log moves because the sites say what really happened — never because a page was
++ * loaded or a button was pressed here:
++ *
++ * live.stream.started a stream went live on OpenVibe.Live (../../quests/catalog.js 'go live once',
++ * 'stream three times')
++ * network.follow.created somebody followed a channel ('follow someone')
++ * community.thread.created a forum thread was started ('start a forum thread', 'settle in')
++ * community.post.created a reply was posted ('reply in a thread')
++ * community.comment.created a comment was left ('comment on a post')
++ * space.thread.created a thread was started in a Space ('start a Space thread')
++ * chat.room.message.created a message was posted in a public room ('say hello in chat', 'settle in')
++ * wiki.revision.created a wiki page got a new revision ('improve a wiki page')
++ * games.player.joined a character entered a world ('play a game')
++ * tools.job.created a tool job was accepted ('use a tool')
++ *
++ * Which field carries the person, and what is ignored, is ./quests/apply.js.
++ *
++ * Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same transaction as the writes. A
++ * replay of the same event is answered 200 with duplicate:true and changes nothing. Signature v2 only
++ * (parseDelivery requireV2) under QUEST_EVENTS_SECRET (comma-separated for rotation, 32+ characters each); unset
++ * = 503. Loopback only: a request that carries a forwarding header came through nginx and is refused.
++ */
++const express = require('express');
++const { http, serviceAuth } = require('openvibe-contracts');
++const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
++const catalog = require('./quests/catalog');
++const { apply } = require('./quests/apply');
++
++const CONSUMER = 'quest';
++const TOPICS = catalog.EVENT_TYPES;
++const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createEventsConsumer({ db, s, secrets = [], coins = null, now = () => Date.now(), log = console }) {
++ const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
++ // Receipts (quest_event_inbox) are in migrations/0002_quest.sql.
++ const inbox = createPgInbox(db, { table: 'quest_event_inbox', now });
++ const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, completed: 0, refused: 0, failed: 0, last_at: null };
++ const router = express.Router();
++
++ router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
++ const ctx = http.requestContext(req.headers);
++ const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });
++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return problem(403, 'quest.internal_only', 'internal route');
++ if (!keys.length) return problem(503, 'quest.events_disabled', 'QUEST_EVENTS_SECRET is not set');
++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
++ let delivery = null;
++ for (const secret of keys) { delivery = parseDelivery(raw, req.headers, secret, { requireV2: true, now: now() }); if (delivery) break; }
++ if (!delivery) { stats.refused++; return problem(401, 'quest.bad_signature', 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window'); }
++ const event = delivery.event;
++ if (!event || !EVENT_ID_RE.test(String(event.event_id || '')) || typeof event.event_type !== 'string') { stats.refused++; return problem(400, 'quest.bad_delivery', 'body must be { event: <envelope>, seq }'); }
++ stats.received++; stats.last_at = new Date(now()).toISOString();
++
++ let outcome;
++ try {
++ const r = await inbox.once(CONSUMER, event.event_id, (t) => apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId }));
++ if (r.duplicate) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }
++ outcome = r.result;
++ } catch (err) {
++ stats.failed++;
++ log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
++ return problem(500, 'quest.event_failed', 'processing failed; it will be retried');
++ }
++
++ if (outcome.outcome === 'ignored:subject' || outcome.outcome === 'ignored:type') stats.ignored++;
++ else stats.applied++;
++ stats.completed += outcome.completed.length;
++ // OpenCoins are settled after the transaction: the completion is committed first, so a wallet outage
++ // cannot lose (or roll back) a completion the person really earned. A failure is recorded and retried.
++ if (coins) for (const completion of outcome.completed) await coins.settle(completion);
++ return res.json({
++ event_id: event.event_id, duplicate: false, outcome: outcome.outcome,
++ completed: outcome.completed.map((c) => c.id),
++ });
++ });
++
++ return { router, stats: () => ({ ...stats, enabled: keys.length > 0 }) };
++}
++
++/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */
++function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console }) {
++ if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.QUEST_EVENTS_SUBSCRIBE === '0') return null;
++ const base = String(eventsUrl).replace(/\/+$/, '');
++ const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;
++ const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.events', scope: 'events.subscription.manage', fetchImpl });
++ const call = async (method, path, body) => {
++ const res = await fetchImpl(`${base}${path}`, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });
++ const json = await res.json().catch(() => ({}));
++ return { status: res.status, ok: res.ok, body: json };
++ };
++ const attempt = async () => {
++ const listed = await call('GET', '/api/v1/subscriptions');
++ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
++ const mine = (listed.body.subscriptions || []).filter((x) => x.endpoint === endpoint);
++ for (const topic of TOPICS) {
++ if (mine.some((x) => x.topic_pattern === topic)) continue;
++ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
++ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
++ if (r.ok) log.log(`[Events consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);
++ }
++ };
++ const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];
++ let i = 0;
++ let timer = null;
++ let stopped = false;
++ const run = () => { timer = null; if (stopped) return; attempt().catch((err) => {
++ if (stopped) return;
++ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Events consumer] subscriptions not created:', err.message);
++ }); };
++ timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();
++ // Graceful stop: no further attempts (they run again at the next start).
++ return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
++}
++
++module.exports = { createEventsConsumer, startSubscriptions, CONSUMER, TOPICS };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a521bca..5ea7dba 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -3,28 +3,125 @@
/**
* /api/v1 — OpenVibe.Quest's API.
*
-- * GET /ping public liveness: { ok: true, service }
++ * GET /ping public liveness: { ok: true, service }
++ * GET /quests public the catalog, grouped by site, with each reward
++ * GET /quests/:id public one quest
++ * GET /me/quests a person your quest log: progress, completions, badges
++ * GET /profiles/:subject/badges public a person's badges — ids, names and dates, nothing else
*
-- * The product adds its own routes here. Every route that reads or changes a person's data names the capability it
-- * needs (`principal.requireCapability('<quest>.thing.read')`, listed in CAPABILITIES in ./principal.js) and takes
-- * its per-caller numbers (`limits.reads('<quest>.thing.read')` or `limits.budget('<name>')`, ./caller-limits.js).
-- * A refusal is an RFC 9457 problem+json with a stable code.
++ * Public reads need no token and are counted per caller (a person is themselves, anyone else by address) with
++ * the default read numbers; there is no expensive route yet, so server/http/caller-limits.js BUDGETS stays empty.
++ * /me/quests is a person's own log: it needs a signed-in person (a Network token or this site's session) and
++ * shows only their own rows. A refusal is an RFC 9457 problem+json with a stable code.
*/
const express = require('express');
++const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
++const catalog = require('../quests/catalog');
++const store = require('../quests/store');
++
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++/** A quest as the wire sees it: what it is, what it counts, what it pays. */
++const questWire = (q) => ({
++ id: q.id,
++ site: q.site,
++ site_name: (catalog.SITES[q.site] || {}).name || q.site,
++ title: q.title,
++ why: q.why,
++ steps: q.steps.map((s) => ({ event_type: s.event_type, count: s.count, label: s.label })),
++ reward: { badge: { id: q.reward.badge.id, name: q.reward.badge.name }, coins: q.reward.coins },
++ repeatable: q.repeatable,
++ url: `/quests/${q.id}`,
++});
function createApi(ctx) {
-- const { config, principal, limits } = ctx;
++ const { config, s, principal, limits, coins } = ctx;
const r = asyncRouter();
++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ /** Whether OpenCoins are live, and the caps — the same answer the pages give. */
++ const coinsWire = () => ({ enabled: coins.enabled, daily_cap: config.coins.dailyCap, global_daily_cap: config.coins.globalDailyCap });
r.use(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use(principal.middleware);
-- // The one route the skeleton ships. It is public and counted per caller with the default read numbers.
r.get('/ping', limits.reads('quest.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
++ // ── The catalog (public) ─────────────────────────────────
++ r.get('/quests', limits.reads('quest.quests.read'), (_req, res) => res.json({
++ sites: catalog.grouped().map((g) => ({
++ site: g.site, name: g.name, what: g.what,
++ quests: g.quests.map(questWire),
++ })),
++ quests: catalog.all().map(questWire),
++ coins: coinsWire(),
++ }));
++
++ r.get('/quests/:id', limits.reads('quest.quests.read'), (req, res) => {
++ const quest = catalog.byId(req.params.id);
++ if (!quest) return problem(req, res, 404, 'quest.not_found', 'No such quest. GET /api/v1/quests lists them.');
++ return res.json(questWire(quest));
++ });
++
++ // ── Your own quest log (a person) ────────────────────────
++ r.get('/me/quests', limits.reads('quest.me.read'), async (req, res) => {
++ if (req.principal.kind !== 'user') return problem(req, res, 401, 'token.required', 'Sign in at openvibe.quest, or send a person\'s Network token, to read your own quest log.');
++ const subject = req.principal.requester.slice('user:'.length);
++ const [progress, completions, badges, balance] = await Promise.all([
++ store.progressFor(s.db, subject),
++ store.completionsFor(s.db, subject),
++ store.badgesFor(s.db, subject),
++ store.creditedTo(s.db, subject, store.dayOf(s.now())),
++ ]);
++ const byQuest = new Map();
++ for (const row of progress) {
++ const list = byQuest.get(row.quest_id) || [];
++ list.push({ step: Number(row.step), count: Number(row.count), updated_at: row.updated_at });
++ byQuest.set(row.quest_id, list);
++ }
++ const done = new Map(completions.map((c) => [c.quest_id, c]));
++ res.json({
++ subject,
++ quests: catalog.all().map((q) => {
++ const seen = byQuest.get(q.id) || [];
++ return {
++ ...questWire(q),
++ progress: q.steps.map((step, i) => ({
++ step: i, event_type: step.event_type, label: step.label, target: step.count,
++ count: Math.min((seen.find((x) => x.step === i) || {}).count || 0, step.count),
++ })),
++ state: done.has(q.id) ? 'completed' : seen.length ? 'in_progress' : 'not_started',
++ completion: done.has(q.id) ? completionWire(done.get(q.id)) : null,
++ };
++ }),
++ completions: completions.map(completionWire),
++ badges: badges.map((b) => ({ ...badgeWire(b.badge_id), awarded_at: b.awarded_at, quest_id: b.quest_id })),
++ coins: { ...coinsWire(), credited_today: balance },
++ });
++ });
++
++ // ── Someone's badges (public: ids, names and dates only) ──
++ r.get('/profiles/:subject/badges', limits.reads('quest.badge.read'), async (req, res) => {
++ const subject = String(req.params.subject || '');
++ if (!SUBJECT_RE.test(subject)) return problem(req, res, 404, 'quest.profile.not_found', 'No such person.');
++ const rows = await store.badgesOf(s.db, subject);
++ res.json({ subject, badges: rows.map((b) => ({ ...badgeWire(b.badge_id), awarded_at: b.awarded_at })) });
++ });
++
return r;
}
--module.exports = { createApi };
++/** A completion as its owner may see it: the quest, when, and what happened to the coins (never a token). */
++const completionWire = (c) => ({
++ id: c.id, quest_id: c.quest_id, completed_at: c.completed_at,
++ coins: { amount: Number(c.coins), state: c.coins_state, error: c.coins_error || null, at: c.coins_at || null },
++});
++
++/** A badge as anyone may see it: the id, its catalog name, and nothing about the person. */
++const badgeWire = (badgeId) => {
++ const badge = catalog.badgeById(badgeId);
++ return { id: badgeId, name: badge ? badge.name : badgeId };
++};
++
++module.exports = { createApi, questWire, completionWire, badgeWire, SUBJECT_RE };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 77fc5e6..ed3abf2 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -12,14 +12,18 @@ const path = require('path');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
++const catalog = require('../quests/catalog');
const SITE_NAME = 'OpenVibe.Quest';
const DESCRIPTION = 'OpenVibe.Quest — Quests across the whole network.';
--const DISALLOW = ['/auth/', '/api/'];
++const DISALLOW = ['/auth/', '/api/', '/me'];
++// One line per public page for llms-full.txt. /me is not here: it is a person's own log, never crawled.
const PAGE_TEXT = {
-- '/': ['OpenVibe.Quest home', 'OpenVibe.Quest: Quests across the whole network.'],
++ '/': ['OpenVibe.Quest home', 'OpenVibe.Quest: Quests across the whole network. The quests, grouped by the site whose events each one counts.'],
++ '/how-it-works': ['How quests work', 'What OpenVibe.Quest counts, what it stores, and what its rewards are — badges and OpenCoins, never money and never Vibes.'],
'/updates': ['What shipped on OpenVibe.Quest', 'This site\'s update log, from the network changelog feed.'],
++ ...Object.fromEntries(catalog.all().map((q) => [`/quests/${q.id}`, [q.title, `${q.why} On ${(catalog.SITES[q.site] || {}).name || q.site}. Reward: the ${q.reward.badge.name} badge${q.reward.coins > 0 ? ` and ${q.reward.coins} OpenCoins` : ''}.`]])),
};
function dayOf(ts) {
@@@ -41,6 +45,8 @@ function homeJsonLd(config) {
const publicPages = () => [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
++ { path: '/how-it-works', changefreq: 'monthly', priority: 0.6 },
++ ...catalog.all().map((q) => ({ path: `/quests/${q.id}`, changefreq: 'monthly', priority: 0.7 })),
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
];
@@@ -65,8 +71,11 @@ function createDiscoveryRoutes(ctx) {
sections: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Quest', url: abs('/'), note: 'Quests across the whole network.' },
++ { title: 'How quests work', url: abs('/how-it-works'), note: 'what is counted, what is stored, and the rewards' },
++ { title: 'The catalog (JSON)', url: abs('/api/v1/quests'), note: 'every quest, grouped by site' },
{ title: 'What shipped on OpenVibe.Quest', url: abs('/updates') },
] },
++ { title: 'The quests', links: catalog.all().map((q) => ({ title: q.title, url: abs(`/quests/${q.id}`), note: `${(catalog.SITES[q.site] || {}).name || q.site} — reward: the ${q.reward.badge.name} badge` })) },
{ title: 'Machine-readable', links: [
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 2b8082a..47277f8 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -1,11 +1,13 @@
'use strict';
/**
-- * Public pages: the home page and the update log. Crawl artifacts (robots.txt, sitemap.xml, llms.txt,
++ * Public pages: the home page (what Quest is, the quests grouped by site, how progress works), a quest's page,
++ * your own quest log, the rules, and the update log. Crawl artifacts (robots.txt, sitemap.xml, llms.txt,
* llms-full.txt, JSON-LD) are http/discovery.js.
*
* Every page works without JavaScript and is server-rendered through openvibe-shared/shell (render/layout.js).
-- * The product replaces the home page's sections and adds its own routes here.
++ * Nothing here reads anything about a person except their own rows on /me, and no page ever shows one person
++ * another's progress.
*/
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
@@@ -13,18 +15,38 @@ const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
--const { html, raw } = require('../render/html');
++const { html, raw, table } = require('../render/html');
const { send } = require('../render/layout');
++const quests = require('../render/quests');
++const catalog = require('../quests/catalog');
++const store = require('../quests/store');
const SITE_NAME = 'OpenVibe.Quest';
const TAGLINE = 'Quests across the whole network.';
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const siteName = (domain) => (catalog.SITES[domain] || {}).name || domain;
function createPageRoutes(ctx) {
-- const { config } = ctx;
++ const { config, s, coins } = ctx;
const r = asyncRouter();
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
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 coinsUi = { coinsEnabled: coins.enabled, dailyCap: config.coins.dailyCap, globalDailyCap: config.coins.globalDailyCap };
++
++ /**
++ * Every event Quest listens to (and the one it does not). The home page lists the names; /how-it-works spells
++ * out what each one means — the list is built from the same catalog, so the page cannot claim less than the
++ * consumer really subscribes to.
++ */
++ const eventsTable = ({ detailed = false } = {}) => table(
++ detailed ? ['Event', 'From', 'What it means'] : ['Event', 'From'],
++ catalog.EVENTS.map((e) => [
++ html`<code>${e.event_type}</code>`,
++ html`<a href="https://${e.site}">${siteName(e.site)}</a>`,
++ ...(detailed ? [html`${e.what}${e.counted === false ? ' Nothing is stored for it.' : ''}`] : []),
++ ]),
++ { empty: 'No events.' });
// ── Home ─────────────────────────────────────────────────
r.get('/', (req, res) => {
@@@ -32,11 +54,11 @@ function createPageRoutes(ctx) {
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: SITE_NAME,
accent: TAGLINE,
-- lede: `${SITE_NAME} is a new OpenVibe service. This is its skeleton: the platform plumbing below is in place, and the product is what goes on top of it.`,
++ lede: `${SITE_NAME} is the network's shared quest log: one place that knows what you have really done on every OpenVibe site, and pays you for it in badges and OpenCoins. Quest runs no activities of its own — it listens to what the sites already report, and nothing else.`,
actions: signedIn(req)
-- ? [{ label: 'Your account', href: '/auth/me' }, { label: 'What shipped', href: '/updates' }]
-- : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2F', primary: true }, { label: 'What shipped', href: '/updates' }],
-- note: 'Open source (AGPL-3.0). Sign-in is OpenVibe.Network; a person\'s token stays in an httpOnly cookie.',
++ ? [{ label: 'Your quest log', href: '/me', primary: true }, { label: 'How it works', href: '/how-it-works' }]
++ : [{ label: 'Sign in to start', href: '/auth/login?next=%2Fme', primary: true }, { label: 'How it works', href: '/how-it-works' }],
++ note: 'Open source (AGPL-3.0). Rewards are badges and OpenCoins — loyalty points, never money, and never Vibes.',
});
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
@@@ -44,30 +66,183 @@ function createPageRoutes(ctx) {
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
${raw(showcase.features({
-- title: 'What is already here',
-- lede: 'The parts every OpenVibe service needs, so a new product starts from them instead of rebuilding them.',
++ title: 'What Quest is',
++ lede: 'One quest log across every OpenVibe site, built on what the sites already tell the network.',
items: [
-- { icon: 'ov:account', title: 'Sign-in with OpenVibe.Network', text: 'OAuth 2 with PKCE (S256), server-side code exchange, httpOnly session cookies, and offline token verification against the Network signing key.' },
-- { icon: 'ov:page', title: 'Server-rendered pages', text: 'The OpenVibe Frame (navbar and footer), the shared theme, one stylesheet, crawl artifacts and JSON-LD. Every page is complete without JavaScript.' },
-- { icon: 'ov:tools', title: 'An API mount and capabilities', text: 'GET /api/v1/ping today; the router, the problem+json errors and the app/person capability guard are wired and waiting for the product\'s routes.' },
-- { icon: 'ov:gauge', title: 'Limits and readiness', text: 'Per-address and per-caller rate limits, /api/health, a truthful /api/ready and Prometheus metrics on loopback.' },
-- { icon: 'ov:db', title: 'PostgreSQL and migrations', text: 'openvibe-sdk/db, migrations/NNNN_*.sql applied at boot, PGlite in development, and a store with an injectable clock.' },
-- { icon: 'ov:deploy', title: 'Deploy files and tests', text: 'nginx and systemd units, an environment template, and a test suite on PGlite with a mock Network, all through npm test.' },
++ { icon: 'ov:page', title: 'One log, every site', text: 'A stream on OpenVibe.Live, a thread on OpenVibe.Community, a message in OpenVibe.Chat: the same log, grouped by the site the event came from.' },
++ { icon: 'ov:account', title: 'Rewards are badges', text: 'A quest pays a text badge on your profile (GET /api/v1/profiles/:subject/badges) and, when OpenCoins are on, OpenCoins.' },
++ { icon: 'ov:tools', title: 'OpenCoins, not Vibes', text: 'OpenCoins are loyalty points credited through OpenVibe.Network\'s wallet. They are never money, they are not Vibes, and Vibes are never touched here.' },
++ { icon: 'ov:games', title: 'Made by communities next', text: 'Quests a streamer or a community writes themselves need moderation, which this release does not have yet. The tables and the API already key everything by quest id.' },
],
}))}
--${raw(showcase.steps({
-- title: 'How a new service starts',
-- items: [
-- { title: 'Generate it', text: 'node scripts/new-service.js replaces the skeleton\'s tokens (service id, name, domain, port, tagline, env prefix) in every file and file name.' },
-- { title: 'Install and run', text: 'npm install, then npm run dev: the service listens on its port with an embedded PGlite database, if no DATABASE_URL is set.' },
-- { title: 'Make it a product', text: 'Add the product\'s tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js.' },
-- { title: 'Grant and deploy', text: 'Register the service and its capabilities with OpenVibe.Contracts and OpenVibe.Services, then install the unit and the vhost.' },
-- ],
-- }))}
--${raw(showcase.cta({ title: 'Read the API', text: 'GET /api/v1/ping is the whole API today. The rest is what the product adds.', actions: [{ label: 'The API', href: '/api/v1/ping' }, { label: 'What shipped', href: '/updates' }] }))}`,
++<section class="sc-sec" id="the-quests" aria-labelledby="the-quests-h"><h2 id="the-quests-h">The quests</h2>
++<p class="sc-lede">Every quest below counts something a person really did on an OpenVibe site. Grouped by the site whose events it counts.</p>
++${quests.list(catalog.grouped(), coinsUi)}
++${quests.coinsNote(coinsUi)}</section>
++<section class="sc-sec" id="how-progress-works" aria-labelledby="how-progress-works-h"><h2 id="how-progress-works-h">How progress works</h2>
++<p class="sc-lede">Quest does not watch you and does not ask the sites who you are. The network's event backbone,
++OpenVibe.Events, delivers the domain events a site already emits, and Quest counts the ones below — one per real
++action, with the person taken from the field the event's own schema defines. Nothing else is tracked: no page
++views, no clicks, no watch time, and no content you wrote.</p>
++${eventsTable()}
++<p class="sc-note">What each one means is on <a href="/how-it-works">How it works</a>. That list is the whole of it. A quest completes when every one of its steps reaches its count;
++the completion, its badge and its OpenCoins are recorded once. <a href="/how-it-works">The rules, in full</a>.</p></section>
++${raw(showcase.cta({
++ title: signedIn(req) ? 'Your quest log is waiting' : 'Start your quest log',
++ text: signedIn(req) ? 'Your progress, your badges, and what each quest still needs.' : 'Sign in with OpenVibe and the sites you already use start counting.',
++ actions: signedIn(req) ? [{ label: 'Your quest log', href: '/me', primary: true }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme', primary: true }],
++ }))}`,
++ });
++ });
++
++ // ── One quest ────────────────────────────────────────────
++ r.get('/quests/:id', (req, res) => {
++ const quest = catalog.byId(req.params.id);
++ if (!quest) return page(req, res, {
++ title: 'No such quest',
++ body: html`<h1>No such quest</h1><p>There is no quest at this address. <a href="/">The quest list</a> has all ${catalog.all().length} of them.</p>`,
++ }, 404);
++
++ const viewer = req.viewer;
++ const mine = signedIn(req) && SUBJECT_RE.test(viewer.subject);
++ const load = async () => {
++ if (!mine) return null;
++ const [rows, completion, holders] = await Promise.all([
++ store.progressOf(s.db, viewer.subject, quest.id),
++ store.completion(s.db, viewer.subject, quest.id),
++ store.badgeHolders(s.db, quest.reward.badge.id),
++ ]);
++ return { seen: new Map(rows.map((x) => [Number(x.step), Number(x.count)])), completion, holders };
++ };
++ return load().then((state) => page(req, res, {
++ index: true, cache: mine ? null : PUBLIC_CACHE,
++ title: quest.title,
++ description: quest.why,
++ crumbs: [{ label: 'Quests', href: '/' }, { label: quest.title }],
++ body: html`<h1>${quest.title}</h1>
++<p class="lede">${quest.why}</p>
++<p class="muted">On <a href="https://${quest.site}">${siteName(quest.site)}</a>.</p>
++<h2>What it counts</h2>
++<ul class="steps">${quest.steps.map((step, i) => html`<li><strong>${step.label}</strong> — ${step.count}× <code>${step.event_type}</code>${state && (state.seen.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul>
++<h2>The reward</h2>
++<p>${quests.reward(quest, coinsUi)}</p>
++<p class="muted">${state && state.holders ? html`${state.holders} ${state.holders === 1 ? 'person has' : 'people have'} this badge.` : html`A badge is awarded once and kept.`}</p>
++${state && state.completion ? html`<div class="notice" role="status">You completed this quest on <strong>${String(state.completion.completed_at).slice(0, 10)}</strong>${coinsUi.coinsEnabled && state.completion.coins_state === 'credited' ? html`, and its OpenCoins are in your wallet` : ''}.</div>` : html`<p><a href="/me">Your quest log</a> keeps your progress.</p>`}
++${quests.coinsNote(coinsUi)}
++<p><a href="/">All quests</a></p>`,
++ }));
++ });
++
++ // ── Your quest log ───────────────────────────────────────
++ r.get('/me', async (req, res) => {
++ if (!signedIn(req)) return page(req, res, {
++ title: 'Your quest log',
++ body: html`<h1>Your quest log</h1>
++<p>Quest keeps one log per person, across every site. Sign in with OpenVibe to see yours: what you have done, what
++each quest still needs, and the badges you have earned.</p>
++<p><a href="/auth/login?next=%2Fme">Sign in with OpenVibe</a></p>
++<p class="muted">Your progress is read from this site's database only, and only for you. Nobody else's log is shown here, and yours is not shown on anyone else's.</p>
++<p><a href="/how-it-works">How progress works</a> · <a href="/">The quests</a></p>`,
++ });
++
++ const subject = req.viewer.subject;
++ const [progress, completions, badges, creditedToday] = await Promise.all([
++ store.progressFor(s.db, subject),
++ store.completionsFor(s.db, subject),
++ store.badgesFor(s.db, subject),
++ store.creditedTo(s.db, subject, store.dayOf(s.now())),
++ ]);
++ const seen = new Map();
++ for (const row of progress) {
++ const list = seen.get(row.quest_id) || new Map();
++ list.set(Number(row.step), Number(row.count));
++ seen.set(row.quest_id, list);
++ }
++ const done = new Map(completions.map((c) => [c.quest_id, c]));
++ const questState = (q) => (done.has(q.id) ? 'done' : (seen.get(q.id) && seen.get(q.id).size && [...(seen.get(q.id).values())].some((n) => n > 0) ? 'started' : 'new'));
++ const started = catalog.all().filter((q) => questState(q) === 'started');
++ const finished = catalog.all().filter((q) => questState(q) === 'done');
++ const badge = (id) => catalog.badgeById(id) || { id, name: id };
++
++ return page(req, res, {
++ title: 'Your quest log',
++ crumbs: [{ label: 'Quests', href: '/' }, { label: 'Your quest log' }],
++ body: html`<h1>Your quest log</h1>
++<p class="muted">Signed in as <strong>${req.viewer.displayName || req.viewer.username || 'you'}</strong>. One log across every OpenVibe site.</p>
++<h2>In progress</h2>
++${started.length ? started.map((q) => html`<section class="quest-block"><h3><a href="/quests/${q.id}">${q.title}</a> <small>on ${siteName(q.site)}</small></h3>
++${q.steps.map((step, i) => quests.progress(step.label, (seen.get(q.id) || new Map()).get(i) || 0, step.count))}
++<p class="quest-reward">${quests.reward(q, coinsUi)}</p></section>`) : html`<p class="muted">Nothing started yet. <a href="/">Pick a quest</a> — any of the sites you already use counts.</p>`}
++<h2>Completed</h2>
++${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
++ const c = done.get(q.id);
++ return html`<li class="quest"><a class="quest-title" href="/quests/${q.id}">${q.title}</a>
++<p class="quest-why">Completed ${String(c.completed_at).slice(0, 10)}. Badge: <strong>${q.reward.badge.name}</strong>. ${c.coins > 0 ? (c.coins_state === 'credited' ? html`<strong>${c.coins}</strong> OpenCoins credited.` : c.coins_state === 'skipped' ? html`${c.coins} OpenCoins ${coinsUi.coinsEnabled ? html`were not credited: ${c.coins_error || 'the daily cap was reached'}` : html`start soon`}.` : html`${c.coins} OpenCoins could not be credited yet — this is recorded and retried.`) : ''}</p></li>`;
++ })}</ul>` : html`<p class="muted">No quests completed yet.</p>`}
++<h2>Your badges</h2>
++${badges.length ? html`<ul class="badges">${badges.map((b) => html`<li><strong>${badge(b.badge_id).name}</strong> <small>${String(b.awarded_at).slice(0, 10)}</small></li>`)}</ul>
++<p class="muted">Anyone can see these three facts at <code>/api/v1/profiles/${subject}/badges</code>: the badge's id, its name and the date. Nothing else about you is ever in that answer.</p>` : html`<p class="muted">No badges yet.</p>`}
++${quests.coinsNote(coinsUi)}
++${coinsUi.coinsEnabled ? html`<p class="muted">Credited today: <strong>${creditedToday}</strong> of ${coinsUi.dailyCap} OpenCoins.</p>` : ''}
++<h2>Not started</h2>
++${catalog.all().filter((q) => questState(q) === 'new').length
++ ? html`<p>${catalog.all().filter((q) => questState(q) === 'new').map((q, i) => html`${i ? ', ' : ''}<a href="/quests/${q.id}">${q.title}</a>`)}</p>`
++ : html`<p class="muted">Every quest is started or done.</p>`}`,
});
});
++ // ── The rules ────────────────────────────────────────────
++ r.get('/how-it-works', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: 'How quests work',
++ description: 'What OpenVibe.Quest counts, what it stores, and what its rewards are — and are not.',
++ crumbs: [{ label: 'Quests', href: '/' }, { label: 'How it works' }],
++ body: html`<h1>How quests work</h1>
++<p class="lede">Everything on this page is the whole truth about what Quest does with your activity. If it is not
++here, Quest does not do it.</p>
++
++<h2>What moves a quest</h2>
++<p>OpenVibe.Events delivers the domain events that OpenVibe sites already emit when something really happens. Quest
++counts the ones below, and takes the person from the field the event's own schema defines — never from a name, never
++by guessing. An event whose person field is missing, or names a guest or a service instead of a person, is ignored
++and nothing is stored.</p>
++${eventsTable({ detailed: true })}
++<p>Nothing else is tracked. Quest stores no page views, no clicks, no watch time, and no content you wrote — not a
++message's text, not a thread's title, not a wiki revision's text. A message in a private chat room or a direct
++message never reaches Quest at all: OpenVibe.Chat only emits <code>chat.room.message.created</code> for public
++rooms.</p>
++
++<h2>What is stored</h2>
++<p>Three things, all keyed by your canonical subject (<code>usr_…</code>):</p>
++<ul>
++<li>a count per quest step — for example "2 of 3 streams";</li>
++<li>one row per quest you completed, with when, and what happened to its OpenCoins;</li>
++<li>one row per badge you were awarded, with the date.</li>
++</ul>
++<p>The only thing anyone else can read about you is that badge list: GET /api/v1/profiles/${'<subject>'}/badges
++answers the badge's id, its name and the date, and nothing else — no counts, no progress, no completions, no person.</p>
++
++<h2>Rewards</h2>
++<p>A quest pays a <strong>badge</strong> — a text label on your profile, awarded once — and, when OpenCoins are on,
++<strong>OpenCoins</strong>. OpenCoins are loyalty points credited through OpenVibe.Network's wallet. They are
++<strong>never money</strong>, they are not <strong>Vibes</strong>, and Quest neither reads nor moves money anywhere.
++Vibes are real money and belong to the sites that pay them out; nothing on this service touches them.</p>
++${quests.coinsNote(coinsUi)}
++<p>When a completion earns OpenCoins, Quest asks OpenVibe.Network to credit them with an idempotency key — so a
++retry of a credit that did land can never pay twice. If the wallet refuses or is unreachable, the failure is
++recorded with its reason and retried; nothing is silently dropped. A completion over a daily cap is marked
++<em>skipped</em> with the reason, and the quest itself is still completed and still pays its badge.</p>
++
++<h2>What Quest does not do yet</h2>
++<ul>
++<li>Quests a community or a streamer writes themselves: they need moderation this release does not have.</li>
++<li>Repeatable quests and seasonal resets: every quest here completes once per person.</li>
++<li>Account deletion and account merges (ADR-033, ADR-029): Quest does not yet remove or re-key a person's rows
++when their Network account is deleted or merged. Until it does, progress rows stay as they are.</li>
++</ul>
++<p><a href="/">The quests</a> · <a href="/updates">What shipped</a></p>`,
++ }));
++
// ── The update log ───────────────────────────────────────
r.get('/updates', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE,
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 3af68ca..239fada 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -20,9 +20,10 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
--// The product fills this in: one entry per capability its routes name, in openvibe-contracts
--// manifests/capabilities/<quest>.*. requireCapability('<name>') refuses a name that is not listed here, so a
--// route can never be guarded by a capability the service does not declare.
++// Empty on purpose: every route OpenVibe.Quest serves is either public (the catalog, a quest, a person's public
++// badges) or a person reading their own log, and neither needs a capability — a person acting for themself never
++// does. openvibe-contracts has no quest.* capability manifest yet; a route that needed one would add it there
++// first and then name it here, so a route can never be guarded by a capability the service does not declare.
const CAPABILITIES = [];
function decodePayload(token) {
ddiff --git a/server/index.js b/server/index.js
iindex 88e5ff8..2d7baa1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -8,13 +8,15 @@ const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
/**
-- * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops and the store closes.
-- * Exported so a test can inject `exit` and `signals: false`.
++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the Events subscriptions
++ * stop, the JWKS refresher stops and the store closes. Exported so a test can inject `exit` and `signals: false`.
++ * `extra` is what the product adds (the Events subscriptions and the OpenCoins retry timer); a test that passes
++ * none keeps the skeleton's order.
*/
--function createLifecycle({ server, ctx, exit, signals, timers = [] }) {
++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
name: 'OpenVibe.Quest', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
-- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.s.close()],
++ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],
});
}
@@@ -28,7 +30,16 @@ async function start() {
server.keepAliveTimeout = 65_000;
ctx.keys.client.start();
-- createLifecycle({ server, ctx });
++ // Subscribe to the events the catalog counts at OpenVibe.Events (idempotent; off without QUEST_EVENTS_URL
++ // and QUEST_EVENTS_SECRET) and come back to OpenCoins a failed credit owes (off unless QUEST_COINS=on).
++ const secret = config.events.secrets[0] || '';
++ const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret });
++ const timers = [];
++ const coinsTimer = ctx.coins.start();
++ if (coinsTimer) timers.push(coinsTimer);
++
++ const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop()];
++ createLifecycle({ server, ctx, timers, extra });
return { server, ctx };
}
ddiff --git a/server/quests/apply.js b/server/quests/apply.js
nnew file mode 100644
iindex 0000000..55ef9ea
---- /dev/null
++++ b/server/quests/apply.js
@@@ -0,0 +1,97 @@
++'use strict';
++
++/**
++ * Applying one domain event to the quest log.
++ *
++ * The person comes from the field the event's own schema defines — never from anything else in the body, never
++ * from a name, and never guessed: a stream's channel subject (live.stream.started), the follower
++ * (network.follow.created), the author of a thread, post, comment or wiki revision, the chat message's
++ * user_subject, a game player's account subject, a tool job's owner. Where a schema says the envelope's actor IS
++ * that person (live.stream.started: "the streamer's user subject when Live knows it"), the actor is the second
++ * choice; otherwise it is ignored. An event whose subject is missing, a guest (gst_…), or a service, app or mod
++ * principal is ignored, outcome 'ignored:subject', and nothing is written.
++ *
++ * Then, for every quest that counts the event type:
++ * - one more on that step's count (capped at the step's target; a completed quest is left frozen);
++ * - if every step is at its target, a completion — once per person, by the unique constraint — and its badge.
++ *
++ * OpenCoins are NOT touched here: the caller settles them after the transaction commits, so a slow or broken
++ * wallet (OpenVibe.Network) cannot hold up the delivery or roll back a completion the person really earned.
++ *
++ * `db` is a transaction handle when this runs inside the inbox (server/events-consumer.js), so the writes and the
++ * receipt of the delivery commit together.
++ */
++const { ids } = require('openvibe-contracts');
++const catalog = require('./catalog');
++const store = require('./store');
++
++const isUser = (v) => typeof v === 'string' && ids.isSubjectId('user', v);
++/** A subject-ref ({ type, id }) that names a person. */
++const refUser = (r) => (r && r.type === 'user' && isUser(r.id) ? r.id : null);
++
++/**
++ * The person the event names, or null when it names nobody this service may credit. Each case is one schema
++ * (node_modules/openvibe-contracts/contracts/events/payloads/<event_type>.v1.json); the comment names the field.
++ */
++function personOf(event) {
++ const p = (event && event.payload) || {};
++ switch (event && event.event_type) {
++ case 'live.stream.started': // payload.channel.subject (the streamer), else the envelope actor
++ return refUser(p.channel && p.channel.subject) || refUser(event.actor);
++ case 'network.follow.created': // payload.follower
++ return isUser(p.follower) ? p.follower : null;
++ case 'community.thread.created': // payload.author (identity.subject-ref@1)
++ case 'community.post.created':
++ case 'community.comment.created':
++ case 'space.thread.created':
++ return refUser(p.author);
++ case 'chat.room.message.created': // payload.user_subject (null in private rooms, and for a guest)
++ return isUser(p.user_subject) ? p.user_subject : null;
++ case 'wiki.revision.created': // payload.author: a usr_… subject, or svc:<id> for a service edit
++ return isUser(p.author) ? p.author : null;
++ case 'games.player.joined': // payload.player.subject (user or guest; a local guest has none)
++ return refUser(p.player && p.player.subject);
++ case 'tools.job.created': // payload.owner (a user, or a service, app or mod principal)
++ return refUser(p.owner);
++ default:
++ return null;
++ }
++}
++
++/**
++ * Apply the event in `db` (a handle or a transaction). Returns
++ * { subject, outcome, completed: [<quest_completions row>] }
++ * with outcome 'ignored:subject' | 'ignored:type' | 'progress' | 'completed'. `completed` is what the caller
++ * still has to do about OpenCoins.
++ */
++async function apply(db, event, { at, newId }) {
++ const subject = personOf(event);
++ if (!subject) return { subject: null, outcome: 'ignored:subject', completed: [] };
++ const quests = catalog.withEvent(event.event_type);
++ if (!quests.length) return { subject, outcome: 'ignored:type', completed: [] };
++
++ const completed = [];
++ for (const quest of quests) {
++ // A quest this person already finished stays finished: its progress is history, not a counter to raise.
++ if (await store.completion(db, subject, quest.id)) continue;
++
++ const progress = new Map((await store.progressOf(db, subject, quest.id)).map((r) => [Number(r.step), Number(r.count)]));
++ for (const [i, step] of quest.steps.entries()) {
++ if (step.event_type !== event.event_type) continue;
++ progress.set(i, await store.bumpProgress(db, { subject, questId: quest.id, step: i, target: step.count, at }));
++ }
++ if (!quest.steps.every((s, i) => (progress.get(i) || 0) >= s.count)) continue;
++
++ const coins = quest.reward.coins;
++ const row = await store.complete(db, {
++ id: newId('qcp'), subject, questId: quest.id, at,
++ coins, coinsState: coins > 0 ? 'pending' : 'skipped',
++ });
++ if (!row) continue; // lost a race with another delivery: it owns the completion
++ await store.awardBadge(db, { subject, badgeId: quest.reward.badge.id, questId: quest.id, at });
++ completed.push(row); // the completion row: id, subject, quest_id, coins, coins_state, …
++ }
++ return { subject, outcome: completed.length ? 'completed' : 'progress', completed };
++}
++
++module.exports = { apply, personOf };
ddiff --git a/server/quests/catalog.js b/server/quests/catalog.js
nnew file mode 100644
iindex 0000000..e1bb5fa
---- /dev/null
++++ b/server/quests/catalog.js
@@@ -0,0 +1,205 @@
++'use strict';
++
++/**
++ * The quest catalog: the first-party quests OpenVibe.Quest itself offers, in code, so a quest cannot drift from
++ * what the service actually counts. Small and honest — one quest per real thing a person already does on an
++ * OpenVibe site, and nothing a person cannot really do today.
++ *
++ * Every quest names the events it counts (the schemas live in openvibe-contracts contracts/events/payloads/):
++ * a quest only counts an event whose payload (or, where the schema says the actor is the person, the envelope
++ * actor) really carries a usr_… subject, so a repeat of someone else's action can never move a quest log.
++ * Nothing else about a person is read or stored: see the rules on /how-it-works.
++ *
++ * A quest is:
++ * id slug, the URL and the database key
++ * site the domain whose events it counts (grouping and provenance)
++ * title one line, a thing a person would say
++ * why one line on why it is worth doing (never a promise the sites do not keep)
++ * steps [{ event_type, count, label }] — every step must reach its count
++ * reward { badge: { id, name }, coins } — a text badge label (no emoji) and OpenCoins (loyalty, not money)
++ * repeatable false: a quest completes once per person, and its badge is awarded once
++ *
++ * Community-made quests (the product's third pillar) are not here yet: they need moderation, which this release
++ * does not have. The API and the tables already key everything by quest id, so they arrive as data, not a rewrite.
++ */
++
++/** The OpenVibe sites a quest can belong to: the domain its events come from, and how the page names it. */
++const SITES = Object.freeze({
++ 'openvibe.live': { name: 'OpenVibe.Live', what: 'streaming' },
++ 'openvibe.network': { name: 'OpenVibe.Network', what: 'the network itself — accounts, follows, blocks' },
++ 'openvibe.community': { name: 'OpenVibe.Community', what: 'the forum' },
++ 'openvibe.chat': { name: 'OpenVibe.Chat', what: 'public chat rooms' },
++ 'openvibe.wiki': { name: 'OpenVibe.Wiki', what: 'the wiki' },
++ 'openvibe.games': { name: 'OpenVibe.Games', what: 'the games platform' },
++ 'openvibe.tools': { name: 'OpenVibe.Tools', what: 'the tools' },
++ 'openvibe.space': { name: 'OpenVibe.Space', what: 'forums in a Space' },
++});
++
++const QUESTS = Object.freeze([
++ {
++ id: 'go-live',
++ site: 'openvibe.live',
++ title: 'Go live once',
++ why: 'A channel with nothing on it is a promise; one broadcast makes it real.',
++ steps: [{ event_type: 'live.stream.started', count: 1, label: 'Start a stream' }],
++ reward: { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10 },
++ repeatable: false,
++ },
++ {
++ id: 'stream-three-times',
++ site: 'openvibe.live',
++ title: 'Stream three times',
++ why: 'Three broadcasts is a habit; the first one is only an attempt.',
++ steps: [{ event_type: 'live.stream.started', count: 3, label: 'Start a stream' }],
++ reward: { badge: { id: 'regular-broadcaster', name: 'Regular Broadcaster' }, coins: 25 },
++ repeatable: false,
++ },
++ {
++ id: 'follow-someone',
++ site: 'openvibe.network',
++ title: 'Follow someone',
++ why: 'The network is a list of people you chose to hear from.',
++ steps: [{ event_type: 'network.follow.created', count: 1, label: 'Follow a channel or a person' }],
++ reward: { badge: { id: 'first-follow', name: 'First Follow' }, coins: 5 },
++ repeatable: false,
++ },
++ {
++ id: 'start-a-thread',
++ site: 'openvibe.community',
++ title: 'Start a forum thread',
++ why: 'Every thread on the forum started as somebody\'s question.',
++ steps: [{ event_type: 'community.thread.created', count: 1, label: 'Start a thread' }],
++ reward: { badge: { id: 'thread-starter', name: 'Thread Starter' }, coins: 10 },
++ repeatable: false,
++ },
++ {
++ id: 'reply-in-a-thread',
++ site: 'openvibe.community',
++ title: 'Reply in a thread',
++ why: 'An answer is worth more than a read.',
++ steps: [{ event_type: 'community.post.created', count: 1, label: 'Post a reply' }],
++ reward: { badge: { id: 'first-reply', name: 'First Reply' }, coins: 5 },
++ repeatable: false,
++ },
++ {
++ id: 'comment-on-a-post',
++ site: 'openvibe.community',
++ title: 'Comment on a post',
++ why: 'Comments are where the conversation about a video or an article actually happens.',
++ steps: [{ event_type: 'community.comment.created', count: 1, label: 'Leave a comment' }],
++ reward: { badge: { id: 'commenter', name: 'Commenter' }, coins: 5 },
++ repeatable: false,
++ },
++ {
++ id: 'say-hello-in-chat',
++ site: 'openvibe.chat',
++ title: 'Say hello in chat',
++ why: 'A public room is the fastest way to meet the people watching the same stream.',
++ steps: [{ event_type: 'chat.room.message.created', count: 1, label: 'Send a message in a public room' }],
++ reward: { badge: { id: 'said-hello', name: 'Said Hello' }, coins: 5 },
++ repeatable: false,
++ },
++ {
++ id: 'improve-a-wiki-page',
++ site: 'openvibe.wiki',
++ title: 'Improve a wiki page',
++ why: 'One corrected sentence is a page someone else reads correctly.',
++ steps: [{ event_type: 'wiki.revision.created', count: 1, label: 'Save a revision' }],
++ reward: { badge: { id: 'wiki-editor', name: 'Wiki Editor' }, coins: 15 },
++ repeatable: false,
++ },
++ {
++ id: 'play-a-game',
++ site: 'openvibe.games',
++ title: 'Play a game',
++ why: 'The games platform is a world; walking into it is the only way to see it.',
++ steps: [{ event_type: 'games.player.joined', count: 1, label: 'Enter a world' }],
++ reward: { badge: { id: 'player-one', name: 'Player One' }, coins: 10 },
++ repeatable: false,
++ },
++ {
++ id: 'use-a-tool',
++ site: 'openvibe.tools',
++ title: 'Use a tool',
++ why: 'OpenVibe.Tools turns a file into something else; run one job and you know what that means.',
++ steps: [{ event_type: 'tools.job.created', count: 1, label: 'Run a job' }],
++ reward: { badge: { id: 'toolsmith', name: 'Toolsmith' }, coins: 10 },
++ repeatable: false,
++ },
++ {
++ id: 'start-a-space-thread',
++ site: 'openvibe.space',
++ title: 'Start a Space thread',
++ why: 'A Space is a community\'s own forum; a thread is how you join it.',
++ steps: [{ event_type: 'space.thread.created', count: 1, label: 'Start a thread in a Space' }],
++ reward: { badge: { id: 'space-thread-starter', name: 'Space Thread Starter' }, coins: 10 },
++ repeatable: false,
++ },
++ {
++ id: 'settle-in',
++ site: 'openvibe.network',
++ title: 'Settle in across the network',
++ why: 'One thread and one chat message: two sites, and the account stops being new.',
++ steps: [
++ { event_type: 'community.thread.created', count: 1, label: 'Start a forum thread' },
++ { event_type: 'chat.room.message.created', count: 1, label: 'Send a chat message' },
++ ],
++ reward: { badge: { id: 'settled-in', name: 'Settled In' }, coins: 20 },
++ repeatable: false,
++ },
++]);
++
++const BY_ID = new Map(QUESTS.map((q) => [q.id, q]));
++
++/**
++ * Every domain event OpenVibe.Quest listens to, with the site that emits it and what it means. This is the whole
++ * list of what the service is told about a person — the /how-it-works page prints it from here, so the page cannot
++ * claim less than the consumer really subscribes to. Each one comes from its schema in openvibe-contracts
++ * contracts/events/payloads/; nothing is inferred.
++ */
++const EVENTS = Object.freeze([
++ { event_type: 'live.stream.started', site: 'openvibe.live', what: 'A stream went live. The streamer is the channel\'s user subject.' },
++ // Listed so /how-it-works can say what Quest does NOT listen to, next to what it does: a stream ending is
++ // real activity too, and a person reading the page should not have to guess why it moves nothing.
++ { event_type: 'live.stream.ended', site: 'openvibe.live', what: 'A stream ended. Quest does not subscribe to this one.', counted: false },
++ { event_type: 'network.follow.created', site: 'openvibe.network', what: 'Somebody followed a channel or a person; the payload names the follower.' },
++ { event_type: 'community.thread.created', site: 'openvibe.community', what: 'A forum thread was started; the payload names its author.' },
++ { event_type: 'community.post.created', site: 'openvibe.community', what: 'A reply was posted in a thread; the payload names its author.' },
++ { event_type: 'community.comment.created', site: 'openvibe.community', what: 'A comment was left on a post or a paste; the payload names its author.' },
++ { event_type: 'space.thread.created', site: 'openvibe.space', what: 'A thread was started in a Space; the payload names its author.' },
++ { event_type: 'chat.room.message.created', site: 'openvibe.chat', what: 'A message was posted in a PUBLIC chat room; the payload names its author. Private rooms and DMs never emit it.' },
++ { event_type: 'wiki.revision.created', site: 'openvibe.wiki', what: 'A wiki page got a new immutable revision; the payload names its author (a service edit is ignored).' },
++ { event_type: 'games.player.joined', site: 'openvibe.games', what: 'A character entered a world; the payload names its account\'s user subject (a guest is ignored).' },
++ { event_type: 'tools.job.created', site: 'openvibe.tools', what: 'A tool job was accepted and queued; the payload names its owner (an anonymous session is ignored).' },
++]);
++
++// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event
++// this catalog does not document. A typo in a step therefore fails loudly here, not silently in production.
++const COUNTED = Object.freeze(EVENTS.filter((e) => e.counted !== false).map((e) => e.event_type));
++const EVENT_TYPES = Object.freeze(COUNTED.slice().sort());
++{
++ const missing = QUESTS.flatMap((q) => q.steps.map((s) => s.event_type)).filter((t) => !COUNTED.includes(t));
++ if (missing.length) throw new Error(`quests/catalog.js: a quest counts an event that is not in EVENTS: ${[...new Set(missing)].join(', ')}`);
++}
++
++/** The sites, in the order they first appear in the catalog (so the pages read the same every time). */
++const SITE_ORDER = Object.freeze([...new Set(QUESTS.map((q) => q.site))]);
++
++const byId = (id) => BY_ID.get(String(id)) || null;
++const all = () => QUESTS;
++/** The quests that count this event type (a step may be shared: 'settle in' counts two sites). */
++const withEvent = (eventType) => QUESTS.filter((q) => q.steps.some((s) => s.event_type === eventType));
++/** The catalog grouped by site, for the home page and the API: [{ site, name, what, quests }]. */
++const grouped = () => SITE_ORDER.map((site) => ({
++ site,
++ name: (SITES[site] || {}).name || site,
++ what: (SITES[site] || {}).what || '',
++ quests: QUESTS.filter((q) => q.site === site),
++}));
++/** The badge for a quest, by badge id (the public badge endpoint stores only the id). */
++const badgeById = (badgeId) => {
++ for (const q of QUESTS) if (q.reward.badge.id === badgeId) return { ...q.reward.badge, quest_id: q.id };
++ return null;
++};
++
++module.exports = { QUESTS, SITES, EVENTS, EVENT_TYPES, SITE_ORDER, all, byId, withEvent, grouped, badgeById };
ddiff --git a/server/quests/coins.js b/server/quests/coins.js
nnew file mode 100644
iindex 0000000..197650b
---- /dev/null
++++ b/server/quests/coins.js
@@@ -0,0 +1,175 @@
++'use strict';
++
++/**
++ * OpenCoins: crediting a completion through OpenVibe.Network's wallet.
++ *
++ * OpenCoins are loyalty points, never money — they are not Vibes, and nothing here ever moves money. The wallet
++ * is Network's (POST /internal/coins/credit, network.coins-change-request@1); this service holds no balance and
++ * reads none. The wallet is keyed by the Network user id, the integer, so a subject is resolved first
++ * (GET /internal/identity/resolve, a projection that carries network_user_id) — a subject the Network does not
++ * know is a failure to record, not a reason to guess.
++ *
++ * Auth: this service's own client credentials (grant_type=client_credentials for `quest`) through
++ * openvibe-contracts' cached token client, for audience openvibe.network and the two capabilities it needs:
++ * identity.subject.resolve and network.coins.credit.
++ *
++ * idempotency_key is the completion id (qcp_…), so a retry of a credit that did land changes nothing: Network
++ * answers the original balance.
++ *
++ * Guard rails, all of them deliberate:
++ * QUEST_COINS=off (the default) no credit is attempted at all; the completion is `skipped` and the pages say
++ * "OpenCoins rewards start soon"
++ * QUEST_COINS_DAILY_CAP per person, per UTC day (default 50): over it, the completion is `skipped`
++ * with the reason, so nobody's day is silently short
++ * QUEST_COINS_GLOBAL_DAILY_CAP everyone together (default 5000)
++ * a failure (the wallet refused, the Network was unreachable, no network account for the subject) is recorded
++ * as `failed` with its reason and retried by a small timer — never lost, and
++ * never retried into a double credit (the idempotency key).
++ */
++
++const { serviceAuth } = require('openvibe-contracts');
++const store = require('./store');
++const catalog = require('./catalog');
++
++const APP_ID = 'quest'; // svc:quest may only credit its own app id (Network's ownApp rule)
++const AUDIENCE = 'openvibe.network';
++const SCOPES = 'identity.subject.resolve network.coins.credit';
++const TIMEOUT_MS = 5000;
++
++/** How a completion is told apart in the ledger row (never anything about the person). */
++const coinsRef = (questId) => `https://openvibe.quest/quests/${questId}`;
++
++function createCoins({ config, s, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
++ const enabled = config.coins.mode === 'on';
++ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: AUDIENCE,
++ scope: SCOPES,
++ fetchImpl,
++ });
++ let timer = null;
++ const stats = { credited: 0, skipped: 0, failed: 0, attempts: 0, last_at: null };
++
++ /** One call to Network's internal API with this service's token. Throws CoinsError (no token, no secret). */
++ async function call(method, path, { query, body } = {}) {
++ const url = new URL(base + path);
++ for (const [k, v] of Object.entries(query || {})) url.searchParams.set(k, v);
++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
++ if (body) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(url.toString(), {
++ method, headers, body: body ? JSON.stringify(body) : undefined,
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ const json = await res.json().catch(() => null);
++ if (!res.ok) {
++ // The wallet's own words (`invalid_user_id`, `user_not_found`), never the token.
++ const err = new Error(`Network ${res.status}${json && (json.error || json.code) ? `: ${json.error || json.code}` : ''}`);
++ err.status = res.status;
++ throw err;
++ }
++ return json || {};
++ }
++
++ /** The Network user id behind a subject, or null when the Network has no such account. */
++ async function networkUserId(subject) {
++ try {
++ const out = await call('GET', '/internal/identity/resolve', { query: { subject_id: subject } });
++ const id = out && out.network_user_id;
++ return Number.isInteger(id) && id > 0 ? id : null;
++ } catch (err) {
++ if (err.status === 404) return null;
++ throw err;
++ }
++ }
++
++ /** Credit one completion. Throws with a short, safe reason; never logs a token or a body. */
++ async function credit(completion) {
++ const quest = catalog.byId(completion.quest_id);
++ if (!quest) throw new Error(`no quest ${completion.quest_id} in the catalog`);
++ const userId = await networkUserId(completion.subject);
++ if (!userId) throw new Error('the Network has no account for this subject yet');
++ const out = await call('POST', '/internal/coins/credit', {
++ body: {
++ user_id: userId,
++ app_id: APP_ID,
++ amount: completion.coins,
++ reason: `quest:${quest.id}`,
++ ref: coinsRef(quest.id),
++ idempotency_key: completion.id,
++ },
++ });
++ return Number.isInteger(out.balance) ? out.balance : null;
++ }
++
++ /** Record the outcome of a decision about one completion's coins. */
++ async function record(completion, patch) {
++ return store.setCoins(s.db, completion.id, patch);
++ }
++
++ /**
++ * Decide what happens to a completion's coins now: skip (coins off, or a cap is reached), credit, or record
++ * the failure for the retry timer. Never throws.
++ */
++ async function settle(completion) {
++ if (!completion || completion.coins <= 0) return null;
++ if (!enabled) { stats.skipped++; return record(completion, { state: 'skipped', error: null }); }
++ try {
++ const day = store.dayOf(now());
++ const mine = await store.creditedTo(s.db, completion.subject, day);
++ if (mine + completion.coins > config.coins.dailyCap) {
++ stats.skipped++;
++ return record(completion, { state: 'skipped', error: `the daily cap of ${config.coins.dailyCap} OpenCoins per person is reached` });
++ }
++ const everyone = await store.creditedAll(s.db, day);
++ if (everyone + completion.coins > config.coins.globalDailyCap) {
++ stats.skipped++;
++ return record(completion, { state: 'skipped', error: `the network-wide daily cap of ${config.coins.globalDailyCap} OpenCoins is reached` });
++ }
++ stats.attempts++;
++ const balance = await credit(completion);
++ stats.credited++; stats.last_at = new Date(now()).toISOString();
++ return record(completion, { state: 'credited', error: null, at: new Date(now()).toISOString(), attempts: 1, balance });
++ } catch (err) {
++ stats.failed++;
++ const message = String((err && err.message) || err).slice(0, 200);
++ log.warn(`[OpenCoins] ${completion.id} (${completion.quest_id}) not credited: ${message}`);
++ return record(completion, { state: 'failed', error: message, attempts: 1 });
++ }
++ }
++
++ /**
++ * Come back to every completion whose credit is still owed (a failure, or a process that stopped between the
++ * completion and the credit). Called by the timer in production and directly by tests.
++ */
++ async function retryPending({ limit = 50 } = {}) {
++ if (!enabled) return { tried: 0, credited: 0, skipped: 0, failed: 0 };
++ const rows = await store.owedCoins(s.db, limit);
++ const out = { tried: rows.length, credited: 0, skipped: 0, failed: 0 };
++ for (const row of rows) {
++ const after = await settle(row);
++ if (after && after.coins_state === 'credited') out.credited++;
++ else if (after && after.coins_state === 'skipped') out.skipped++;
++ else if (after) out.failed++;
++ }
++ if (rows.length) log.log(`[OpenCoins] retried ${rows.length}: ${out.credited} credited, ${out.skipped} skipped, ${out.failed} still failing`);
++ return out;
++ }
++
++ function start({ intervalMs = config.coins.retryMs } = {}) {
++ if (!enabled || timer) return null;
++ timer = setInterval(() => { retryPending().catch((err) => log.warn('[OpenCoins] retry failed:', err.message)); }, intervalMs);
++ if (timer.unref) timer.unref();
++ return timer;
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++
++ return {
++ enabled, settle, retryPending, start, stop, networkUserId, credit,
++ status: () => ({ enabled, daily_cap: config.coins.dailyCap, global_daily_cap: config.coins.globalDailyCap, ...stats }),
++ };
++}
++
++module.exports = { createCoins, APP_ID, AUDIENCE, SCOPES, coinsRef };
ddiff --git a/server/quests/store.js b/server/quests/store.js
nnew file mode 100644
iindex 0000000..f383301
---- /dev/null
++++ b/server/quests/store.js
@@@ -0,0 +1,114 @@
++'use strict';
++
++/**
++ * The quest log in PostgreSQL (migrations/0002_quest.sql): progress, completions and badges.
++ *
++ * Every function takes a db handle first — the store's `s.db`, or the transaction handle inside `inbox.once`,
++ * so the progress a delivery writes and its receipt commit together or not at all.
++ *
++ * A subject is a canonical usr_… id. A quest never completes twice for a person (UNIQUE (subject, quest_id)) and
++ * a badge is never awarded twice (PRIMARY KEY (subject, badge_id)), so a redelivered event changes nothing.
++ */
++
++const DAY_MS = 86_400_000;
++
++/** The UTC day (YYYY-MM-DD) a moment falls in — the bucket the daily coin caps count. */
++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
++
++/** Raise one step's count by one, never past its target. Returns the new count. */
++async function bumpProgress(db, { subject, questId, step, target, at }) {
++ return Number(await db.value(
++ `INSERT INTO quest_progress (subject, quest_id, step, count, updated_at)
++ VALUES ($1, $2, $3, 1, $4)
++ ON CONFLICT (subject, quest_id, step) DO UPDATE
++ SET count = LEAST(quest_progress.count + 1, $5), updated_at = EXCLUDED.updated_at
++ RETURNING count`,
++ [subject, questId, step, at, target]));
++}
++
++/** A person's progress on one quest: [{ step, count, updated_at }], keyed by step index in the caller. */
++const progressOf = (db, subject, questId) => db.many(
++ 'SELECT step, count, updated_at FROM quest_progress WHERE subject = $1 AND quest_id = $2 ORDER BY step',
++ [subject, questId]);
++
++/** Every progress row a person has, newest first — the quest log page and GET /me/quests read this once. */
++const progressFor = (db, subject) => db.many(
++ 'SELECT quest_id, step, count, updated_at FROM quest_progress WHERE subject = $1 ORDER BY quest_id, step',
++ [subject]);
++
++const completion = (db, subject, questId) => db.maybe(
++ 'SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = $2', [subject, questId]);
++
++const completionsFor = (db, subject) => db.many(
++ 'SELECT * FROM quest_completions WHERE subject = $1 ORDER BY completed_at DESC, id DESC', [subject]);
++
++/** Every completion for a quest — a completion count for the pages, never a list of people. */
++const completionCount = async (db, questId) => Number(await db.value('SELECT count(*) FROM quest_completions WHERE quest_id = $1', [questId]));
++
++/**
++ * Record a completion. Returns the row when it was inserted now, or null when this person already completed the
++ * quest (the unique constraint, not a read-then-write, is what makes that safe under a redelivery).
++ */
++async function complete(db, c) {
++ const row = await db.maybe(
++ `INSERT INTO quest_completions (id, subject, quest_id, completed_at, coins, coins_state)
++ VALUES ($1, $2, $3, $4, $5, $6)
++ ON CONFLICT (subject, quest_id) DO NOTHING
++ RETURNING *`,
++ [c.id, c.subject, c.questId, c.at, c.coins, c.coinsState]);
++ return row;
++}
++
++const awardBadge = (db, { subject, badgeId, questId, at }) => db.exec(
++ `INSERT INTO quest_badges (subject, badge_id, quest_id, awarded_at) VALUES ($1, $2, $3, $4)
++ ON CONFLICT (subject, badge_id) DO NOTHING`,
++ [subject, badgeId, questId, at]);
++
++const badgesFor = (db, subject) => db.many(
++ 'SELECT badge_id, quest_id, awarded_at FROM quest_badges WHERE subject = $1 ORDER BY awarded_at DESC, badge_id',
++ [subject]);
++
++/** A person's public badges: the caller renders them; nothing else about the person leaves this function. */
++const badgesOf = (db, subject) => db.many(
++ 'SELECT badge_id, awarded_at FROM quest_badges WHERE subject = $1 ORDER BY awarded_at DESC, badge_id',
++ [subject]);
++
++/** Move a completion's coins state; `attempt` bumps the attempt counter the retry timer reads. */
++const setCoins = (db, id, { state, error = null, at = null, attempts = 0, balance = null }) => db.maybe(
++ `UPDATE quest_completions
++ SET coins_state = $2, coins_error = $3, coins_at = $4,
++ coins_attempts = coins_attempts + $5, coins_balance = COALESCE($6, coins_balance)
++ WHERE id = $1
++ RETURNING *`,
++ [id, state, error, at, attempts, balance]);
++
++/** Completions whose credit is still owed (pending, or failed and worth retrying), oldest first. */
++const owedCoins = (db, limit = 50) => db.many(
++ `SELECT * FROM quest_completions WHERE coins_state IN ('pending', 'failed') AND coins > 0
++ ORDER BY completed_at, id LIMIT $1`,
++ [limit]);
++
++// `left(coins_at, 10)` (the UTC day of an ISO timestamp) keeps the comparison typed: substr() is overloaded for
++// text and bytea, and with an untyped parameter Postgres cannot tell which one is meant.
++/** OpenCoins credited to one person on a UTC day (by when the credit landed, not when the quest completed). */
++const creditedTo = async (db, subject, day) => Number(await db.value(
++ `SELECT COALESCE(SUM(coins), 0) FROM quest_completions
++ WHERE subject = $1 AND coins_state = 'credited' AND coins_at IS NOT NULL AND left(coins_at, 10) = $2::text`,
++ [subject, day]));
++
++/** OpenCoins credited to everyone on a UTC day — the global cap. */
++const creditedAll = async (db, day) => Number(await db.value(
++ `SELECT COALESCE(SUM(coins), 0) FROM quest_completions
++ WHERE coins_state = 'credited' AND coins_at IS NOT NULL AND left(coins_at, 10) = $1::text`,
++ [day]));
++
++/** How many people have a badge — the count a quest page can show without naming anyone. */
++const badgeHolders = async (db, badgeId) => Number(await db.value('SELECT count(*) FROM quest_badges WHERE badge_id = $1', [badgeId]));
++
++module.exports = {
++ DAY_MS, dayOf,
++ bumpProgress, progressOf, progressFor,
++ completion, completionsFor, completionCount, complete,
++ awardBadge, badgesFor, badgesOf, badgeHolders,
++ setCoins, owedCoins, creditedTo, creditedAll,
++};
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex c8c8940..67d8738 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -24,6 +24,9 @@ const TAGLINE = 'Quests across the whole network.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's own navigation: one entry per public page it serves.
const NAV = [
++ { label: 'Quests', href: '/' },
++ { label: 'Your quest log', href: '/me' },
++ { label: 'How it works', href: '/how-it-works' },
{ label: 'What shipped', href: '/updates' },
];
ddiff --git a/server/render/quests.js b/server/render/quests.js
nnew file mode 100644
iindex 0000000..81f18c1
---- /dev/null
++++ b/server/render/quests.js
@@@ -0,0 +1,61 @@
++'use strict';
++
++/**
++ * Small server-rendered pieces the quest pages share: a quest's reward line, a quest card, the catalog grouped by
++ * site, a progress bar and the OpenCoins note. Plain HTML, no JavaScript, every value through the html`` template
++ * (escaping); the catalog is code, but nothing here assumes its text is harmless.
++ */
++const catalog = require('../quests/catalog');
++const { html } = require('./html');
++
++/**
++ * What a quest pays: its badge (a text label) and its OpenCoins. When OpenCoins are off the reward says so
++ * instead of promising points that do not move — the sentence the product uses everywhere.
++ */
++function reward(quest, { coinsEnabled = false } = {}) {
++ const badge = quest.reward.badge.name;
++ if (quest.reward.coins <= 0) return html`<span class="reward">Badge: <strong>${badge}</strong></span>`;
++ if (!coinsEnabled) return html`<span class="reward">Badge: <strong>${badge}</strong> · <span class="muted">OpenCoins rewards start soon</span></span>`;
++ return html`<span class="reward">Badge: <strong>${badge}</strong> · <strong>${quest.reward.coins}</strong> OpenCoins</span>`;
++}
++
++/** One quest in a list: what it is, why, what it pays, and the events it counts. */
++function card(quest, { coinsEnabled = false } = {}) {
++ return html`<li class="quest">
++ <a class="quest-title" href="/quests/${quest.id}">${quest.title}</a>
++ <p class="quest-why">${quest.why}</p>
++ <p class="quest-steps">${quest.steps.map((s) => html`<span class="step">${s.label}${s.count > 1 ? html` ×${s.count}` : ''} <code>${s.event_type}</code></span>`)}</p>
++ <p class="quest-reward">${reward(quest, { coinsEnabled })}</p>
++</li>`;
++}
++
++/** The catalog grouped by the site whose events each quest counts. */
++function list(groups = catalog.grouped(), { coinsEnabled = false } = {}) {
++ // The heading id is the domain with its dots replaced: an id with a dot is legal but awkward to target.
++ const anchor = (site) => `site-${String(site).replace(/[^a-z0-9-]/gi, '-')}`;
++ return html`${groups.map((g) => html`<section class="site-group" aria-labelledby="${anchor(g.site)}">
++ <h3 id="${anchor(g.site)}"><a href="https://${g.site}">${g.name}</a>${g.what ? html` <small>${g.what}</small>` : ''}</h3>
++ <ul class="quest-list">${g.quests.map((q) => card(q, { coinsEnabled }))}</ul>
++</section>`)}`;
++}
++
++/** A progress bar as a plain <progress> element — readable without JavaScript and by a screen reader. */
++function progress(label, count, target) {
++ const value = Math.min(count, target);
++ return html`<div class="progress"><span class="progress-label">${label}</span>
++ <progress max="${target}" value="${value}">${value} of ${target}</progress>
++ <span class="progress-count">${value} / ${target}</span></div>`;
++}
++
++/** The one sentence about OpenCoins: what they are, and whether they move yet. */
++function coinsNote({ coinsEnabled, dailyCap, globalDailyCap }) {
++ if (!coinsEnabled) {
++ return html`<p class="muted">OpenCoins rewards start soon. A completion is recorded now, and no OpenCoins move
++while <code>QUEST_COINS</code> is off. OpenCoins are loyalty points, never money — and never Vibes.</p>`;
++ }
++ return html`<p class="muted">Completing a quest credits OpenCoins through OpenVibe.Network's wallet, within
++${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone together. OpenCoins are loyalty points,
++never money — and never Vibes.</p>`;
++}
++
++module.exports = { reward, card, list, progress, coinsNote };
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
nnew file mode 100644
iindex 0000000..a31ace0
---- /dev/null
++++ b/test/events-consumer.test.js
@@@ -0,0 +1,279 @@
++'use strict';
++/**
++ * POST /internal/events (server/events-consumer.js): the six things that matter about how the quest log moves.
++ *
++ * - a delivery that is not signed correctly, comes through a proxy, or arrives with no secret configured is
++ * refused before it touches anything;
++ * - the same event twice is the same event once (the openvibe-sdk inbox), and a completion is not paid twice;
++ * - progress counts to the step's target and then completes the quest, awarding its badge once;
++ * - an event that names nobody Quest may credit (no subject, a guest, a service) changes nothing;
++ * - OpenCoins: off by default (skipped), on it credits through Network's wallet with the completion id as the
++ * idempotency key, the two daily caps stop it, and a failure is recorded and retried.
++ *
++ * The stand-in Network (test/helpers/mocks.js) serves /internal/identity/resolve and /internal/coins/credit too,
++ * checked the way Network checks them: a svc:quest token with the capability, and its own app id.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { boot, check, done } = require('./helpers/boot');
++
++const SECRET = 'quest-events-secret-0123456789abcdef';
++const second = 'quest-events-secret-fedcba9876543210';
++const iso = () => new Date().toISOString();
++
++/** A real envelope shape (openvibe-contracts contracts/events/event-envelope.v1.json) for one event type. */
++function envelope(eventType, payload, { actor, subject, source = 'live', id } = {}) {
++ return {
++ event_id: id || ids.newId('event'), event_type: eventType, version: 1, source,
++ actor: actor || { type: 'service', id: source }, timestamp: iso(),
++ subject: subject || { type: 'thing', id: '1' }, payload,
++ };
++}
++const streamStarted = (subject) => envelope('live.stream.started', {
++ stream_id: 7, channel: { username: 'liv', display_name: 'Liv', url: 'https://openvibe.live/@liv', subject: { type: 'user', id: subject } },
++ title: 'A stream', category: null, protocol: 'webrtc', is_nsfw: false, started_at: iso(),
++}, { actor: { type: 'user', id: subject }, subject: { type: 'stream', id: '7' } });
++const chatMessage = (user_subject) => envelope('chat.room.message.created', {
++ message_id: 12, room: { id: 'room_1', slug: 'general', name: 'General' }, text: 'hello', created_at: iso(),
++ user_subject, username: 'liv', display_name: 'Liv',
++}, { source: 'chat', actor: user_subject ? { type: 'user', id: user_subject } : { type: 'service', id: 'chat' } });
++const followCreated = (follower) => envelope('network.follow.created', {
++ follower, target_type: 'channel', target_id: 'usr_' + '0'.repeat(26), revision: 1,
++}, { source: 'network', actor: { type: 'user', id: follower } });
++
++/** One signed delivery, as OpenVibe.Events posts it. */
++function deliver(t, event, { secret = SECRET, headers = {}, rawBody } = {}) {
++ const raw = rawBody || JSON.stringify({ event, seq: 1 });
++ return t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, secret), ...headers } });
++}
++const rows = async (t, sql, params) => t.ctx.s.db.many(sql, params);
++const completions = (t, subject) => rows(t, 'SELECT * FROM quest_completions WHERE subject = $1 ORDER BY completed_at, id', [subject]);
++const badges = (t, subject) => rows(t, 'SELECT * FROM quest_badges WHERE subject = $1', [subject]);
++const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, count FROM quest_progress WHERE subject = $1 AND quest_id = $2 ORDER BY step', [subject, questId])).map((r) => Number(r.count));
++
++(async () => {
++ // ── Without a secret ─────────────────────────────────────
++ await check('with QUEST_EVENTS_SECRET unset the consumer is off (503) and nothing is written', async () => {
++ const t = await boot();
++ try {
++ const u = t.network.addUser('liv');
++ const r = await deliver(t, streamStarted(u.subject));
++ assert.strictEqual(r.status, 503);
++ assert.strictEqual(r.json().code, 'quest.events_disabled');
++ assert.strictEqual((await completions(t, u.subject)).length, 0);
++ } finally { await t.close(); }
++ });
++
++ const t = await boot({ env: { QUEST_EVENTS_SECRET: `${SECRET},${second}` } });
++ const liv = t.network.addUser('liv');
++ const sam = t.network.addUser('sam');
++
++ try {
++ await check('a delivery with no signature, a wrong signature or a tampered body is refused 401', async () => {
++ const event = streamStarted(liv.subject);
++ const raw = JSON.stringify({ event, seq: 1 });
++ const unsigned = await t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json' } });
++ assert.strictEqual(unsigned.status, 401);
++ assert.strictEqual(unsigned.json().code, 'quest.bad_signature');
++ const wrong = await deliver(t, event, { secret: 'a-different-secret-of-32-characters!' });
++ assert.strictEqual(wrong.status, 401);
++ const tampered = await t.get('/internal/events', { method: 'POST', body: `${raw} `, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });
++ assert.strictEqual(tampered.status, 401);
++ assert.strictEqual((await progressOf(t, liv.subject, 'go-live')).length, 0, 'nothing was applied');
++ });
++
++ await check('a delivery that came through a proxy (a forwarding header) is refused 403', async () => {
++ const r = await deliver(t, streamStarted(liv.subject), { headers: { 'x-forwarded-for': '203.0.113.9' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'quest.internal_only');
++ });
++
++ await check('a rotated secret signs too, and a body that is not a delivery is refused 400', async () => {
++ const rotated = await deliver(t, streamStarted(liv.subject), { secret: second });
++ assert.strictEqual(rotated.status, 200);
++ assert.strictEqual(rotated.json().outcome, 'completed');
++ // A signed body that is not a delivery at all never gets past the signature check.
++ const noEvent = JSON.stringify({ seq: 1 });
++ const notADelivery = await t.get('/internal/events', { method: 'POST', body: noEvent, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(noEvent, SECRET) } });
++ assert.strictEqual(notADelivery.status, 401);
++ // A signed delivery whose envelope is malformed (no evt_ id, no type) is refused 400, not applied.
++ const broken = JSON.stringify({ event: { event_id: 'nope', event_type: '' }, seq: 1 });
++ const malformed = await t.get('/internal/events', { method: 'POST', body: broken, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(broken, SECRET) } });
++ assert.strictEqual(malformed.status, 400);
++ assert.strictEqual(malformed.json().code, 'quest.bad_delivery');
++ });
++
++ await check('the same event twice: the second is duplicate:true and changes nothing', async () => {
++ const event = followCreated(sam.subject);
++ const first = await deliver(t, event);
++ const again = await deliver(t, event);
++ assert.strictEqual(first.status, 200);
++ assert.strictEqual(first.json().duplicate, false);
++ assert.strictEqual(again.status, 200);
++ assert.strictEqual(again.json().duplicate, true);
++ assert.strictEqual(again.json().outcome, null);
++ assert.deepStrictEqual(await progressOf(t, sam.subject, 'follow-someone'), [1]);
++ assert.strictEqual((await completions(t, sam.subject)).length, 1, 'one completion, not two');
++ assert.strictEqual((await badges(t, sam.subject)).length, 1);
++ });
++
++ await check('progress counts to the step target, then the quest completes and its badge is awarded once', async () => {
++ const u = t.network.addUser('three-streams');
++ // The first stream completes 'go live once' on its own; the third completes 'stream three times'.
++ assert.strictEqual((await deliver(t, streamStarted(u.subject))).json().outcome, 'completed');
++ assert.strictEqual((await deliver(t, streamStarted(u.subject))).json().outcome, 'progress');
++ assert.deepStrictEqual(await progressOf(t, u.subject, 'stream-three-times'), [2]);
++ const third = await deliver(t, streamStarted(u.subject));
++ assert.strictEqual(third.json().outcome, 'completed');
++ assert.deepStrictEqual(await progressOf(t, u.subject, 'stream-three-times'), [3]);
++ const done = await completions(t, u.subject);
++ assert.ok(done.some((c) => c.quest_id === 'stream-three-times'), 'stream-three-times completed');
++ // The first stream also completed 'go live once': two quests, one event.
++ assert.ok(done.some((c) => c.quest_id === 'go-live'), 'go-live completed by the same first stream');
++ const earned = await badges(t, u.subject);
++ assert.deepStrictEqual(earned.map((b) => b.badge_id).sort(), ['first-broadcast', 'regular-broadcaster']);
++ // A fourth stream raises nothing: the quest is finished and frozen.
++ await deliver(t, streamStarted(u.subject));
++ assert.strictEqual((await completions(t, u.subject)).length, 2, 'no third completion');
++ assert.strictEqual((await badges(t, u.subject)).length, 2, 'no second badge');
++ assert.deepStrictEqual(await progressOf(t, u.subject, 'stream-three-times'), [3], 'progress is frozen at the target');
++ });
++
++ await check('an event that names nobody creditable is ignored: no subject, a guest, a service, an unknown type', async () => {
++ const before = (await completions(t, sam.subject)).length;
++ const guest = 'gst_' + '0'.repeat(26);
++ for (const event of [
++ chatMessage(null), // a private-ish/guest message: no user_subject
++ chatMessage(guest), // a guest id is not a usr_ subject
++ envelope('chat.room.message.created', { message_id: 3, room: { id: 'r', slug: 'g', name: 'G' }, text: 'x', created_at: iso(), user_subject: null }, { source: 'chat' }),
++ envelope('deals.vote.changed', { value: 1 }, { source: 'deals', actor: { type: 'user', id: sam.subject } }), // no person in the payload
++ envelope('quest.something.happened', { user_subject: sam.subject }), // not an event Quest counts
++ ]) {
++ const r = await deliver(t, event);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.json().outcome, /^ignored:/, r.json().outcome);
++ assert.deepStrictEqual(r.json().completed, []);
++ }
++ assert.strictEqual((await completions(t, sam.subject)).length, before, 'nothing completed');
++ assert.deepStrictEqual(await progressOf(t, sam.subject, 'say-hello-in-chat'), [], 'no progress for an ignored event');
++ });
++
++ await check('OpenCoins off (the default): the completion is recorded and its coins are skipped', async () => {
++ const u = t.network.addUser('coins-off');
++ const r = await deliver(t, followCreated(u.subject));
++ assert.strictEqual(r.json().outcome, 'completed');
++ const [c] = await completions(t, u.subject);
++ assert.strictEqual(c.quest_id, 'follow-someone');
++ assert.strictEqual(Number(c.coins), 5);
++ assert.strictEqual(c.coins_state, 'skipped');
++ assert.strictEqual(c.coins_error, null);
++ assert.strictEqual(t.network.coinCredits.length, 0, 'the wallet was never called');
++ assert.deepStrictEqual(await t.ctx.coins.retryPending(), { tried: 0, credited: 0, skipped: 0, failed: 0 }, 'with coins off there is nothing to retry');
++ assert.strictEqual(await t.ctx.coins.start(), null, 'no retry timer while coins are off');
++ const page = await t.get('/me', { as: u });
++ assert.ok(page.text.includes('OpenCoins rewards start soon'), 'the page says the rewards start soon');
++ });
++
++ await check('a signed-in person sees their own progress in the API and on their page, as plain progress bars', async () => {
++ const u = t.network.addUser('log');
++ await deliver(t, streamStarted(u.subject));
++ await deliver(t, chatMessage(u.subject));
++ const api = await t.get('/api/v1/me/quests', { as: u });
++ assert.strictEqual(api.status, 200);
++ const goLive = api.json().quests.find((q) => q.id === 'go-live');
++ assert.strictEqual(goLive.state, 'completed');
++ assert.strictEqual(goLive.completion.coins.state, 'skipped');
++ const settleIn = api.json().quests.find((q) => q.id === 'settle-in');
++ assert.strictEqual(settleIn.state, 'in_progress');
++ assert.deepStrictEqual(settleIn.progress.map((p) => [p.event_type, p.count]), [['community.thread.created', 0], ['chat.room.message.created', 1]]);
++ const page = await t.get('/me', { as: u });
++ assert.match(page.text, /<progress max="3" value="1">1 of 3<\/progress>/, 'a real <progress> element');
++ assert.ok(page.text.includes('First Broadcast'), 'the badge it earned');
++ });
++ } finally { await t.close(); }
++
++ // ── OpenCoins on, with the stand-in Network wallet ───────
++ await check('coins on: a completion is credited through the wallet, keyed by the completion id', async () => {
++ const c = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, QUEST_COINS: 'on' } });
++ try {
++ const u = c.network.addUser('paid');
++ const r = await deliver(c, streamStarted(u.subject));
++ assert.strictEqual(r.json().outcome, 'completed');
++ const done = await completions(c, u.subject);
++ assert.deepStrictEqual(done.map((x) => x.quest_id), ['go-live'], 'one stream completes one quest; "stream three times" is still at 1 of 3');
++ const [goLive] = done;
++ assert.strictEqual(goLive.coins_state, 'credited');
++ const [credit] = c.network.coinCredits;
++ assert.strictEqual(credit.idempotency_key, goLive.id, 'the idempotency key is the completion id');
++ assert.strictEqual(credit.app_id, 'quest');
++ assert.strictEqual(credit.amount, 10);
++ assert.strictEqual(credit.reason, 'quest:go-live');
++ assert.strictEqual(credit.ref, 'https://openvibe.quest/quests/go-live');
++ assert.strictEqual(c.network.coinBalance(u), 10, 'the wallet holds the OpenCoins');
++ assert.strictEqual(Number(goLive.coins_balance), 10);
++ assert.ok(goLive.coins_at, 'the credit is timestamped (the day the caps count)');
++ } finally { await c.close(); }
++ });
++
++ await check('coins on: the per-person daily cap is enforced — over it, skipped with the reason and no credit', async () => {
++ const c = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, QUEST_COINS: 'on', QUEST_COINS_DAILY_CAP: '5' } });
++ try {
++ const u = c.network.addUser('capped');
++ await deliver(c, followCreated(u.subject)); // 5 OpenCoins: inside the cap
++ await deliver(c, chatMessage(u.subject)); // 5 more: over it
++ const all = await completions(c, u.subject);
++ const follow = all.find((x) => x.quest_id === 'follow-someone');
++ const hello = all.find((x) => x.quest_id === 'say-hello-in-chat');
++ assert.strictEqual(follow.coins_state, 'credited');
++ assert.strictEqual(hello.coins_state, 'skipped');
++ assert.match(hello.coins_error, /daily cap of 5 OpenCoins per person/);
++ assert.strictEqual(c.network.coinCredits.length, 1, 'the second quest was not sent to the wallet');
++ assert.strictEqual(c.network.coinBalance(u), 5);
++ assert.strictEqual((await badges(c, u.subject)).length, 2, 'the badge is still awarded');
++ } finally { await c.close(); }
++ });
++
++ await check('coins on: the network-wide daily cap is enforced', async () => {
++ const c = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, QUEST_COINS: 'on', QUEST_COINS_DAILY_CAP: '100', QUEST_COINS_GLOBAL_DAILY_CAP: '5' } });
++ try {
++ const a = c.network.addUser('first');
++ const b = c.network.addUser('second');
++ await deliver(c, followCreated(a.subject)); // 5: exactly the global cap
++ await deliver(c, followCreated(b.subject)); // over it, for someone else
++ const second = (await completions(c, b.subject))[0];
++ assert.strictEqual(second.coins_state, 'skipped');
++ assert.match(second.coins_error, /network-wide daily cap of 5 OpenCoins/);
++ assert.strictEqual(c.network.coinCredits.length, 1);
++ } finally { await c.close(); }
++ });
++
++ await check('coins on: a credit failure is recorded with its reason, then retried and credited', async () => {
++ const c = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, QUEST_COINS: 'on' } });
++ try {
++ const u = c.network.addUser('unlucky');
++ c.network.setCoinsDown(true);
++ const r = await deliver(c, followCreated(u.subject));
++ assert.strictEqual(r.status, 200, 'the delivery still succeeds: the completion is committed first');
++ let [completion] = await completions(c, u.subject);
++ assert.strictEqual(completion.coins_state, 'failed');
++ assert.match(completion.coins_error, /Network 503/);
++ assert.strictEqual((await badges(c, u.subject)).length, 1, 'the badge is awarded regardless');
++
++ c.network.setCoinsDown(false);
++ const out = await c.ctx.coins.retryPending();
++ assert.strictEqual(out.credited, 1);
++ [completion] = await completions(c, u.subject);
++ assert.strictEqual(completion.coins_state, 'credited');
++ assert.strictEqual(completion.coins_error, null);
++ assert.strictEqual(Number(completion.coins_attempts), 2, 'the first failure and the retry');
++ assert.strictEqual(c.network.coinCredits[1].idempotency_key, completion.id);
++ assert.strictEqual(c.network.coinBalance(u), 5);
++
++ // Retrying again pays nothing more: there is nothing owed, and the key would not double-credit anyway.
++ assert.strictEqual((await c.ctx.coins.retryPending()).tried, 0);
++ assert.strictEqual(c.network.coinBalance(u), 5);
++ } finally { await c.close(); }
++ });
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex b20ed7b..d38bd0a 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -14,6 +14,10 @@
* answers what setUsage() stored for the project, or an empty network.project-usage-result@1.
* POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,
* sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.
++ * GET /internal/identity/resolve?subject_id=usr_… (the projection with network_user_id) and
++ * POST /internal/coins/credit (the OpenCoins wallet), both as OpenVibe.Quest's OpenCoins client
++ * needs them: the caller must be svc:quest with the capability in its token, the credit is
++ * idempotent by idempotency_key, and setCoinsDown(true) makes the wallet refuse.
* Every request is recorded in `.requests` so tests can assert what was (not) called.
*/
const http = require('http');
@@@ -56,6 +60,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
++ coins: { balances: new Map(), byKey: new Map(), credits: [], down: false },
};
let issuer = null;
let nextUserId = 1;
@@@ -83,6 +88,20 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
return st.users.get(c.sub) || null;
}
++ /** The verified claims of a service token this mock issued, or null (bad signature, expired, not a service). */
++ function serviceFrom(req) {
++ const h = String(req.headers.authorization || '');
++ if (!h.startsWith('Bearer ')) return null;
++ const parts = h.slice(7).split('.');
++ if (parts.length !== 3) return null;
++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));
++ if (!ok) return null;
++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ if (c.exp * 1000 < Date.now() || !String(c.sub || '').startsWith('svc:')) return null;
++ return c;
++ }
++ const hasCap = (claims, cap) => Array.isArray(claims.cap) && claims.cap.includes(cap);
++
// ── Projects model ──
const roleOf = (p, u) => (st.members.get(p.id) || new Map()).get(u.subject) || null;
const projectView = (p, role) => ({
@@@ -298,6 +317,35 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });
return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
}
++ // ── The OpenCoins wallet and subject resolution (OpenVibe.Quest's client) ──
++ // Both are internal: a service token with the right capability, never a person's token.
++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ const claims = serviceFrom(req);
++ if (!claims || !hasCap(claims, 'identity.subject.resolve')) return problem(json, 403, 'capability.denied', 'identity.subject.resolve is required');
++ const u = [...st.users.values()].find((x) => x.subject === url.searchParams.get('subject_id'));
++ if (!u) return problem(json, 404, 'identity.subject_not_found', 'no subject for that id');
++ return json(200, { subject: { type: 'user', id: u.subject }, network_user_id: u.id, username: u.username, display_name: u.display_name, banned: false });
++ }
++ if (url.pathname === '/internal/coins/credit' && req.method === 'POST') {
++ const claims = serviceFrom(req);
++ if (!claims || !hasCap(claims, 'network.coins.credit')) return problem(json, 403, 'capability.denied', 'network.coins.credit is required');
++ let body = {};
++ try { body = raw.length ? JSON.parse(raw.toString('utf8')) : {}; } catch { return json(400, { error: 'request.malformed_json' }); }
++ st.coins.credits.push(body);
++ if (st.coins.down) return json(503, { error: 'wallet_unavailable' });
++ // Network's ownApp rule: svc:quest may only name its own app id.
++ if (String(body.app_id) !== claims.sub.slice(4)) return problem(json, 403, 'capability.owner_denied', `svc:${claims.sub.slice(4)} may only credit app ${claims.sub.slice(4)}`);
++ if (!Number.isInteger(body.amount) || body.amount < 1) return json(400, { error: 'invalid_amount' });
++ if (typeof body.idempotency_key !== 'string' || !body.idempotency_key) return json(400, { error: 'missing_idempotency_key' });
++ const userId = Number(body.user_id);
++ if (!st.users.has(userId)) return json(404, { error: 'user_not_found' });
++ const seen = st.coins.byKey.get(body.idempotency_key);
++ if (seen !== undefined) return json(200, { balance: seen }); // a replay changes nothing
++ const balance = (st.coins.balances.get(userId) || 0) + body.amount;
++ st.coins.balances.set(userId, balance);
++ st.coins.byKey.set(body.idempotency_key, balance);
++ return json(200, { balance });
++ }
if (url.pathname.startsWith('/api/v1/projects')) {
const u = userFrom(req);
if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
@@@ -321,6 +369,10 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },
setCatalog(list) { st.catalog = list; },
setDown(v) { st.down = v; },
++ /** The OpenCoins wallet: what it credited, and how to make it refuse (a 503, as an outage does). */
++ get coinCredits() { return st.coins.credits; },
++ coinBalance(user) { return st.coins.balances.get(user.id) || 0; },
++ setCoinsDown(v) { st.coins.down = Boolean(v); },
mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
close: srv.close,
};
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 898c396..220dccb 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -14,8 +14,10 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// headroom; file counts are the measured counts and external files stay at 0.
const BUDGETS = {
-- htmlRawKB: 27.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
-- htmlBrotliKB: 6.9, // 6.2
++ // Raised 2026-10-08 with the product: the home page now carries the quest catalog (12 quests, grouped by
++ // site, each with its reward) and the list of the events Quest counts. Measured 34.0 KB raw / 7.7 KB brotli.
++ htmlRawKB: 37.5, // measured 34.0 (the home page: hero, features, the quests, the events, cta, JSON-LD)
++ htmlBrotliKB: 8.5, // 7.7
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
ddiff --git a/test/quests-api.test.js b/test/quests-api.test.js
nnew file mode 100644
iindex 0000000..b99261e
---- /dev/null
++++ b/test/quests-api.test.js
@@@ -0,0 +1,181 @@
++'use strict';
++/**
++ * /api/v1/quests, /api/v1/quests/:id, /api/v1/me/quests and /api/v1/profiles/:subject/badges, plus the pages that
++ * carry them:
++ *
++ * - the public catalog needs no token and is grouped by site, with each reward;
++ * - /me/quests is a person's own log: it requires sign-in, shows only their rows, and nobody else's;
++ * - the public badge endpoint shows an id, a name and a date and nothing else about the person;
++ * - every page is server-rendered and complete without JavaScript;
++ * - a hostile title is escaped everywhere it is rendered.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const catalog = require('../server/quests/catalog');
++const renderQuests = require('../server/render/quests');
++const { render } = require('../server/render/html');
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const ana = t.network.addUser('ana');
++
++ try {
++ await check('GET /api/v1/quests: public, grouped by site, every quest with its reward and its steps', async () => {
++ const r = await t.get('/api/v1/quests');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('cache-control') || '', /no-store/);
++ const body = r.json();
++ assert.strictEqual(body.quests.length, catalog.all().length);
++ assert.ok(body.quests.length >= 8 && body.quests.length <= 12, `${body.quests.length} quests`);
++ assert.deepStrictEqual(body.sites.map((s) => s.site), catalog.SITE_ORDER, 'grouped in catalog order');
++ assert.strictEqual(body.sites.reduce((n, s) => n + s.quests.length, 0), catalog.all().length, 'every quest is in exactly one group');
++ const live = body.sites.find((s) => s.site === 'openvibe.live');
++ assert.strictEqual(live.name, 'OpenVibe.Live');
++ const goLive = body.quests.find((q) => q.id === 'go-live');
++ assert.strictEqual(goLive.title, 'Go live once');
++ assert.deepStrictEqual(goLive.steps, [{ event_type: 'live.stream.started', count: 1, label: 'Start a stream' }]);
++ assert.deepStrictEqual(goLive.reward, { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10 });
++ assert.strictEqual(goLive.repeatable, false);
++ assert.strictEqual(body.coins.enabled, false, 'OpenCoins are off by default');
++ assert.strictEqual(body.coins.daily_cap, 50);
++ });
++
++ await check('every quest counts only events the catalog documents, and no quest pays Vibes', () => {
++ for (const q of catalog.all()) {
++ for (const step of q.steps) assert.ok(catalog.EVENT_TYPES.includes(step.event_type), `${q.id} counts ${step.event_type}`);
++ assert.ok(Number.isInteger(q.reward.coins) && q.reward.coins >= 0, `${q.id} coins`);
++ assert.ok(typeof q.reward.badge.name === 'string' && /^[A-Za-z ]+$/.test(q.reward.badge.name), `${q.id} badge label is plain text`);
++ assert.strictEqual(q.repeatable, false);
++ assert.ok(catalog.SITES[q.site], `${q.id} has a real site`);
++ }
++ });
++
++ await check('GET /api/v1/quests/:id answers one quest, and a problem+json 404 for an unknown one', async () => {
++ const ok = await t.get('/api/v1/quests/improve-a-wiki-page');
++ assert.strictEqual(ok.status, 200);
++ assert.strictEqual(ok.json().site, 'openvibe.wiki');
++ assert.strictEqual(ok.json().url, '/quests/improve-a-wiki-page');
++ const missing = await t.get('/api/v1/quests/not-a-quest');
++ assert.strictEqual(missing.status, 404);
++ assert.match(missing.headers.get('content-type'), /application\/problem\+json/);
++ assert.strictEqual(missing.json().code, 'quest.not_found');
++ });
++
++ await check('GET /api/v1/me/quests requires sign-in and shows only the caller\'s own log', async () => {
++ const anon = await t.get('/api/v1/me/quests');
++ assert.strictEqual(anon.status, 401);
++ assert.match(anon.headers.get('content-type'), /application\/problem\+json/);
++ assert.strictEqual(anon.json().code, 'token.required');
++
++ await t.ctx.s.db.query('INSERT INTO quest_progress (subject, quest_id, step, count, updated_at) VALUES ($1, $2, 0, 2, $3)', [kim.subject, 'stream-three-times', new Date().toISOString()]);
++ await t.ctx.s.db.query('INSERT INTO quest_completions (id, subject, quest_id, completed_at, coins, coins_state) VALUES ($1, $2, $3, $4, 5, $5)', ['qcp_kim', kim.subject, 'follow-someone', new Date().toISOString(), 'skipped']);
++
++ const mine = await t.get('/api/v1/me/quests', { as: kim });
++ assert.strictEqual(mine.status, 200);
++ assert.strictEqual(mine.json().subject, kim.subject);
++ assert.strictEqual(mine.json().quests.length, catalog.all().length);
++ const started = mine.json().quests.find((q) => q.id === 'stream-three-times');
++ assert.strictEqual(started.state, 'in_progress');
++ assert.deepStrictEqual(started.progress.map((p) => p.count), [2]);
++ assert.strictEqual(mine.json().quests.find((q) => q.id === 'follow-someone').state, 'completed');
++ assert.deepStrictEqual(mine.json().completions.map((c) => c.id), ['qcp_kim']);
++ assert.strictEqual(mine.json().coins.enabled, false);
++
++ const hers = await t.get('/api/v1/me/quests', { as: ana });
++ assert.strictEqual(hers.json().subject, ana.subject);
++ assert.deepStrictEqual(hers.json().completions, [], 'ana sees nothing of kim');
++ assert.deepStrictEqual(hers.json().badges, []);
++ assert.ok(!hers.text.includes('qcp_kim'), 'kim\'s completion id is not in ana\'s answer');
++ });
++
++ await check('GET /api/v1/profiles/:subject/badges: public, and only ids, names and dates', async () => {
++ await t.ctx.s.db.query('INSERT INTO quest_badges (subject, badge_id, quest_id, awarded_at) VALUES ($1, $2, $3, $4)', [kim.subject, 'first-broadcast', 'go-live', '2026-10-01T10:00:00.000Z']);
++
++ const anon = await t.get(`/api/v1/profiles/${kim.subject}/badges`);
++ assert.strictEqual(anon.status, 200, 'no token needed');
++ const body = anon.json();
++ assert.strictEqual(body.subject, kim.subject);
++ assert.deepStrictEqual(body.badges, [{ id: 'first-broadcast', name: 'First Broadcast', awarded_at: '2026-10-01T10:00:00.000Z' }]);
++ for (const b of body.badges) assert.deepStrictEqual(Object.keys(b).sort(), ['awarded_at', 'id', 'name'], 'nothing else about the person');
++ assert.ok(!anon.text.includes('qcp_'), 'no completion');
++ assert.ok(!anon.text.includes(kim.username), 'no username');
++ assert.ok(!anon.text.includes('coins'), 'no coins');
++
++ const nobody = await t.get('/api/v1/profiles/usr_01JZZZZZZZZZZZZZZZZZZZZZZZ/badges');
++ assert.strictEqual(nobody.status, 200);
++ assert.deepStrictEqual(nobody.json().badges, []);
++ const hostile = await t.get('/api/v1/profiles/%3Cscript%3Ealert(1)%3C%2Fscript%3E/badges');
++ assert.strictEqual(hostile.status, 404);
++ assert.strictEqual(hostile.json().code, 'quest.profile.not_found');
++ });
++
++ await check('every page is server-rendered, complete without JavaScript, and answers 200', async () => {
++ for (const p of ['/', '/how-it-works', '/quests/go-live', '/updates']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++ assert.match(r.headers.get('content-type'), /^text\/html/, p);
++ assert.ok(r.text.includes('<main id="main"'), `${p}: no main`);
++ assert.ok(/<noscript>/.test(r.text), `${p}: no noscript fallback`);
++ }
++ const quest = await t.get('/quests/go-live');
++ assert.ok(quest.text.includes('Go live once'), 'the title');
++ assert.ok(quest.text.includes('live.stream.started'), 'the event it counts');
++ assert.ok(quest.text.includes('First Broadcast'), 'its badge');
++ assert.ok(quest.text.includes('OpenCoins rewards start soon'), 'coins are honestly off');
++ const home = await t.get('/');
++ assert.ok(home.text.includes('OpenVibe.Live'), 'the quests are grouped by site');
++ assert.ok(home.text.includes('Say hello in chat'), 'a chat quest is listed');
++ assert.ok(home.text.includes('chat.room.message.created'), 'the events are listed');
++ const how = await t.get('/how-it-works');
++ assert.ok(how.text.includes('never money'), 'the rules say what rewards are');
++ assert.ok(how.text.includes('Vibes'), 'and that they are never Vibes');
++ assert.ok(how.text.includes('QUEST_COINS') || how.text.includes('OpenCoins rewards start soon'), 'the coins state');
++ });
++
++ await check('/me without a session asks for sign-in; with one it shows the person\'s own log', async () => {
++ const anon = await t.get('/me');
++ assert.strictEqual(anon.status, 200);
++ assert.ok(anon.text.includes('Sign in with OpenVibe'), 'a sign-in prompt');
++ assert.ok(!anon.text.includes(kim.username), 'nothing about anyone');
++ assert.match(anon.headers.get('cache-control') || '', /no-store|private/);
++
++ const mine = await t.get('/me', { as: kim });
++ assert.strictEqual(mine.status, 200);
++ assert.ok(mine.text.includes('<progress max="3" value="2">2 of 3</progress>'), 'a real progress bar for the started quest');
++ assert.ok(mine.text.includes('First Broadcast'), 'the badge');
++ assert.ok(mine.text.includes('Completed'), 'the completion');
++ assert.ok(mine.text.includes(`/api/v1/profiles/${kim.subject}/badges`), 'it says what is public');
++ assert.ok(!mine.text.includes(ana.subject), 'nothing about anyone else');
++ });
++
++ await check('crawl artifacts cover the quest pages and keep the private ones out', async () => {
++ const sitemap = await t.get('/sitemap.xml');
++ for (const q of catalog.all()) assert.ok(sitemap.text.includes(`<loc>https://openvibe.quest/quests/${q.id}</loc>`), `sitemap misses ${q.id}`);
++ assert.ok(sitemap.text.includes('<loc>https://openvibe.quest/how-it-works</loc>'));
++ const robots = await t.get('/robots.txt');
++ assert.ok(robots.text.includes('Disallow: /me'), 'the private log is not crawled');
++ const llms = await t.get('/llms.txt');
++ assert.ok(llms.text.includes('(https://openvibe.quest/quests/go-live)'), 'llms.txt lists the quests');
++ const full = await t.get('/llms-full.txt');
++ assert.ok(full.text.includes('https://openvibe.quest/quests/go-live'), 'llms-full.txt has every quest page');
++ });
++
++ await check('a hostile title is escaped in the renderer and on the page', async () => {
++ const hostile = {
++ id: 'x', title: '<script>alert(1)</script>', why: '<img src=x onerror=alert(2)>',
++ steps: [{ event_type: 'live.stream.started', count: 1, label: '<b>go</b>' }],
++ reward: { badge: { id: 'b', name: '<i>Badge</i>' }, coins: 5 }, repeatable: false,
++ };
++ const cardHtml = render(renderQuests.card(hostile, { coinsEnabled: true }));
++ assert.ok(!cardHtml.includes('<script>') && !cardHtml.includes('<img') && !cardHtml.includes('<b>go</b>'), 'no raw markup from data');
++ assert.ok(cardHtml.includes('&lt;script&gt;alert(1)&lt;/script&gt;'), 'escaped, not stripped');
++ assert.ok(cardHtml.includes('&lt;i&gt;Badge&lt;/i&gt;'), 'the badge label is escaped too');
++
++ const page = await t.get('/quests/%3Cscript%3Ealert(1)%3C%2Fscript%3E');
++ assert.strictEqual(page.status, 404);
++ assert.ok(!page.text.includes('<script>alert(1)'), 'the requested path is never echoed as markup');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });