openvibers/openvibe.quest Commits

Quest: app quests — developer apps define quests on their own events (quest.app_quest.manage, contracts 0.193.1) (#55)

OP OpenVibers committed 29 days ago · fadc8b3e71 · 16 files, +377 −29

Changes

ddiff --git a/README.md b/README.md
iindex 7ad6433..841cae1 100644
---- a/README.md
++++ b/README.md
@@@ -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, and
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
| `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 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
ddiff --git a/STATUS.json b/STATUS.json
iindex e41e940..ebe9d45 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
ddiff --git a/migrations/0007_app_quests.sql b/migrations/0007_app_quests.sql
nnew file mode 100644
iindex 0000000..b7946fd
---- /dev/null
++++ b/migrations/0007_app_quests.sql
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
iindex 375c0cf..2af03f8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 5012b41..a30dbb8 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/app.js b/server/app.js
iindex 2d51b18..afdf183 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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');
ddiff --git a/server/events-consumer.js b/server/events-consumer.js
iindex 351a9e1..15fbd41 100644
---- a/server/events-consumer.js
++++ b/server/events-consumer.js
@@@ -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.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);
}); };
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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 145e94e..5d1751e 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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 };
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 22404c0..57ea728 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -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
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 41577ce..3599f1b 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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',
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 239fada..735e4f8 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -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 } };
ddiff --git a/server/index.js b/server/index.js
iindex 19e96f7..9f194b0 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 };
ddiff --git a/server/quests/app-quests.js b/server/quests/app-quests.js
nnew file mode 100644
iindex 0000000..0627b9f
---- /dev/null
++++ b/server/quests/app-quests.js
@@@ -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 };
ddiff --git a/server/quests/apply.js b/server/quests/apply.js
iindex adb4b82..f24c7ec 100644
---- a/server/quests/apply.js
++++ b/server/quests/apply.js
@@@ -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 = [];
ddiff --git a/test/app-quests.test.js b/test/app-quests.test.js
nnew file mode 100644
iindex 0000000..7e93635
---- /dev/null
++++ b/test/app-quests.test.js
@@@ -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();
++})();
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex c2b44fb..12ff9f7 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -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 () => {