openvibers/openvibe.quest Commits
Quest: app quests — developer apps define quests on their own events (quest.app_quest.manage, contracts 0.193.1) (#55)
Changes
@@@ -108,6 +108,9 @@ own** and produces no events; no `/api/v1` route is guarded by one ([server/http| `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, andanswers only that person's rows. Errors are RFC 9457 `application/problem+json` with a stable `code`. Requests are@@@ -135,6 +138,15 @@ OpenVibe.Events delivers an event when a site really does something. Quest count| `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) |++**App quests** (contracts 0.193.0, [server/quests/app-quests.js](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 onequest 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@@@ -7,11 +7,11 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": "4980","domain": "openvibe.quest",-- "contracts": "openvibe-contracts v0.190.0",++ "contracts": "openvibe-contracts v0.193.1","sdk": "openvibe-sdk v0.46.0","shared": "openvibe-shared v4.2.1","packages": [-- "openvibe-contracts v0.190.0",++ "openvibe-contracts v0.193.1","openvibe-sdk v0.46.0","openvibe-shared v4.2.1","openvibe-publishing v1.4.0"nnew file mode 100644@@@ -0,0 +1,23 @@++-- phase: expand++-- App quests (contracts 0.193.0, capability quest.app_quest.manage): quests a developer app defines on its own project's++-- events (app.<project_key>.<name>). Progress, completions and badges use the existing tables under the quest id++-- app.<project_key>.<slug>; this table holds the definitions. A retired quest keeps its row, its completions and its badges.++CREATE TABLE app_quests (++ id text COLLATE "C" PRIMARY KEY, -- app.<project_key>.<slug>++ project_id text COLLATE "C" NOT NULL, -- prj_…++ project_key text COLLATE "C" NOT NULL, -- p<ULID lowercase>, the event type prefix's middle segment++ app text COLLATE "C" NOT NULL, -- app:app_… that last defined it++ env text NOT NULL CHECK (env IN ('sandbox','production')),++ slug text COLLATE "C" NOT NULL,++ title text NOT NULL,++ why text,++ steps jsonb NOT NULL, -- [{ event_type, count, label }]++ badge_name text NOT NULL,++ url text,++ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')),++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ UNIQUE (project_key, slug)++);++CREATE INDEX app_quests_project ON app_quests (project_id, status);++CREATE INDEX app_quests_listing ON app_quests (env, status, updated_at DESC);@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.193.1","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",@@@ -872,9 +872,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.190.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",-- "integrity": "sha512-zh71oYBT6xHuyIj4OirWbWmJ33Xe5HeBAG6Q4Sq2S0aqA6gG4LVK5+k9P/UEV0AuSkcqqlQm84PjBF8ZTY90Ow==",++ "version": "0.193.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.193.1",++ "integrity": "sha512-qDyCqV4T0PQCyPu51GXj/EQXC+znsEtzZIf8478MOFZTksffarh4hfBnD8ATXyeLSWUIeIEDFl29qPaYClUwrw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -2,7 +2,7 @@"name": "openvibe-quest","version": "0.3.0","private": true,-- "description": "OpenVibe.Quest \u2014 Quests across the whole network.",++ "description": "OpenVibe.Quest — Quests across the whole network.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -22,7 +22,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.193.1","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",@@@ -68,10 +68,12 @@ async function createApp(opts = {}) {const accountSend = opts.accountSend || (config.oauth.clientSecret? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });-- const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, now: s.now, log });++ // App quests (server/quests/app-quests.js): quests developer apps define on their own events.++ const appQuests = require('./quests/app-quests').createAppQuests({ s, log });++ const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, appQuests, now: s.now, log });// The quests in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.const searchIndex = opts.searchIndex || createSearchIndex({ config, s, log });-- const ctx = { config, s, keys, sso, principal, coins, items, eventsConsumer, accountData, accountSend, searchIndex, log };++ const ctx = { config, s, keys, sso, principal, coins, items, eventsConsumer, accountData, accountSend, searchIndex, appQuests, log };const app = express();app.disable('x-powered-by');@@@ -42,7 +42,7 @@ const CONSUMER = 'quest';const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;--function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, now = () => Date.now(), log = console }) {++function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = 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 });@@@ -81,7 +81,7 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,let outcome;try {-- const r = await inbox.once(CONSUMER, event.event_id, (t) => apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId }));++ const r = await inbox.once(CONSUMER, event.event_id, (t) => apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId, appQuests }));if (r.duplicate) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }outcome = r.result;} catch (err) {@@@ -138,8 +138,14 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.urif (++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();++ /** One more topic (an app project's events, app.<project_key>.*): idempotent, a 409 means it exists. */++ const subscribe = async (topic) => {++ 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})`);++ };// 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; } };++ return { topics: TOPICS, endpoint, subscribe, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };}module.exports = { createEventsConsumer, startSubscriptions, CONSUMER, TOPICS };@@@ -38,6 +38,7 @@ const questWire = (q) => ({function createApi(ctx) {const { config, s, principal, limits, coins } = ctx;++ if (ctx.appQuests) setAppBadgeNames(ctx.appQuests.badgeName);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. */@@@ -65,6 +66,31 @@ function createApi(ctx) {return res.json(questWire(quest));});++ // ── App quests (developer apps, contracts 0.193.0: quest.app_quest.manage) ──++ // Only an app token with a project: a person, an agent or a service never defines an app's quests.++ const appOnly = (req, res, next) => {++ const p = req.principal;++ if (p.kind !== 'app' || !String(p.requester || '').startsWith('app:') || !p.project) {++ return problem(req, res, p.kind === 'anonymous' ? 401 : 403, 'quest.app_only', 'App quests are defined by a developer app: send its app token (a project, audience openvibe.quest, quest.app_quest.manage).');++ }++ return next();++ };++ const manage = principal.requireCapability('quest.app_quest.manage');++ r.get('/app-quests', limits.reads('quest.app_quest.read'), appOnly, manage, async (req, res) => {++ const rows = await ctx.appQuests.listForProject(req.principal.project);++ res.json({ project_id: req.principal.project, max: ctx.appQuests.MAX_PER_PROJECT, quests: await Promise.all(rows.map((row) => ctx.appQuests.wire(row))) });++ });++ r.put('/app-quests/:slug', limits.budget('quest.app_quest.write'), appOnly, manage, async (req, res) => {++ const out = await ctx.appQuests.put(req.principal, req.params.slug, req.body);++ if (out.code) return problem(req, res, out.status, out.code, out.detail);++ res.status(out.status).json(await ctx.appQuests.wire(out.row));++ });++ r.delete('/app-quests/:slug', limits.budget('quest.app_quest.write'), appOnly, manage, async (req, res) => {++ const row = await ctx.appQuests.retire(req.principal.project, req.params.slug);++ if (!row) return problem(req, res, 404, 'quest.not_found', 'This project has no app quest with that slug.');++ res.json(await ctx.appQuests.wire(row));++ });++// ── 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.');@@@ -120,9 +146,12 @@ const completionWire = (c) => ({});/** A badge as anyone may see it: the id, its catalog name, and nothing about the person. */++let appBadgeName = () => null;++/** server/app.js points this at the app quests' badge names. */++const setAppBadgeNames = (fn) => { appBadgeName = typeof fn === 'function' ? fn : () => null; };const badgeWire = (badgeId) => {const badge = catalog.badgeById(badgeId);-- return { id: badgeId, name: badge ? badge.name : badgeId };++ return { id: badgeId, name: badge ? badge.name : (appBadgeName(badgeId) || badgeId) };};--module.exports = { createApi, questWire, completionWire, badgeWire, SUBJECT_RE };++module.exports = { createApi, questWire, completionWire, badgeWire, setAppBadgeNames, SUBJECT_RE };@@@ -32,7 +32,10 @@ function caller(req) {** 'quest.thing.create': { minute: 6, hour: 60 },*/--const BUDGETS = {};++const BUDGETS = {++ // An app defining or retiring its quests (server/quests/app-quests.js).++ 'quest.app_quest.write': { minute: 20, hour: 200 },++};function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {const refused = registry@@@ -49,7 +49,8 @@ function createPageRoutes(ctx) {{ empty: 'No events.' });// ── Home ─────────────────────────────────────────────────-- r.get('/', (req, res) => {++ r.get('/', async (req, res) => {++ const appList = ctx.appQuests ? await ctx.appQuests.listed(24) : [];const hero = showcase.hero({eyebrow: `${SITE_NAME} · ${TAGLINE}`,title: 'Quests across OpenVibe.',@@@ -73,13 +74,16 @@ ${raw(showcase.features({{ icon: 'ov:page', title: 'One log, many sites', 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: coinsUi.coinsEnabled ? 'A quest pays a badge that shows on your profile across the network, and OpenCoins into your OpenVibe.Network wallet.' : 'A quest pays a badge that shows on your profile across the network and, once they are switched 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.' },++ { icon: 'ov:games', title: 'Made by apps', text: 'A developer app defines quests on its own events with an app token (the SDK\'s quests): they pay a badge named for the app, never OpenCoins. Quests a streamer writes come later.' },],}))}<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({ fold: true }), coinsUi)}${quests.coinsNote(coinsUi)}</section>++${appList.length ? html`<section class="sc-sec" id="app-quests" aria-labelledby="app-quests-h"><h2 id="app-quests-h">Made by apps</h2>++<p class="sc-lede">Quests that developer apps on OpenVibe define on their own events. Each pays a badge named for the app, never OpenCoins.</p>++<ul class="quest-list">${appList.map((q) => html`<li class="quest-card"><h3><a href="/apps/quests/${q.id}">${q.title}</a></h3>${q.why ? html`<p>${q.why}</p>` : ''}<p class="muted">Badge: ${q.reward.badge.name}${q.url ? html` · <a href="${q.url}" rel="nofollow noopener">Where to do it</a>` : ''}</p></li>`)}</ul></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@@@ -96,6 +100,37 @@ ${raw(showcase.cta({});});++ // ── An app quest (server/quests/app-quests.js) ──────────++ r.get('/apps/quests/:id', async (req, res) => {++ const quest = ctx.appQuests ? await ctx.appQuests.byId(req.params.id) : null;++ if (!quest || quest.env !== 'production') return page(req, res, {++ title: 'No such quest',++ body: html`<h1>No such quest</h1><p>There is no app quest at this address. <a href="/#app-quests">Quests made by apps</a> lists them.</p>`,++ }, 404);++ const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);++ const [rows, completion, holders] = await Promise.all([++ mine ? store.progressOf(s.db, req.viewer.subject, quest.id) : [],++ mine ? store.completion(s.db, req.viewer.subject, quest.id) : null,++ store.badgeHolders(s.db, quest.reward.badge.id),++ ]);++ const seen = new Map(rows.map((x) => [Number(x.step), Number(x.count)]));++ return page(req, res, {++ index: quest.status === 'active', cache: mine ? null : PUBLIC_CACHE,++ title: quest.title, description: quest.why || `${quest.title}: a quest an OpenVibe app made.`,++ crumbs: [{ label: 'Quests', href: '/' }, { label: 'Made by apps', href: '/#app-quests' }, { label: quest.title }],++ body: html`<h1>${quest.title}</h1>++${quest.why ? html`<p class="lede">${quest.why}</p>` : ''}++<p class="muted">Made by an OpenVibe developer app (project <code>${quest.project_id}</code>)${quest.url ? html` · <a href="${quest.url}" rel="nofollow noopener">Where to do it</a>` : ''}.${quest.status === 'retired' ? ' Retired: it no longer counts.' : ''}</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>${(seen.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul>++<h2>The reward</h2>++<p>The badge <strong>${quest.reward.badge.name}</strong>. App quests never pay OpenCoins.</p>++<p class="muted">${holders ? html`${holders} ${holders === 1 ? 'person has' : 'people have'} this badge.` : 'A badge is awarded once and kept.'}</p>++${completion ? html`<div class="notice" role="status">You completed this quest on <strong>${String(completion.completed_at).slice(0, 10)}</strong>.</div>` : ''}++<p><a href="/">All quests</a></p>`,++ });++ });++// ── One quest ────────────────────────────────────────────r.get('/quests/:id', (req, res) => {const quest = catalog.byId(req.params.id);@@@ -188,7 +223,7 @@ each quest still needs, and the badges you have earned.</p>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 };++ const badge = (id) => catalog.badgeById(id) || { id, name: (ctx.appQuests && ctx.appQuests.badgeName(id)) || id };return page(req, res, {title: 'Your quest log',@@@ -20,11 +20,9 @@ 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}$/;--// 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 = [];++// Every other route is public or a person reading their own log (no capability). App quests are the one exception:++// a developer app (an app token with a project) defines its own quests with quest.app_quest.manage (contracts 0.193.0).++const CAPABILITIES = ['quest.app_quest.manage'];function decodePayload(token) {const parts = String(token || '').split('.');@@@ -54,7 +52,9 @@ function createPrincipal({ config, keys }) {if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {const publicKey = await keys.pemForToken(token);if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });++ // Sandbox app tokens are accepted (ADR-014 opt-in): only the app-quest routes take app tokens, and a sandbox++ // app's quests are its own, never listed or counted (server/quests/app-quests.js).++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, acceptSandbox: true });if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };@@@ -49,6 +49,9 @@ async function start() {const itemsTimer = ctx.items.start();if (itemsTimer) timers.push(itemsTimer);++ // App quests: each project with an active production quest gets its app.<project_key>.* subscription.++ ctx.appQuests.setSubscriber(subscriptions ? subscriptions.subscribe : null);++ ctx.appQuests.load().catch((err) => console.warn('[Quest] app quests not loaded:', err.message));const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop(), () => ctx.items.stop()];createLifecycle({ server, ctx, timers, extra });return { server, ctx };nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++/**++ * App quests (contracts 0.193.0, capability quest.app_quest.manage): the owner's "moddable in the SDK" for Quest. A++ * developer app defines quests on its own project's events, app.<project_key>.<name>, and Quest counts them like its++ * own quests (progress, completions and badges in the same tables, under the id app.<project_key>.<slug>):++ *++ * - only the app's own events: every step's type starts with app.<the token's project key>.;++ * - 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: OpenVibe.Events never hands sandbox events to a first-party consumer, so a sandbox app may++ * define quests to try the API, and they are listed to it alone;++ * - a badge named for the app is the whole reward: app quests never pay OpenCoins;++ * - at most 20 active per project; a retired quest keeps its completions and badges and is no longer counted or listed.++ *++ * Quest subscribes to app.<project_key>.* at Events when a project's first production quest goes active (and at boot++ * for every project that has one).++ */++const contracts = require('openvibe-contracts');++++const MAX_PER_PROJECT = 20;++const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;++const PROJECT_RE = /^prj_([0-9A-HJKMNP-TV-Z]{26})$/;++++/** 'prj_01JAB…' → 'p01jab…' (Events' project key, the middle segment of an app event type). */++function projectKey(projectId) {++ const m = PROJECT_RE.exec(String(projectId || ''));++ return m ? `p${m[1].toLowerCase()}` : null;++}++++const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };++++/** A row in the catalog's shape, so apply() and the pages treat it like any quest. */++function asQuest(row) {++ return {++ id: row.id, site: null, app: true, project_id: row.project_id, env: row.env, title: row.title, why: row.why || '',++ steps: parse(row.steps, []), url: row.url || null, status: row.status,++ reward: { badge: { id: row.id, name: row.badge_name }, coins: 0 },++ repeatable: false,++ };++}++++function createAppQuests({ s, log = console, subscribe: initialSubscribe = null }) {++ const badgeNames = new Map();++ let subscribe = initialSubscribe;++ const iso = () => new Date(s.now()).toISOString();++++ async function ensureSubscribed(key) {++ if (!subscribe) return;++ try { await subscribe(`app.${key}.*`); } catch (err) { log.warn(`[Quest] subscribing to app.${key}.* failed: ${err.message}`); }++ }++++ return {++ MAX_PER_PROJECT, projectKey, asQuest,++ /** The Events subscription helper (server/index.js sets it once subscriptions start). */++ setSubscriber(fn) { subscribe = typeof fn === 'function' ? fn : null; },++++ /** Fill the badge names at boot; subscribe to every project with an active production quest. */++ async load() {++ for (const r of await s.db.many('SELECT id, badge_name FROM app_quests')) badgeNames.set(r.id, r.badge_name);++ for (const r of await s.db.many("SELECT DISTINCT project_key FROM app_quests WHERE env = 'production' AND status = 'active'")) await ensureSubscribed(r.project_key);++ },++ badgeName: (id) => badgeNames.get(String(id)) || null,++++ /** Create or replace one quest for the app's project. → { status, row } | { status, code, detail } */++ async put(principal, slug, body) {++ const projectId = principal.project;++ const key = projectKey(projectId);++ if (!key || !String(principal.requester || '').startsWith('app:')) return { status: 403, code: 'quest.app_only', detail: 'Only a developer app token with a project defines app quests.' };++ if (!SLUG_RE.test(String(slug || ''))) return { status: 422, code: 'quest.bad_slug', detail: 'slug: lower-case letters, digits and dashes, at most 48' };++ const v = contracts.validate('quest.app-quest-request@1', body);++ if (!v.valid) return { status: 422, code: 'quest.invalid', detail: `the quest does not match quest.app-quest-request@1: ${JSON.stringify(v.errors).slice(0, 400)}` };++ const foreign = body.steps.find((st) => !st.event_type.startsWith(`app.${key}.`));++ if (foreign) return { status: 422, code: 'quest.foreign_event', detail: `${foreign.event_type} is not one of this project's events (app.${key}.<name>)` };++ const id = `app.${key}.${slug}`;++ const others = Number(await s.db.value("SELECT count(*) FROM app_quests WHERE project_key = $1 AND id <> $2 AND status = 'active'", [key, id])) || 0;++ if (others >= MAX_PER_PROJECT && (body.status || 'active') === 'active') return { status: 409, code: 'quest.limit', detail: `a project has at most ${MAX_PER_PROJECT} active app quests; retire one or reuse a slug` };++ const env = principal.claims && principal.claims.env === 'sandbox' ? 'sandbox' : 'production';++ const at = iso();++ const status = body.status || 'active';++ const existed = await s.db.maybe('SELECT id FROM app_quests WHERE id = $1', [id]);++ await s.db.query(`INSERT INTO app_quests (id, project_id, project_key, app, env, slug, title, why, steps, badge_name, url, status, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $13)++ ON CONFLICT (id) DO UPDATE SET app = excluded.app, env = excluded.env, title = excluded.title, why = excluded.why,++ steps = excluded.steps, badge_name = excluded.badge_name, url = excluded.url, status = excluded.status, updated_at = excluded.updated_at`,++ [id, projectId, key, principal.requester, env, slug, body.title, body.why || null, JSON.stringify(body.steps), body.badge.name, body.url || null, status, at]);++ badgeNames.set(id, body.badge.name);++ if (env === 'production' && status === 'active') await ensureSubscribed(key);++ return { status: existed ? 200 : 201, row: await s.db.maybe('SELECT * FROM app_quests WHERE id = $1', [id]) };++ },++++ async listForProject(projectId) {++ return s.db.many('SELECT * FROM app_quests WHERE project_id = $1 ORDER BY created_at', [projectId]);++ },++++ /** Retire one (its completions and badges stay). → the row, or null. */++ async retire(projectId, slug) {++ const key = projectKey(projectId);++ if (!key) return null;++ return s.db.maybe("UPDATE app_quests SET status = 'retired', updated_at = $3 WHERE project_key = $1 AND slug = $2 RETURNING *", [key, String(slug), iso()]);++ },++++ /** The active production app quests with a step that counts this event (in `db`, the delivery's transaction). */++ async forEvent(db, event) {++ const type = String((event && event.event_type) || '');++ if (!type.startsWith('app.')) return [];++ const rows = await db.many(`SELECT * FROM app_quests WHERE env = 'production' AND status = 'active'++ AND project_key = $1 AND steps @> $2::jsonb`, [type.split('.')[1], JSON.stringify([{ event_type: type }])]);++ return rows.map(asQuest);++ },++++ /** The public list: active production quests, newest first. */++ async listed(limit = 100) {++ return (await s.db.many("SELECT * FROM app_quests WHERE env = 'production' AND status = 'active' ORDER BY updated_at DESC LIMIT $1", [limit])).map(asQuest);++ },++ async byId(id) {++ const row = await s.db.maybe('SELECT * FROM app_quests WHERE id = $1', [String(id)]);++ return row ? asQuest(row) : null;++ },++++ /** quest.app-quest@1 */++ async wire(row) {++ const completions = Number(await s.db.value('SELECT count(*) FROM quest_completions WHERE quest_id = $1', [row.id])) || 0;++ return {++ id: row.id, slug: row.slug, project_id: row.project_id, app: row.app, env: row.env, title: row.title, why: row.why || null,++ steps: parse(row.steps, []), badge: { id: row.id, name: row.badge_name }, url: row.url || null, status: row.status,++ created_at: row.created_at, updated_at: row.updated_at, completions,++ };++ },++ };++}++++module.exports = { createAppQuests, projectKey, asQuest, MAX_PER_PROJECT };@@@ -65,6 +65,9 @@ function personOf(event) {case 'bot.robot.online': // payload.owner (usr_…, contracts 0.192.0; absent from older events)return isUser(p.owner) ? p.owner : null;default:++ // An app's own event (app quests, server/quests/app-quests.js): the actor, when the app published it++ // on_behalf_of a person; an app acting as itself is nobody.++ if (String(event && event.event_type).startsWith('app.')) return refUser(event.actor);return null;}}@@@ -75,10 +78,10 @@ function personOf(event) {* 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 }) {++async function apply(db, event, { at, newId, appQuests = null }) {const subject = personOf(event);if (!subject) return { subject: null, outcome: 'ignored:subject', completed: [] };-- const quests = catalog.forEvent(event);++ const quests = [...catalog.forEvent(event), ...(appQuests ? await appQuests.forEvent(db, event) : [])];if (!quests.length) return { subject, outcome: 'ignored:type', completed: [] };const completed = [];nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++/**++ * App quests (contracts 0.193.0, quest.app_quest.manage; server/quests/app-quests.js):++ * - only a developer app token with a project and the capability defines them; a person, a service or an app without++ * the capability cannot;++ * - a step may count only the project's own events (app.<project_key>.<name>), and the body is quest.app-quest-request@1;++ * - a production quest subscribes Quest to app.<project_key>.* and counts the app's events whose actor is a person;++ * the reward is a badge, never OpenCoins; a sandbox quest is listed to its app alone and counts nothing;++ * - at most 20 per project; a retired quest stops counting and keeps its badges; the public page and the badge name.++ */++const assert = require('assert');++const crypto = require('crypto');++const { ids, serviceAuth } = require('openvibe-contracts');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++++const SECRET = 'quest-events-secret-0123456789abcdef';++const ULID = () => ids.newId('user').slice(4);++++(async () => {++ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET } });++ const subscribed = [];++ t.ctx.appQuests.setSubscriber(async (topic) => { subscribed.push(topic); });++ const projectId = `prj_${ULID()}`;++ const key = `p${projectId.slice(4).toLowerCase()}`;++ const appId = `app_${ULID()}`;++ const token = ({ env = 'production', cap = ['quest.app_quest.manage'], project = projectId, sub = `app:${appId}` } = {}) => {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'app', aud: ['openvibe.quest'], cap, ns: [project], project_id: project, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, t.network.privatePem);++ };++ const put = (slug, body, tok = token()) => t.get(`/api/v1/app-quests/${slug}`, { method: 'PUT', json: body, headers: { authorization: `Bearer ${tok}` } });++ const QUEST = { title: 'Collector', why: 'Find five gems.', steps: [{ event_type: `app.${key}.gem.found`, count: 5, label: 'Find a gem' }], badge: { name: 'Gem Collector' }, url: 'https://example.com/play' };++ const deliver = (event) => {++ const raw = JSON.stringify({ event, seq: 1 });++ return t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });++ };++ const appEvent = (type, actor) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: `app-${appId.slice(4).toLowerCase()}`, actor, timestamp: new Date().toISOString(), subject: { type: 'thing', id: '1' }, payload: {} });++ const sam = t.network.addUser('sam');++++ try {++ await check('only an app token with a project and the capability defines a quest', async () => {++ assert.strictEqual((await t.get(`/api/v1/app-quests/x`, { method: 'PUT', json: QUEST })).status, 401);++ assert.strictEqual((await t.get(`/api/v1/app-quests/x`, { method: 'PUT', json: QUEST, headers: { authorization: `Bearer ${t.network.userToken(sam)}` } })).status, 403, 'a person cannot');++ assert.strictEqual((await put('x', QUEST, token({ cap: ['events.app.publish'] }))).status, 403, 'an app without the capability cannot');++ assert.strictEqual((await put('x', QUEST, token({ sub: 'svc:someone' }))).status, 403, 'a service cannot');++ });++++ await check('the body is checked; a step may count only this project\'s own events', async () => {++ const foreign = await put('x', { ...QUEST, steps: [{ event_type: `app.p${'0'.repeat(26)}.x`, count: 1, label: 'x' }] });++ assert.strictEqual(foreign.status, 422);++ assert.strictEqual(foreign.json().code, 'quest.foreign_event');++ assert.strictEqual((await put('x', { ...QUEST, coins: 50 })).status, 422, 'no OpenCoins field at all');++ assert.strictEqual((await put('Bad Slug', QUEST)).status, 422);++ });++++ await check('a production quest: 201, Quest subscribes to the project\'s events, a person completes it and gets the badge (no coins)', async () => {++ const r = await put('collector', QUEST);++ assert.strictEqual(r.status, 201, r.text);++ const q = r.json();++ assert.deepStrictEqual([q.id, q.env, q.status, q.badge.name, q.completions], [`app.${key}.collector`, 'production', 'active', 'Gem Collector', 0]);++ assert.deepStrictEqual(subscribed, [`app.${key}.*`]);++ assert.strictEqual((await put('collector', { ...QUEST, title: 'Gem Collector' })).status, 200, 'replace');++ // The app acting as itself is nobody; on behalf of a person, it counts.++ assert.match((await deliver(appEvent(`app.${key}.gem.found`, { type: 'app', id: appId }))).json().outcome, /^ignored:/);++ for (let i = 0; i < 5; i++) await deliver(appEvent(`app.${key}.gem.found`, { type: 'user', id: sam.subject }));++ const c = await t.ctx.s.db.many('SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = $2', [sam.subject, q.id]);++ assert.strictEqual(c.length, 1);++ assert.deepStrictEqual([Number(c[0].coins), c[0].coins_state], [0, 'skipped']);++ const badges = (await t.get(`/api/v1/profiles/${sam.subject}/badges`)).json().badges;++ assert.ok(badges.some((b) => b.id === q.id && b.name === 'Gem Collector'), JSON.stringify(badges));++ const page = await t.get(`/apps/quests/${q.id}`);++ assert.ok(page.text.includes('Gem Collector') && page.text.includes('App quests never pay OpenCoins') && page.text.includes('1 person has this badge'));++ assert.ok((await t.get('/')).text.includes(`href="/apps/quests/${q.id}"`), 'listed on the home page');++ assert.strictEqual((await t.get('/api/v1/app-quests', { headers: { authorization: `Bearer ${token()}` } })).json().quests[0].completions, 1);++ });++++ await check('a sandbox quest is the app\'s alone: not listed, not counted, no subscription', async () => {++ const before = subscribed.length;++ const r = await put('sandbox-try', { ...QUEST, steps: [{ event_type: `app.${key}.level_completed`, count: 1, label: 'Finish a level' }] }, token({ env: 'sandbox' }));++ assert.strictEqual(r.status, 201);++ assert.strictEqual(r.json().env, 'sandbox');++ assert.strictEqual(subscribed.length, before);++ assert.strictEqual((await t.get(`/apps/quests/${r.json().id}`)).status, 404);++ await deliver(appEvent(`app.${key}.level_completed`, { type: 'user', id: sam.subject }));++ assert.strictEqual((await t.ctx.s.db.many('SELECT * FROM quest_completions WHERE quest_id = $1', [r.json().id])).length, 0);++ });++++ await check('retired: no longer counted or listed, its badges stay; at most 20 a project', async () => {++ const del = await t.get('/api/v1/app-quests/collector', { method: 'DELETE', headers: { authorization: `Bearer ${token()}` } });++ assert.strictEqual(del.json().status, 'retired');++ assert.ok(!(await t.get('/')).text.includes('/apps/quests/'), 'off the home page');++ assert.ok((await t.get(`/api/v1/profiles/${sam.subject}/badges`)).json().badges.some((b) => b.name === 'Gem Collector'), 'the badge stays');++ // Active: the sandbox one and 19 more is 20; the retired one does not count.++ for (let i = 0; i < 19; i++) assert.strictEqual((await put(`q${i}`, QUEST)).status, 201);++ const over = await put('one-too-many', QUEST);++ assert.strictEqual(over.status, 409);++ assert.strictEqual(over.json().code, 'quest.limit');++ });++ } finally { await t.close(); }++ done();++})();@@@ -20,8 +20,8 @@ const READ = 'quest.api.read';const rosa = t.network.addUser('rosa');const sam = t.network.addUser('sam');-- await check('BUDGETS starts empty: the product declares its own expensive routes', () => {-- assert.deepStrictEqual(BUDGETS, {});++ await check('BUDGETS: the product declares its own expensive routes (an app defining its quests)', () => {++ assert.deepStrictEqual(BUDGETS, { 'quest.app_quest.write': { minute: 20, hour: 200 } });});await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
@@ -108,6 +108,9 @@ own** and produces no events; no `/api/v1` route is guarded by one ([server/http
@@ -108,6 +108,9 @@ own** and produces no events; no `/api/v1` route is guarded by one ([server/http
| `GET /api/v1/quests/:id` | anyone | one quest: its steps, its events, its reward |
| `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/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 |
| `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
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
answers only that person's rows. Errors are RFC 9457 `application/problem+json` with a stable `code`. Requests are
@@ -135,6 +138,15 @@ OpenVibe.Events delivers an event when a site really does something. Quest count
@@ -135,6 +138,15 @@ OpenVibe.Events delivers an event when a site really does something. Quest count
| `inventory.item.equipped` | OpenVibe.Inventory | `payload.owner` |
| `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) |
| `bot.robot.online` | OpenVibe.Bot | `payload.owner`, the robot's owner (Bot names it since contracts 0.192.0) |
+**App quests** (contracts 0.193.0, [server/quests/app-quests.js](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
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
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
`/series/<id>`. A quest completes when every step reaches its count. The completion, its badge and (when OpenCoins are on) its
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"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",
- "contracts": "openvibe-contracts v0.190.0",
+ "contracts": "openvibe-contracts v0.193.1",
"sdk": "openvibe-sdk v0.46.0",
"sdk": "openvibe-sdk v0.46.0",
"shared": "openvibe-shared v4.2.1",
"shared": "openvibe-shared v4.2.1",
"packages": [
"packages": [
- "openvibe-contracts v0.190.0",
+ "openvibe-contracts v0.193.1",
"openvibe-sdk v0.46.0",
"openvibe-sdk v0.46.0",
"openvibe-shared v4.2.1",
"openvibe-shared v4.2.1",
"openvibe-publishing v1.4.0"
"openvibe-publishing v1.4.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+-- phase: expand
+-- App quests (contracts 0.193.0, capability quest.app_quest.manage): quests a developer app defines on its own project's
+-- events (app.<project_key>.<name>). Progress, completions and badges use the existing tables under the quest id
+-- app.<project_key>.<slug>; this table holds the definitions. A retired quest keeps its row, its completions and its badges.
+CREATE TABLE app_quests (
+ id text COLLATE "C" PRIMARY KEY, -- app.<project_key>.<slug>
+ project_id text COLLATE "C" NOT NULL, -- prj_…
+ project_key text COLLATE "C" NOT NULL, -- p<ULID lowercase>, the event type prefix's middle segment
+ app text COLLATE "C" NOT NULL, -- app:app_… that last defined it
+ env text NOT NULL CHECK (env IN ('sandbox','production')),
+ slug text COLLATE "C" NOT NULL,
+ title text NOT NULL,
+ why text,
+ steps jsonb NOT NULL, -- [{ event_type, count, label }]
+ badge_name text NOT NULL,
+ url text,
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')),
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ UNIQUE (project_key, slug)
+);
+CREATE INDEX app_quests_project ON app_quests (project_id, status);
+CREATE INDEX app_quests_listing ON app_quests (env, status, updated_at DESC);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.193.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
@@ -872,9 +872,9 @@
@@ -872,9 +872,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.190.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",
- "integrity": "sha512-zh71oYBT6xHuyIj4OirWbWmJ33Xe5HeBAG6Q4Sq2S0aqA6gG4LVK5+k9P/UEV0AuSkcqqlQm84PjBF8ZTY90Ow==",
+ "version": "0.193.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.193.1",
+ "integrity": "sha512-qDyCqV4T0PQCyPu51GXj/EQXC+znsEtzZIf8478MOFZTksffarh4hfBnD8ATXyeLSWUIeIEDFl29qPaYClUwrw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-quest",
"name": "openvibe-quest",
"version": "0.3.0",
"version": "0.3.0",
"private": true,
"private": true,
- "description": "OpenVibe.Quest \u2014 Quests across the whole network.",
+ "description": "OpenVibe.Quest — Quests across the whole network.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.193.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
@@ -68,10 +68,12 @@ async function createApp(opts = {}) {
@@ -68,10 +68,12 @@ async function createApp(opts = {}) {
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });
- const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, now: s.now, log });
+ // App quests (server/quests/app-quests.js): quests developer apps define on their own events.
+ const appQuests = require('./quests/app-quests').createAppQuests({ s, log });
+ const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, appQuests, now: s.now, log });
// The quests in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.
// The quests in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.
const searchIndex = opts.searchIndex || createSearchIndex({ config, s, log });
const searchIndex = opts.searchIndex || createSearchIndex({ config, s, log });
- const ctx = { config, s, keys, sso, principal, coins, items, eventsConsumer, accountData, accountSend, searchIndex, log };
+ const ctx = { config, s, keys, sso, principal, coins, items, eventsConsumer, accountData, accountSend, searchIndex, appQuests, log };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -42,7 +42,7 @@ const CONSUMER = 'quest';
@@ -42,7 +42,7 @@ const CONSUMER = 'quest';
const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);
const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
-function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, now = () => Date.now(), log = console }) {
+function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, now = () => Date.now(), log = console }) {
const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
// Receipts (quest_event_inbox) are in migrations/0002_quest.sql.
// Receipts (quest_event_inbox) are in migrations/0002_quest.sql.
const inbox = createPgInbox(db, { table: 'quest_event_inbox', now });
const inbox = createPgInbox(db, { table: 'quest_event_inbox', now });
@@ -81,7 +81,7 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
@@ -81,7 +81,7 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
let outcome;
let outcome;
try {
try {
- const r = await inbox.once(CONSUMER, event.event_id, (t) => apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId }));
+ const r = await inbox.once(CONSUMER, event.event_id, (t) => apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId, appQuests }));
if (r.duplicate) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }
if (r.duplicate) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }
outcome = r.result;
outcome = r.result;
} catch (err) {
} catch (err) {
@@ -138,8 +138,14 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.ur
@@ -138,8 +138,14 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.ur
if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Events consumer] subscriptions not created:', err.message);
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();
timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();
+ /** One more topic (an app project's events, app.<project_key>.*): idempotent, a 409 means it exists. */
+ const subscribe = async (topic) => {
+ 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})`);
+ };
// Graceful stop: no further attempts (they run again at the next start).
// 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; } };
+ return { topics: TOPICS, endpoint, subscribe, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
}
}
module.exports = { createEventsConsumer, startSubscriptions, CONSUMER, TOPICS };
module.exports = { createEventsConsumer, startSubscriptions, CONSUMER, TOPICS };
@@ -38,6 +38,7 @@ const questWire = (q) => ({
@@ -38,6 +38,7 @@ const questWire = (q) => ({
function createApi(ctx) {
function createApi(ctx) {
const { config, s, principal, limits, coins } = ctx;
const { config, s, principal, limits, coins } = ctx;
+ if (ctx.appQuests) setAppBadgeNames(ctx.appQuests.badgeName);
const r = asyncRouter();
const r = asyncRouter();
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
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. */
/** Whether OpenCoins are live, and the caps — the same answer the pages give. */
@@ -65,6 +66,31 @@ function createApi(ctx) {
@@ -65,6 +66,31 @@ function createApi(ctx) {
return res.json(questWire(quest));
return res.json(questWire(quest));
});
});
+ // ── App quests (developer apps, contracts 0.193.0: quest.app_quest.manage) ──
+ // Only an app token with a project: a person, an agent or a service never defines an app's quests.
+ const appOnly = (req, res, next) => {
+ const p = req.principal;
+ if (p.kind !== 'app' || !String(p.requester || '').startsWith('app:') || !p.project) {
+ return problem(req, res, p.kind === 'anonymous' ? 401 : 403, 'quest.app_only', 'App quests are defined by a developer app: send its app token (a project, audience openvibe.quest, quest.app_quest.manage).');
+ }
+ return next();
+ };
+ const manage = principal.requireCapability('quest.app_quest.manage');
+ r.get('/app-quests', limits.reads('quest.app_quest.read'), appOnly, manage, async (req, res) => {
+ const rows = await ctx.appQuests.listForProject(req.principal.project);
+ res.json({ project_id: req.principal.project, max: ctx.appQuests.MAX_PER_PROJECT, quests: await Promise.all(rows.map((row) => ctx.appQuests.wire(row))) });
+ });
+ r.put('/app-quests/:slug', limits.budget('quest.app_quest.write'), appOnly, manage, async (req, res) => {
+ const out = await ctx.appQuests.put(req.principal, req.params.slug, req.body);
+ if (out.code) return problem(req, res, out.status, out.code, out.detail);
+ res.status(out.status).json(await ctx.appQuests.wire(out.row));
+ });
+ r.delete('/app-quests/:slug', limits.budget('quest.app_quest.write'), appOnly, manage, async (req, res) => {
+ const row = await ctx.appQuests.retire(req.principal.project, req.params.slug);
+ if (!row) return problem(req, res, 404, 'quest.not_found', 'This project has no app quest with that slug.');
+ res.json(await ctx.appQuests.wire(row));
+ });
+
// ── Your own quest log (a person) ────────────────────────
// ── Your own quest log (a person) ────────────────────────
r.get('/me/quests', limits.reads('quest.me.read'), async (req, res) => {
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.');
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.');
@@ -120,9 +146,12 @@ const completionWire = (c) => ({
@@ -120,9 +146,12 @@ const completionWire = (c) => ({
});
});
/** A badge as anyone may see it: the id, its catalog name, and nothing about the person. */
/** A badge as anyone may see it: the id, its catalog name, and nothing about the person. */
+let appBadgeName = () => null;
+/** server/app.js points this at the app quests' badge names. */
+const setAppBadgeNames = (fn) => { appBadgeName = typeof fn === 'function' ? fn : () => null; };
const badgeWire = (badgeId) => {
const badgeWire = (badgeId) => {
const badge = catalog.badgeById(badgeId);
const badge = catalog.badgeById(badgeId);
- return { id: badgeId, name: badge ? badge.name : badgeId };
+ return { id: badgeId, name: badge ? badge.name : (appBadgeName(badgeId) || badgeId) };
};
};
-module.exports = { createApi, questWire, completionWire, badgeWire, SUBJECT_RE };
+module.exports = { createApi, questWire, completionWire, badgeWire, setAppBadgeNames, SUBJECT_RE };
@@ -32,7 +32,10 @@ function caller(req) {
@@ -32,7 +32,10 @@ function caller(req) {
*
*
* 'quest.thing.create': { minute: 6, hour: 60 },
* 'quest.thing.create': { minute: 6, hour: 60 },
*/
*/
-const BUDGETS = {};
+const BUDGETS = {
+ // An app defining or retiring its quests (server/quests/app-quests.js).
+ 'quest.app_quest.write': { minute: 20, hour: 200 },
+};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
const refused = registry
const refused = registry
@@ -49,7 +49,8 @@ function createPageRoutes(ctx) {
@@ -49,7 +49,8 @@ function createPageRoutes(ctx) {
{ empty: 'No events.' });
{ empty: 'No events.' });
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
- r.get('/', (req, res) => {
+ r.get('/', async (req, res) => {
+ const appList = ctx.appQuests ? await ctx.appQuests.listed(24) : [];
const hero = showcase.hero({
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: 'Quests across OpenVibe.',
title: 'Quests across OpenVibe.',
@@ -73,13 +74,16 @@ ${raw(showcase.features({
@@ -73,13 +74,16 @@ ${raw(showcase.features({
{ icon: 'ov:page', title: 'One log, many sites', 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:page', title: 'One log, many sites', 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: coinsUi.coinsEnabled ? 'A quest pays a badge that shows on your profile across the network, and OpenCoins into your OpenVibe.Network wallet.' : 'A quest pays a badge that shows on your profile across the network and, once they are switched on, OpenCoins.' },
{ icon: 'ov:account', title: 'Rewards are badges', text: coinsUi.coinsEnabled ? 'A quest pays a badge that shows on your profile across the network, and OpenCoins into your OpenVibe.Network wallet.' : 'A quest pays a badge that shows on your profile across the network and, once they are switched 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: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.' },
+ { icon: 'ov:games', title: 'Made by apps', text: 'A developer app defines quests on its own events with an app token (the SDK\'s quests): they pay a badge named for the app, never OpenCoins. Quests a streamer writes come later.' },
],
],
}))}
}))}
<section class="sc-sec" id="the-quests" aria-labelledby="the-quests-h"><h2 id="the-quests-h">The quests</h2>
<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>
<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({ fold: true }), coinsUi)}
${quests.list(catalog.grouped({ fold: true }), coinsUi)}
${quests.coinsNote(coinsUi)}</section>
${quests.coinsNote(coinsUi)}</section>
+${appList.length ? html`<section class="sc-sec" id="app-quests" aria-labelledby="app-quests-h"><h2 id="app-quests-h">Made by apps</h2>
+<p class="sc-lede">Quests that developer apps on OpenVibe define on their own events. Each pays a badge named for the app, never OpenCoins.</p>
+<ul class="quest-list">${appList.map((q) => html`<li class="quest-card"><h3><a href="/apps/quests/${q.id}">${q.title}</a></h3>${q.why ? html`<p>${q.why}</p>` : ''}<p class="muted">Badge: ${q.reward.badge.name}${q.url ? html` · <a href="${q.url}" rel="nofollow noopener">Where to do it</a>` : ''}</p></li>`)}</ul></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>
<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,
<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
OpenVibe.Events, delivers the domain events a site already emits, and Quest counts the ones below — one per real
@@ -96,6 +100,37 @@ ${raw(showcase.cta({
@@ -96,6 +100,37 @@ ${raw(showcase.cta({
});
});
});
});
+ // ── An app quest (server/quests/app-quests.js) ──────────
+ r.get('/apps/quests/:id', async (req, res) => {
+ const quest = ctx.appQuests ? await ctx.appQuests.byId(req.params.id) : null;
+ if (!quest || quest.env !== 'production') return page(req, res, {
+ title: 'No such quest',
+ body: html`<h1>No such quest</h1><p>There is no app quest at this address. <a href="/#app-quests">Quests made by apps</a> lists them.</p>`,
+ }, 404);
+ const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
+ const [rows, completion, holders] = await Promise.all([
+ mine ? store.progressOf(s.db, req.viewer.subject, quest.id) : [],
+ mine ? store.completion(s.db, req.viewer.subject, quest.id) : null,
+ store.badgeHolders(s.db, quest.reward.badge.id),
+ ]);
+ const seen = new Map(rows.map((x) => [Number(x.step), Number(x.count)]));
+ return page(req, res, {
+ index: quest.status === 'active', cache: mine ? null : PUBLIC_CACHE,
+ title: quest.title, description: quest.why || `${quest.title}: a quest an OpenVibe app made.`,
+ crumbs: [{ label: 'Quests', href: '/' }, { label: 'Made by apps', href: '/#app-quests' }, { label: quest.title }],
+ body: html`<h1>${quest.title}</h1>
+${quest.why ? html`<p class="lede">${quest.why}</p>` : ''}
+<p class="muted">Made by an OpenVibe developer app (project <code>${quest.project_id}</code>)${quest.url ? html` · <a href="${quest.url}" rel="nofollow noopener">Where to do it</a>` : ''}.${quest.status === 'retired' ? ' Retired: it no longer counts.' : ''}</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>${(seen.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul>
+<h2>The reward</h2>
+<p>The badge <strong>${quest.reward.badge.name}</strong>. App quests never pay OpenCoins.</p>
+<p class="muted">${holders ? html`${holders} ${holders === 1 ? 'person has' : 'people have'} this badge.` : 'A badge is awarded once and kept.'}</p>
+${completion ? html`<div class="notice" role="status">You completed this quest on <strong>${String(completion.completed_at).slice(0, 10)}</strong>.</div>` : ''}
+<p><a href="/">All quests</a></p>`,
+ });
+ });
+
// ── One quest ────────────────────────────────────────────
// ── One quest ────────────────────────────────────────────
r.get('/quests/:id', (req, res) => {
r.get('/quests/:id', (req, res) => {
const quest = catalog.byId(req.params.id);
const quest = catalog.byId(req.params.id);
@@ -188,7 +223,7 @@ each quest still needs, and the badges you have earned.</p>
@@ -188,7 +223,7 @@ each quest still needs, and the badges you have earned.</p>
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 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 started = catalog.all().filter((q) => questState(q) === 'started');
const finished = catalog.all().filter((q) => questState(q) === 'done');
const finished = catalog.all().filter((q) => questState(q) === 'done');
- const badge = (id) => catalog.badgeById(id) || { id, name: id };
+ const badge = (id) => catalog.badgeById(id) || { id, name: (ctx.appQuests && ctx.appQuests.badgeName(id)) || id };
return page(req, res, {
return page(req, res, {
title: 'Your quest log',
title: 'Your quest log',
@@ -20,11 +20,9 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
@@ -20,11 +20,9 @@ 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}$/;
-// 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 = [];
+// Every other route is public or a person reading their own log (no capability). App quests are the one exception:
+// a developer app (an app token with a project) defines its own quests with quest.app_quest.manage (contracts 0.193.0).
+const CAPABILITIES = ['quest.app_quest.manage'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -54,7 +52,9 @@ function createPrincipal({ config, keys }) {
@@ -54,7 +52,9 @@ function createPrincipal({ config, keys }) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const publicKey = await keys.pemForToken(token);
const publicKey = await keys.pemForToken(token);
if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
+ // Sandbox app tokens are accepted (ADR-014 opt-in): only the app-quest routes take app tokens, and a sandbox
+ // app's quests are its own, never listed or counted (server/quests/app-quests.js).
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, acceptSandbox: true });
if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
@@ -49,6 +49,9 @@ async function start() {
@@ -49,6 +49,9 @@ async function start() {
const itemsTimer = ctx.items.start();
const itemsTimer = ctx.items.start();
if (itemsTimer) timers.push(itemsTimer);
if (itemsTimer) timers.push(itemsTimer);
+ // App quests: each project with an active production quest gets its app.<project_key>.* subscription.
+ ctx.appQuests.setSubscriber(subscriptions ? subscriptions.subscribe : null);
+ ctx.appQuests.load().catch((err) => console.warn('[Quest] app quests not loaded:', err.message));
const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop(), () => ctx.items.stop()];
const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop(), () => ctx.items.stop()];
createLifecycle({ server, ctx, timers, extra });
createLifecycle({ server, ctx, timers, extra });
return { server, ctx };
return { server, ctx };
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+/**
+ * App quests (contracts 0.193.0, capability quest.app_quest.manage): the owner's "moddable in the SDK" for Quest. A
+ * developer app defines quests on its own project's events, app.<project_key>.<name>, and Quest counts them like its
+ * own quests (progress, completions and badges in the same tables, under the id app.<project_key>.<slug>):
+ *
+ * - only the app's own events: every step's type starts with app.<the token's project key>.;
+ * - 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: OpenVibe.Events never hands sandbox events to a first-party consumer, so a sandbox app may
+ * define quests to try the API, and they are listed to it alone;
+ * - a badge named for the app is the whole reward: app quests never pay OpenCoins;
+ * - at most 20 active per project; a retired quest keeps its completions and badges and is no longer counted or listed.
+ *
+ * Quest subscribes to app.<project_key>.* at Events when a project's first production quest goes active (and at boot
+ * for every project that has one).
+ */
+const contracts = require('openvibe-contracts');
+
+const MAX_PER_PROJECT = 20;
+const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;
+const PROJECT_RE = /^prj_([0-9A-HJKMNP-TV-Z]{26})$/;
+
+/** 'prj_01JAB…' → 'p01jab…' (Events' project key, the middle segment of an app event type). */
+function projectKey(projectId) {
+ const m = PROJECT_RE.exec(String(projectId || ''));
+ return m ? `p${m[1].toLowerCase()}` : null;
+}
+
+const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };
+
+/** A row in the catalog's shape, so apply() and the pages treat it like any quest. */
+function asQuest(row) {
+ return {
+ id: row.id, site: null, app: true, project_id: row.project_id, env: row.env, title: row.title, why: row.why || '',
+ steps: parse(row.steps, []), url: row.url || null, status: row.status,
+ reward: { badge: { id: row.id, name: row.badge_name }, coins: 0 },
+ repeatable: false,
+ };
+}
+
+function createAppQuests({ s, log = console, subscribe: initialSubscribe = null }) {
+ const badgeNames = new Map();
+ let subscribe = initialSubscribe;
+ const iso = () => new Date(s.now()).toISOString();
+
+ async function ensureSubscribed(key) {
+ if (!subscribe) return;
+ try { await subscribe(`app.${key}.*`); } catch (err) { log.warn(`[Quest] subscribing to app.${key}.* failed: ${err.message}`); }
+ }
+
+ return {
+ MAX_PER_PROJECT, projectKey, asQuest,
+ /** The Events subscription helper (server/index.js sets it once subscriptions start). */
+ setSubscriber(fn) { subscribe = typeof fn === 'function' ? fn : null; },
+
+ /** Fill the badge names at boot; subscribe to every project with an active production quest. */
+ async load() {
+ for (const r of await s.db.many('SELECT id, badge_name FROM app_quests')) badgeNames.set(r.id, r.badge_name);
+ for (const r of await s.db.many("SELECT DISTINCT project_key FROM app_quests WHERE env = 'production' AND status = 'active'")) await ensureSubscribed(r.project_key);
+ },
+ badgeName: (id) => badgeNames.get(String(id)) || null,
+
+ /** Create or replace one quest for the app's project. → { status, row } | { status, code, detail } */
+ async put(principal, slug, body) {
+ const projectId = principal.project;
+ const key = projectKey(projectId);
+ if (!key || !String(principal.requester || '').startsWith('app:')) return { status: 403, code: 'quest.app_only', detail: 'Only a developer app token with a project defines app quests.' };
+ if (!SLUG_RE.test(String(slug || ''))) return { status: 422, code: 'quest.bad_slug', detail: 'slug: lower-case letters, digits and dashes, at most 48' };
+ const v = contracts.validate('quest.app-quest-request@1', body);
+ if (!v.valid) return { status: 422, code: 'quest.invalid', detail: `the quest does not match quest.app-quest-request@1: ${JSON.stringify(v.errors).slice(0, 400)}` };
+ const foreign = body.steps.find((st) => !st.event_type.startsWith(`app.${key}.`));
+ if (foreign) return { status: 422, code: 'quest.foreign_event', detail: `${foreign.event_type} is not one of this project's events (app.${key}.<name>)` };
+ const id = `app.${key}.${slug}`;
+ const others = Number(await s.db.value("SELECT count(*) FROM app_quests WHERE project_key = $1 AND id <> $2 AND status = 'active'", [key, id])) || 0;
+ if (others >= MAX_PER_PROJECT && (body.status || 'active') === 'active') return { status: 409, code: 'quest.limit', detail: `a project has at most ${MAX_PER_PROJECT} active app quests; retire one or reuse a slug` };
+ const env = principal.claims && principal.claims.env === 'sandbox' ? 'sandbox' : 'production';
+ const at = iso();
+ const status = body.status || 'active';
+ const existed = await s.db.maybe('SELECT id FROM app_quests WHERE id = $1', [id]);
+ await s.db.query(`INSERT INTO app_quests (id, project_id, project_key, app, env, slug, title, why, steps, badge_name, url, status, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $13)
+ ON CONFLICT (id) DO UPDATE SET app = excluded.app, env = excluded.env, title = excluded.title, why = excluded.why,
+ steps = excluded.steps, badge_name = excluded.badge_name, url = excluded.url, status = excluded.status, updated_at = excluded.updated_at`,
+ [id, projectId, key, principal.requester, env, slug, body.title, body.why || null, JSON.stringify(body.steps), body.badge.name, body.url || null, status, at]);
+ badgeNames.set(id, body.badge.name);
+ if (env === 'production' && status === 'active') await ensureSubscribed(key);
+ return { status: existed ? 200 : 201, row: await s.db.maybe('SELECT * FROM app_quests WHERE id = $1', [id]) };
+ },
+
+ async listForProject(projectId) {
+ return s.db.many('SELECT * FROM app_quests WHERE project_id = $1 ORDER BY created_at', [projectId]);
+ },
+
+ /** Retire one (its completions and badges stay). → the row, or null. */
+ async retire(projectId, slug) {
+ const key = projectKey(projectId);
+ if (!key) return null;
+ return s.db.maybe("UPDATE app_quests SET status = 'retired', updated_at = $3 WHERE project_key = $1 AND slug = $2 RETURNING *", [key, String(slug), iso()]);
+ },
+
+ /** The active production app quests with a step that counts this event (in `db`, the delivery's transaction). */
+ async forEvent(db, event) {
+ const type = String((event && event.event_type) || '');
+ if (!type.startsWith('app.')) return [];
+ const rows = await db.many(`SELECT * FROM app_quests WHERE env = 'production' AND status = 'active'
+ AND project_key = $1 AND steps @> $2::jsonb`, [type.split('.')[1], JSON.stringify([{ event_type: type }])]);
+ return rows.map(asQuest);
+ },
+
+ /** The public list: active production quests, newest first. */
+ async listed(limit = 100) {
+ return (await s.db.many("SELECT * FROM app_quests WHERE env = 'production' AND status = 'active' ORDER BY updated_at DESC LIMIT $1", [limit])).map(asQuest);
+ },
+ async byId(id) {
+ const row = await s.db.maybe('SELECT * FROM app_quests WHERE id = $1', [String(id)]);
+ return row ? asQuest(row) : null;
+ },
+
+ /** quest.app-quest@1 */
+ async wire(row) {
+ const completions = Number(await s.db.value('SELECT count(*) FROM quest_completions WHERE quest_id = $1', [row.id])) || 0;
+ return {
+ id: row.id, slug: row.slug, project_id: row.project_id, app: row.app, env: row.env, title: row.title, why: row.why || null,
+ steps: parse(row.steps, []), badge: { id: row.id, name: row.badge_name }, url: row.url || null, status: row.status,
+ created_at: row.created_at, updated_at: row.updated_at, completions,
+ };
+ },
+ };
+}
+
+module.exports = { createAppQuests, projectKey, asQuest, MAX_PER_PROJECT };
@@ -65,6 +65,9 @@ function personOf(event) {
@@ -65,6 +65,9 @@ function personOf(event) {
case 'bot.robot.online': // payload.owner (usr_…, contracts 0.192.0; absent from older events)
case 'bot.robot.online': // payload.owner (usr_…, contracts 0.192.0; absent from older events)
return isUser(p.owner) ? p.owner : null;
return isUser(p.owner) ? p.owner : null;
default:
default:
+ // An app's own event (app quests, server/quests/app-quests.js): the actor, when the app published it
+ // on_behalf_of a person; an app acting as itself is nobody.
+ if (String(event && event.event_type).startsWith('app.')) return refUser(event.actor);
return null;
return null;
}
}
}
}
@@ -75,10 +78,10 @@ function personOf(event) {
@@ -75,10 +78,10 @@ function personOf(event) {
* with outcome 'ignored:subject' | 'ignored:type' | 'progress' | 'completed'. `completed` is what the caller
* with outcome 'ignored:subject' | 'ignored:type' | 'progress' | 'completed'. `completed` is what the caller
* still has to do about OpenCoins.
* still has to do about OpenCoins.
*/
*/
-async function apply(db, event, { at, newId }) {
+async function apply(db, event, { at, newId, appQuests = null }) {
const subject = personOf(event);
const subject = personOf(event);
if (!subject) return { subject: null, outcome: 'ignored:subject', completed: [] };
if (!subject) return { subject: null, outcome: 'ignored:subject', completed: [] };
- const quests = catalog.forEvent(event);
+ const quests = [...catalog.forEvent(event), ...(appQuests ? await appQuests.forEvent(db, event) : [])];
if (!quests.length) return { subject, outcome: 'ignored:type', completed: [] };
if (!quests.length) return { subject, outcome: 'ignored:type', completed: [] };
const completed = [];
const completed = [];
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+/**
+ * App quests (contracts 0.193.0, quest.app_quest.manage; server/quests/app-quests.js):
+ * - only a developer app token with a project and the capability defines them; a person, a service or an app without
+ * the capability cannot;
+ * - a step may count only the project's own events (app.<project_key>.<name>), and the body is quest.app-quest-request@1;
+ * - a production quest subscribes Quest to app.<project_key>.* and counts the app's events whose actor is a person;
+ * the reward is a badge, never OpenCoins; a sandbox quest is listed to its app alone and counts nothing;
+ * - at most 20 per project; a retired quest stops counting and keeps its badges; the public page and the badge name.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { ids, serviceAuth } = require('openvibe-contracts');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+
+const SECRET = 'quest-events-secret-0123456789abcdef';
+const ULID = () => ids.newId('user').slice(4);
+
+(async () => {
+ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET } });
+ const subscribed = [];
+ t.ctx.appQuests.setSubscriber(async (topic) => { subscribed.push(topic); });
+ const projectId = `prj_${ULID()}`;
+ const key = `p${projectId.slice(4).toLowerCase()}`;
+ const appId = `app_${ULID()}`;
+ const token = ({ env = 'production', cap = ['quest.app_quest.manage'], project = projectId, sub = `app:${appId}` } = {}) => {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'app', aud: ['openvibe.quest'], cap, ns: [project], project_id: project, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, t.network.privatePem);
+ };
+ const put = (slug, body, tok = token()) => t.get(`/api/v1/app-quests/${slug}`, { method: 'PUT', json: body, headers: { authorization: `Bearer ${tok}` } });
+ const QUEST = { title: 'Collector', why: 'Find five gems.', steps: [{ event_type: `app.${key}.gem.found`, count: 5, label: 'Find a gem' }], badge: { name: 'Gem Collector' }, url: 'https://example.com/play' };
+ const deliver = (event) => {
+ const raw = JSON.stringify({ event, seq: 1 });
+ return t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });
+ };
+ const appEvent = (type, actor) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: `app-${appId.slice(4).toLowerCase()}`, actor, timestamp: new Date().toISOString(), subject: { type: 'thing', id: '1' }, payload: {} });
+ const sam = t.network.addUser('sam');
+
+ try {
+ await check('only an app token with a project and the capability defines a quest', async () => {
+ assert.strictEqual((await t.get(`/api/v1/app-quests/x`, { method: 'PUT', json: QUEST })).status, 401);
+ assert.strictEqual((await t.get(`/api/v1/app-quests/x`, { method: 'PUT', json: QUEST, headers: { authorization: `Bearer ${t.network.userToken(sam)}` } })).status, 403, 'a person cannot');
+ assert.strictEqual((await put('x', QUEST, token({ cap: ['events.app.publish'] }))).status, 403, 'an app without the capability cannot');
+ assert.strictEqual((await put('x', QUEST, token({ sub: 'svc:someone' }))).status, 403, 'a service cannot');
+ });
+
+ await check('the body is checked; a step may count only this project\'s own events', async () => {
+ const foreign = await put('x', { ...QUEST, steps: [{ event_type: `app.p${'0'.repeat(26)}.x`, count: 1, label: 'x' }] });
+ assert.strictEqual(foreign.status, 422);
+ assert.strictEqual(foreign.json().code, 'quest.foreign_event');
+ assert.strictEqual((await put('x', { ...QUEST, coins: 50 })).status, 422, 'no OpenCoins field at all');
+ assert.strictEqual((await put('Bad Slug', QUEST)).status, 422);
+ });
+
+ await check('a production quest: 201, Quest subscribes to the project\'s events, a person completes it and gets the badge (no coins)', async () => {
+ const r = await put('collector', QUEST);
+ assert.strictEqual(r.status, 201, r.text);
+ const q = r.json();
+ assert.deepStrictEqual([q.id, q.env, q.status, q.badge.name, q.completions], [`app.${key}.collector`, 'production', 'active', 'Gem Collector', 0]);
+ assert.deepStrictEqual(subscribed, [`app.${key}.*`]);
+ assert.strictEqual((await put('collector', { ...QUEST, title: 'Gem Collector' })).status, 200, 'replace');
+ // The app acting as itself is nobody; on behalf of a person, it counts.
+ assert.match((await deliver(appEvent(`app.${key}.gem.found`, { type: 'app', id: appId }))).json().outcome, /^ignored:/);
+ for (let i = 0; i < 5; i++) await deliver(appEvent(`app.${key}.gem.found`, { type: 'user', id: sam.subject }));
+ const c = await t.ctx.s.db.many('SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = $2', [sam.subject, q.id]);
+ assert.strictEqual(c.length, 1);
+ assert.deepStrictEqual([Number(c[0].coins), c[0].coins_state], [0, 'skipped']);
+ const badges = (await t.get(`/api/v1/profiles/${sam.subject}/badges`)).json().badges;
+ assert.ok(badges.some((b) => b.id === q.id && b.name === 'Gem Collector'), JSON.stringify(badges));
+ const page = await t.get(`/apps/quests/${q.id}`);
+ assert.ok(page.text.includes('Gem Collector') && page.text.includes('App quests never pay OpenCoins') && page.text.includes('1 person has this badge'));
+ assert.ok((await t.get('/')).text.includes(`href="/apps/quests/${q.id}"`), 'listed on the home page');
+ assert.strictEqual((await t.get('/api/v1/app-quests', { headers: { authorization: `Bearer ${token()}` } })).json().quests[0].completions, 1);
+ });
+
+ await check('a sandbox quest is the app\'s alone: not listed, not counted, no subscription', async () => {
+ const before = subscribed.length;
+ const r = await put('sandbox-try', { ...QUEST, steps: [{ event_type: `app.${key}.level_completed`, count: 1, label: 'Finish a level' }] }, token({ env: 'sandbox' }));
+ assert.strictEqual(r.status, 201);
+ assert.strictEqual(r.json().env, 'sandbox');
+ assert.strictEqual(subscribed.length, before);
+ assert.strictEqual((await t.get(`/apps/quests/${r.json().id}`)).status, 404);
+ await deliver(appEvent(`app.${key}.level_completed`, { type: 'user', id: sam.subject }));
+ assert.strictEqual((await t.ctx.s.db.many('SELECT * FROM quest_completions WHERE quest_id = $1', [r.json().id])).length, 0);
+ });
+
+ await check('retired: no longer counted or listed, its badges stay; at most 20 a project', async () => {
+ const del = await t.get('/api/v1/app-quests/collector', { method: 'DELETE', headers: { authorization: `Bearer ${token()}` } });
+ assert.strictEqual(del.json().status, 'retired');
+ assert.ok(!(await t.get('/')).text.includes('/apps/quests/'), 'off the home page');
+ assert.ok((await t.get(`/api/v1/profiles/${sam.subject}/badges`)).json().badges.some((b) => b.name === 'Gem Collector'), 'the badge stays');
+ // Active: the sandbox one and 19 more is 20; the retired one does not count.
+ for (let i = 0; i < 19; i++) assert.strictEqual((await put(`q${i}`, QUEST)).status, 201);
+ const over = await put('one-too-many', QUEST);
+ assert.strictEqual(over.status, 409);
+ assert.strictEqual(over.json().code, 'quest.limit');
+ });
+ } finally { await t.close(); }
+ done();
+})();
@@ -20,8 +20,8 @@ const READ = 'quest.api.read';
@@ -20,8 +20,8 @@ const READ = 'quest.api.read';
const rosa = t.network.addUser('rosa');
const rosa = t.network.addUser('rosa');
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
- await check('BUDGETS starts empty: the product declares its own expensive routes', () => {
- assert.deepStrictEqual(BUDGETS, {});
+ await check('BUDGETS: the product declares its own expensive routes (an app defining its quests)', () => {
+ assert.deepStrictEqual(BUDGETS, { 'quest.app_quest.write': { minute: 20, hour: 200 } });
});
});
await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {