openvibers/openvibe.quest Commits
Quest 0.2.0: the quest log, the events consumer and OpenCoins
Changes
@@@ -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@@@ -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.@@@ -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:4980Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm runtest: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/workReporting 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.---@@@ -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"}@@@ -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;nnew file mode 100644@@@ -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)++);@@@ -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",@@@ -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; }@@@ -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, {@@@ -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)),++ },};}nnew file mode 100644@@@ -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 };@@@ -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 };@@@ -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') },@@@ -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,@@@ -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) {@@@ -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 };}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};@@@ -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' },];nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });@@@ -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.musers: 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.mreturn 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.mif (!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.msetQuota(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,};@@@ -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.7jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 264, // 239.8jsBrotliKB: 62.5, // 56.5nnew file mode 100644@@@ -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('<script>alert(1)</script>'), 'escaped, not stripped');++ assert.ok(cardHtml.includes('<i>Badge</i>'), '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); });
@@ -26,3 +26,20 @@ OV_OAUTH_CLIENT_SECRET=
@@ -26,3 +26,20 @@ OV_OAUTH_CLIENT_SECRET=
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# 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
@@ -2,6 +2,29 @@
@@ -2,6 +2,29 @@
What changed in OpenVibe.Quest, newest first. Each site also publishes its patch notes at /updates.
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
## 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.
- **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.
@@ -2,48 +2,96 @@
@@ -2,48 +2,96 @@
> Quests across the whole network.
> 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`
**Domain:** `openvibe.quest` · **Port:** 4980 · **Service id:** `quest` · **Env prefix:** `QUEST`
**License:** AGPL-3.0 (same as every OpenVibe service).
**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 |
| 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
## API
| Route | Who | |
| Route | Who | |
|---|---|---|
|---|---|---|
| `GET /api/v1/ping` | anyone | `{ ok: true, service: "quest" }` |
| `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
## 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
## Development
@@ -56,12 +104,20 @@ fnm exec --using=22 npm run dev # http://localhost:4980
@@ -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
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)).
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 (for the lead)
- **Deploy:** `sudo ovhost deploy quest` on the host (git checkout at `/opt/openvibe.quest`, unit
- **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).
`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`.
- **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.
- **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
- Register the service and its capabilities in **OpenVibe.Contracts** (`contracts-service: quest` in CI) and with
**OpenVibe.Services** before the first deploy.
**OpenVibe.Services** before the first deploy.
@@ -70,10 +126,14 @@ test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/work
@@ -70,10 +126,14 @@ test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/work
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
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.
- 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.
---
---
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
{
{
- "stage": "skeleton",
+ "stage": "alpha",
"code": true,
"code": true,
"wave": "W20",
"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",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4980",
"port": "4980",
"domain": "openvibe.quest",
"domain": "openvibe.quest",
@@ -14,33 +14,38 @@
@@ -14,33 +14,38 @@
"openvibe-sdk v0.35.2",
"openvibe-sdk v0.35.2",
"openvibe-shared v2.14.0"
"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": [
"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": [
"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,
"capabilitiesRegistered": false,
"serviceManifestRegistered": false,
"serviceManifestRegistered": false,
"deployed": false,
"deployed": false,
"placeholderInSites": false,
"placeholderInSites": false,
"features": [
"features": [
- "Network sign-in",
+ "quest catalog",
+ "OpenVibe.Events consumer",
+ "OpenCoins through Network's wallet",
+ "public API",
"server-rendered pages",
"server-rendered pages",
- "api mount",
- "limits and readiness",
- "deploy files"
+ "crawl artifacts"
],
],
"updated": "2026-10-08"
"updated": "2026-10-08"
}
}
@@ -99,6 +99,12 @@ server {
@@ -99,6 +99,12 @@ server {
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4980/metrics) ──
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4980/metrics) ──
location = /metrics { return 404; }
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 ─────
# ── Pages, docs, robots.txt, sitemap, static assets ─────
location / {
location / {
proxy_pass http://127.0.0.1:4980;
proxy_pass http://127.0.0.1:4980;
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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)
+);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-quest",
"name": "openvibe-quest",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
"description": "OpenVibe.Quest — Quests across the whole network.",
"description": "OpenVibe.Quest — Quests across the whole network.",
"main": "server/index.js",
"main": "server/index.js",
@@ -42,3 +42,24 @@ form.inline { display: inline; }
@@ -42,3 +42,24 @@ form.inline { display: inline; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
button.sc-btn { background: transparent; }
button.sc-btn { background: transparent; }
button.sc-btn.sc-primary { border: 0; }
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; }
@@ -28,6 +28,8 @@ const { createSso } = require('./auth/sso');
@@ -28,6 +28,8 @@ const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
+const { createCoins } = require('./quests/coins');
+const { createEventsConsumer } = require('./events-consumer');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
@@ -49,7 +51,9 @@ async function createApp(opts = {}) {
@@ -49,7 +51,9 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -89,7 +93,7 @@ async function createApp(opts = {}) {
@@ -89,7 +93,7 @@ async function createApp(opts = {}) {
app.use(cookieParser());
app.use(cookieParser());
// ── Machine endpoints ───────────────────────────────────
// ── 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 });
release.mount(app, { registry: metrics.registry });
const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
@@ -102,6 +106,9 @@ async function createApp(opts = {}) {
@@ -102,6 +106,9 @@ async function createApp(opts = {}) {
app.use('/auth', sso.routes());
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' })); }
{ 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) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
app.use(express.static(PUBLIC_DIR, {
app.use(express.static(PUBLIC_DIR, {
@@ -52,6 +52,26 @@ function load(env = process.env) {
@@ -52,6 +52,26 @@ function load(env = process.env) {
sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
},
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
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)),
+ },
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -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 };
@@ -3,28 +3,125 @@
@@ -3,28 +3,125 @@
/**
/**
* /api/v1 — OpenVibe.Quest's API.
* /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 express = require('express');
+const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
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) {
function createApi(ctx) {
- const { config, principal, limits } = ctx;
+ const { config, s, principal, limits, coins } = ctx;
const r = asyncRouter();
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(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use(principal.middleware);
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 }));
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;
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 };
@@ -12,14 +12,18 @@ const path = require('path');
@@ -12,14 +12,18 @@ const path = require('path');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
+const catalog = require('../quests/catalog');
const SITE_NAME = 'OpenVibe.Quest';
const SITE_NAME = 'OpenVibe.Quest';
const DESCRIPTION = 'OpenVibe.Quest — Quests across the whole network.';
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 = {
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.'],
'/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) {
function dayOf(ts) {
@@ -41,6 +45,8 @@ function homeJsonLd(config) {
@@ -41,6 +45,8 @@ function homeJsonLd(config) {
const publicPages = () => [
const publicPages = () => [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ 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 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
];
];
@@ -65,8 +71,11 @@ function createDiscoveryRoutes(ctx) {
@@ -65,8 +71,11 @@ function createDiscoveryRoutes(ctx) {
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Quest', url: abs('/'), note: 'Quests across the whole network.' },
{ 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: '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: 'Machine-readable', links: [
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
@@ -1,11 +1,13 @@
@@ -1,11 +1,13 @@
'use strict';
'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.
* 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).
* 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 ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
@@ -13,18 +15,38 @@ const showcase = require('openvibe-shared/showcase');
@@ -13,18 +15,38 @@ const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
-const { html, raw } = require('../render/html');
+const { html, raw, table } = require('../render/html');
const { send } = require('../render/layout');
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 SITE_NAME = 'OpenVibe.Quest';
const TAGLINE = 'Quests across the whole network.';
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) {
function createPageRoutes(ctx) {
- const { config } = ctx;
+ const { config, s, coins } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
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 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 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 ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
r.get('/', (req, res) => {
r.get('/', (req, res) => {
@@ -32,11 +54,11 @@ function createPageRoutes(ctx) {
@@ -32,11 +54,11 @@ function createPageRoutes(ctx) {
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: SITE_NAME,
title: SITE_NAME,
accent: TAGLINE,
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)
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, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
@@ -44,30 +66,183 @@ function createPageRoutes(ctx) {
@@ -44,30 +66,183 @@ function createPageRoutes(ctx) {
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
body: html`${raw(hero)}
${raw(showcase.features({
${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: [
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 ───────────────────────────────────────
// ── The update log ───────────────────────────────────────
r.get('/updates', (req, res) => page(req, res, {
r.get('/updates', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
@@ -20,9 +20,10 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
@@ -20,9 +20,10 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
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 = [];
const CAPABILITIES = [];
function decodePayload(token) {
function decodePayload(token) {
@@ -8,13 +8,15 @@ const { createApp } = require('./app');
@@ -8,13 +8,15 @@ const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
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({
return gracefulStop({
name: 'OpenVibe.Quest', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
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() {
@@ -28,7 +30,16 @@ async function start() {
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
ctx.keys.client.start();
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 };
return { server, ctx };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,205 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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,
+};
@@ -24,6 +24,9 @@ const TAGLINE = 'Quests across the whole network.';
@@ -24,6 +24,9 @@ const TAGLINE = 'Quests across the whole network.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's own navigation: one entry per public page it serves.
// The product's own navigation: one entry per public page it serves.
const NAV = [
const NAV = [
+ { label: 'Quests', href: '/' },
+ { label: 'Your quest log', href: '/me' },
+ { label: 'How it works', href: '/how-it-works' },
{ label: 'What shipped', href: '/updates' },
{ label: 'What shipped', href: '/updates' },
];
];
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,279 @@
@@ -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); });
@@ -14,6 +14,10 @@
@@ -14,6 +14,10 @@
* answers what setUsage() stored for the project, or an empty network.project-usage-result@1.
* 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,
* 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.
* 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.
* Every request is recorded in `.requests` so tests can assert what was (not) called.
*/
*/
const http = require('http');
const http = require('http');
@@ -56,6 +60,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -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(),
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(),
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,
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
+ coins: { balances: new Map(), byKey: new Map(), credits: [], down: false },
};
};
let issuer = null;
let issuer = null;
let nextUserId = 1;
let nextUserId = 1;
@@ -83,6 +88,20 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -83,6 +88,20 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
return st.users.get(c.sub) || null;
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 ──
// ── Projects model ──
const roleOf = (p, u) => (st.members.get(p.id) || new Map()).get(u.subject) || null;
const roleOf = (p, u) => (st.members.get(p.id) || new Map()).get(u.subject) || null;
const projectView = (p, role) => ({
const projectView = (p, role) => ({
@@ -298,6 +317,35 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -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}` });
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(' ') });
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')) {
if (url.pathname.startsWith('/api/v1/projects')) {
const u = userFrom(req);
const u = userFrom(req);
if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
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
@@ -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() }); },
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; },
setCatalog(list) { st.catalog = list; },
setDown(v) { st.down = v; },
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); },
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,
close: srv.close,
};
};
@@ -14,8 +14,10 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -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%
// 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.
// headroom; file counts are the measured counts and external files stay at 0.
const BUDGETS = {
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)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
jsBrotliKB: 62.5, // 56.5
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -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('<script>alert(1)</script>'), 'escaped, not stripped');
+ assert.ok(cardHtml.includes('<i>Badge</i>'), '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); });