Mirror of github.com/OpenVibers/OpenVibe.Quest
  • JavaScript 97.2%
  • CSS 2.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
OpenVibers 00fedb8991
openvibe-contracts v0.216.0 (was v0.215.0) (#98)
Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.216.0 release.
2026-10-27 04:57:57 -07:00
.github/workflows Generated from the OpenVibe service skeleton 2026-10-08 14:49:16 -07:00
deploy Quest: nginx refuses /internal/ and /metrics case-insensitively (#7) 2026-10-09 17:54:40 -07:00
migrations Account merges (ADR-029): network.subject.merged folds the merged account's progress (higher count per step), completions (one per quest, owed coins to the survivor) and badges (first award) into the survivor's, once per merge; how-it-works says what Quest does instead of 'does not do yet' (it already deleted and exported accounts) (#65) 2026-10-18 15:09:21 -07:00
public/css Phones: list grids size their columns with minmax(0, 1fr) (or a min(…, 100%) minimum), so one long line can never widen a page past the screen (#84) 2026-10-25 03:36:10 -07:00
server /auth/me is not counted by the sign-in limiter: 60 page views in 15 minutes made a signed-in visitor look signed out (#94) 2026-10-26 22:14:55 -07:00
test openvibe-shared v4.15.0 (was v4.14.0) (#92) 2026-10-26 19:47:28 -07:00
.env.example Quest 0.3.0: item rewards — six quests also give one of Live's items in OpenVibe.Inventory (Sparkle, Hearts, Basic Cap, Fire, Ice and Rainbow Name), granted with Quest's own token after the completion commits, retried, and given to earlier finishers too (#3) 2026-10-09 10:05:12 -07:00
.gitignore Generated from the OpenVibe service skeleton 2026-10-08 14:49:16 -07:00
CHANGELOG.md Quest 0.3.0: item rewards — six quests also give one of Live's items in OpenVibe.Inventory (Sparkle, Hearts, Basic Cap, Fire, Ice and Rainbow Name), granted with Quest's own token after the completion commits, retried, and given to earlier finishers too (#3) 2026-10-09 10:05:12 -07:00
LICENSE Generated from the OpenVibe service skeleton 2026-10-08 14:49:16 -07:00
package-lock.json openvibe-contracts v0.216.0 (was v0.215.0) (#98) 2026-10-27 04:57:57 -07:00
package.json openvibe-contracts v0.216.0 (was v0.215.0) (#98) 2026-10-27 04:57:57 -07:00
README.md openvibe-contracts v0.216.0 (was v0.215.0) (#98) 2026-10-27 04:57:57 -07:00
SECURITY.md Generated from the OpenVibe service skeleton 2026-10-08 14:49:16 -07:00
STATUS.json openvibe-contracts v0.216.0 (was v0.215.0) (#98) 2026-10-27 04:57:57 -07:00

OpenVibe.Quest

Quests across the whole network.

Status: first release. The quest log, the events consumer, the OpenCoins crediting, the item rewards and the pages are written and tested; it is deployed, and OpenCoins have been credited in production since 2026-10-14 (QUEST_COINS=on, within the daily caps). Domain: openvibe.quest · Port: 4980 · Service id: quest · Env prefix: QUEST License: AGPL-3.0 (same as every OpenVibe service).

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.

Owns

OpenVibe.Quest owns the quest log and the catalog that describes it.

It is the authority for a person's quest progress, completions and badges, and for the quest.* namespace. Migration 0001_initial.sql creates no table.

Does not own

  • Identity — the usr_… subjects, the Network user id and the sign-in session are OpenVibe.Network's. Quest stores the subject a delivery names and resolves the wallet id on demand; it issues and verifies no identity itself.
  • Events — the events and their delivery belong to OpenVibe.Events. Quest runs no activity of its own and only consumes what Events delivers.
  • Items and their definitions — the items Quest grants (Sparkle, Hearts, Basic Cap, Fire Name, Ice Name, Rainbow Name) are OpenVibe.Live's, and the instances are OpenVibe.Inventory's. Quest holds no item row.
  • The wallet and the OpenCoins ledger — the balance lives in OpenVibe.Network's wallet. Quest holds no balance and reads none.
  • The sites' content — threads, posts, comments, chat messages, wiki revisions, streams and job inputs belong to OpenVibe.Community, OpenVibe.Space, OpenVibe.Chat, OpenVibe.Wiki, OpenVibe.Live and OpenVibe.Tools. Quest counts that they happened and stores none of them.

Depends on

Depends on For Env vars
OpenVibe.Network sign-in with PKCE S256 (OAuth client quest), its JWKS, identity.subject.resolve, network.coins.credit, and the account export/deletion sender OV_NETWORK_URL, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET
OpenVibe.Events the deliveries to POST /internal/events and the subscriptions it creates at boot QUEST_EVENTS_URL, QUEST_EVENTS_SECRET, QUEST_EVENTS_ENDPOINT
OpenVibe.Inventory granting an item reward, and reading Live's item definitions by alias OV_INVENTORY_INTERNAL_URL
PostgreSQL the store (every migration and table) DATABASE_URL, DATABASE_DIRECT_URL
Valkey shared per-caller limit counters (optional: without it they count in this process) VALKEY_URL, VALKEY_PREFIX

Packages: openvibe-contracts (service auth, capabilities, HTTP problems), openvibe-sdk (auth, the events inbox, account-data, limits, service lifecycle) and openvibe-shared (cache policy, metrics, readiness, release, legal pages).

Capabilities

The service manifest lists no capabilities of its own and produces no events; no /api/v1 route is guarded by one (server/http/principal.js CAPABILITIES is empty). Quest calls these on other services with its own client-credentials token:

  • identity.subject.resolve — OpenVibe.Network GET /internal/identity/resolve, to turn a subject into the wallet's network_user_id (server/quests/coins.js).
  • network.coins.credit — OpenVibe.Network POST /internal/coins/credit, to credit a completion's OpenCoins.
  • events.subscription.manage — OpenVibe.Events, to create the catalog's subscriptions at boot (server/events-consumer.js).
  • inventory.item.grant — OpenVibe.Inventory POST /api/v1/grants, to give a quest's item (server/quests/items.js).

What works

Piece Where What it does
Catalog server/quests/catalog.js 36 first-party quests in code (the 18 of the Live setup series among them), 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 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 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, 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
Item rewards server/quests/items.js Six quests also give one of Live's items: Sparkle, Hearts, Basic Cap, Fire Name, Ice Name and Rainbow Name. Each is granted in OpenVibe.Inventory with Quest's own token (Live names Quest a grantor of those items, ADR-054 §3) as earned, keyed by the completion id. Failures and completions from before items are retried by a timer. QUEST_ITEMS=off turns it off
OpenCoins 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 GET /quests, GET /quests/:id, GET /me/quests, GET /profiles/:subject/badges (see the table below)
Pages server/http/pages.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 robots.txt (the private log disallowed), sitemap.xml (every quest page), llms.txt, llms-full.txt, JSON-LD
Request telemetry server/app.js, server/index.js, migrations/0006_telemetry.sql Every HTTP request is aggregated by route template, method and status class, never by raw URL. Once a minute one http.request sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this service's own telemetry_samples table (openvibe-sdk 0.46 telemetry, the platform.telemetry-sample@1 shape); rows older than 14 days are deleted daily. /shared files, static assets, health, readiness and /metrics are not counted. server/app.js builds the collector (createHttpTelemetry) and mounts its middleware; server/index.js flushes it every minute and on stop, and runs the 14-day retention
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
PUT /api/v1/app-quests/:slug a developer app (quest.app_quest.manage) create or replace one of the app's quests (quest.app-quest-request@1 → quest.app-quest@1)
GET /api/v1/app-quests a developer app (quest.app_quest.manage) the project's app quests, with how many people finished each
DELETE /api/v1/app-quests/:slug a developer app (quest.app_quest.manage) retire one: no longer counted or listed; its completions and badges stay

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 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)
live.setup.task_completed OpenVibe.Live payload.user, the streamer who finished a task on Live's go-live setup list
blog.post.published OpenVibe.Blog the envelope actor (a schedule or a service publishing is nobody)
deals.offer.created / deals.vote.changed OpenVibe.Deals the envelope actor: the submitter or the voter (an imported deal's actor is svc:deals)
run.job.succeeded OpenVibe.Run payload.requester when it is a user (an app's or a service's job is nobody's)
inventory.item.equipped OpenVibe.Inventory payload.owner
bot.robot.online OpenVibe.Bot payload.owner, the robot's owner (Bot names it since contracts 0.192.0)
games.world.published OpenVibe.Games payload.owner, who published a creator world for the first time (contracts 0.194.0)
mediahub.video.published OpenVibe.Video (MediaHub) payload.owner, whose upload is public and ready for the first time (contracts 0.196.0)
mediahub.picture.uploaded OpenVibe.Pics (MediaHub) payload.owner, who uploaded a picture while signed in (contracts 0.196.0)

App quests (contracts 0.193.0, server/quests/app-quests.js): a developer app defines quests on its own project's events with an app token (audience openvibe.quest, capability quest.app_quest.manage). A step may count only app.<project_key>.<name> events of that project, and the person is the event's actor (an app publishing on_behalf_of a user; an app acting as itself is nobody). Only production counts: Events never hands sandbox events to a first-party consumer, so a sandbox app's quests are its own and never listed. Quest subscribes to app.<project_key>.* when a project's first production quest goes active (and at boot). The reward is a badge named for the app, never OpenCoins; at most 20 active quests a project; /apps/quests/<id> is the public page and the home page lists them under "Made by apps".

A step can also name payload values to match ({ task: 'emote' }): the "Set up your channel" series is one quest per Live setup task, plus one that counts all 18 tasks. The home page shows a series as one card; its page is /series/<id>. 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.

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.190.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, Blog, Deals, Run, Inventory, Bot) 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

Rewards are badges and OpenCoins. Nothing here is money, and nothing here is Vibes.

Configuration

See .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

npm install
fnm exec --using=22 npm test        # every test/*.test.js, on temp PGlite databases with a mock Network
fnm exec --using=22 npm run dev     # http://localhost:4980

Without DATABASE_URL development uses an embedded PGlite database in data/pglite (one process only). npm run test:pg runs the same suite through PostgreSQL and PgBouncer (see .github/workflows/ci.yml).

The mock Network in 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.

Acceptance

npm test (test/run.js) runs every test/*.test.js in its own process on temp PGlite databases and an in-process mock of OpenVibe.Network (test/helpers/mocks.js); no network or running site is needed. npm run test:pg runs the same suite through PostgreSQL and PgBouncer. The main proofs:

  • test/events-consumer.test.js — a bad signature, a forwarded request and an unconfigured secret are refused; the same event twice changes nothing; progress counts to completion and the badge is awarded once; an event that names nobody creditable is ignored; OpenCoins off is skipped, on credits with the completion id as the idempotency key, the two daily caps stop it, and a failure is recorded and retried.
  • test/quests-api.test.js — the catalog and a quest are public; /me/quests requires sign-in and shows only the caller's rows; the badge endpoint answers an id, a name and a date and nothing else; the pages are complete without JavaScript; a hostile title is escaped.
  • test/items.test.js — a quest's item is granted in a stand-in Inventory with origin: earned and the completion id, a refusal is recorded and retried, a completion from before items is granted by the retry, and QUEST_ITEMS=off grants nothing.
  • test/account-data.test.js — an export carries only the person's own progress, completions and badges; a deletion erases them whole and confirms the counts; a redelivery changes nothing.
  • test/security-secrets.test.js — the OAuth client secret never appears in a response, the database or a log line, over every route and hostile input.
  • test/security-session.test.js — a FedCM assertion, app, service or internal token is never accepted as a session cookie.
  • test/caller-limits.test.js — past its budget a caller gets 429 problem+json with Retry-After before the route does any work, while another caller still passes.
  • test/open-redirect.test.js — the sign-in next never leaves the site.
  • test/no-internal-key.test.js — no X-Internal-Key anywhere in the code, deploy files or the running service (ADR-014).
  • test/auth-ops.test.js — PKCE S256 sign-in, truthful /api/ready, /release.json, and loopback-only /metrics.
  • test/auth-jwks.test.js — the Network signing key is kept across outages, a rotation is honoured at once and unknown-kid floods are throttled.
  • test/discovery.test.js — robots.txt, sitemap.xml, llms.txt, llms-full.txt and the JSON-LD carry real public entries only and take lastmod from STATUS.json, not the clock.
  • test/service-kit.test.js — the graceful stop runs its close steps in the manifest's order and resolves exit 0.
  • test/layout.test.js, test/asset-cache.test.js, test/nginx-auth-limit.test.js and test/perf-budget.test.js — the shared boost marker and the asset cache rules, the nginx limit zones, and the home page's size budgets.

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, 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.

Account export and deletion

A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its part (ADR-033). Quest receives network.account.export_requested and network.account.deleted at POST /internal/events (loopback only) through the consumer it already runs — the two account topics are handed to server/identity/account-data.js, which maps the tables, and the boot-time subscriptions for them are created (alongside the catalog's events) by server/events-consumer.js:

  • Exported: the quests a person is partway through (progress.json), the quests they completed and their OpenCoins (completions.json) and the badges they earned (badges.json), pushed to POST /internal/account-exports/:id/parts with this service's own token. Nothing here is a secret — Quest stores no token, key or credential.
  • Erased: all three tables hold the person's own rows and nothing anyone else's page hangs under them, so they are deleted whole and nothing is kept. Deleting a completion deletes its OpenCoins record here; the wallet credit at OpenVibe.Network is Network's own data. Quest then confirms with POST /internal/account-deletions/:id/confirmations and the counts.
  • Anonymized: nothing. There is no row Quest keeps that was written by this person for another person to read.

The delivery receipts (quest_event_inbox) are this service's own, not a person's rows, and are neither exported nor erased.

Environment: QUEST_EVENTS_SECRET (comma-separated for rotation, 32+ characters each; unset makes the route answer 503), QUEST_EVENTS_URL (or EVENTS_URL) is where the subscriptions are created at boot (off when unset), and QUEST_EVENTS_ENDPOINT overrides the loopback endpoint; QUEST_EVENTS_SUBSCRIBE=0 turns the boot-time subscription off.

Security (threat notes)

Reporting a vulnerability: 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. 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.

Part of the OpenVibe network. Built in the open by OpenVibers.

  • openvibe-contracts: v0.216.0
  • openvibe-sdk: v0.65.0
  • openvibe-shared: v4.15.2
  • openvibe-publishing: v1.4.0