openvibers/openvibe.sdk Commits

SDK 0.47.0: openvibe-sdk/quest (app quests) (#51)

OP OpenVibers committed 29 days ago · 75cbf498c3 · 17 files, +145 −12

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b39da0c..4314ed5 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,13 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.47.0 (2026-10-15)
++
++- `openvibe-sdk/quest`: `createQuestClient(client)` → `appQuests.define(slug, quest)`, `appQuests.list()`,
++ `appQuests.retire(slug)`, and `eventType(projectId, name)` / `projectKey(projectId)` for the `app.<project_key>.<name>`
++ events an app publishes. App quests (contracts 0.193.0, `quest.app_quest.manage`): a developer app defines quests on
++ its own events on OpenVibe.Quest; the reward is a badge named for the app, never OpenCoins.
++
## 0.46.0 (2026-10-13)
- `startTelemetryRetention({ db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log? })` → `{ run(), stop() }`:
ddiff --git a/README.md b/README.md
iindex 53da984..5dec08a 100644
---- a/README.md
++++ b/README.md
@@@ -184,6 +184,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/resources` | server | `parseResourceName`/`resourceName`/`resourceNameOf` (OVRN, via the pinned `contracts.resources`) and `RESOURCE_KINDS` (only the chosen `med`/`wch` prefixes); `createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs})`: `list({project, kind, owner, limit})` -> `{resources, stale}` (`owner`: a subject id, each authority answering only what it owns), fanning out over the authorities' `GET /api/v1/resources`, following each opaque `next_cursor` and merging `common.resource-list-result@1` pages (an authority that fails — or whose cursor chain repeats, outruns `maxPages`, or outruns `maxMs` — is reported in `stale`, never fatal), and `iterate`; `createResourceClient({origin, token})`: `control(request)` POSTs a `common.resource-control-request@1` to `/api/v1/resources/control` — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that `idempotencyKey` — and returns the `common.resource-control-result@1` (`done`/`pending`/`refused`/`failed`, a refused/failed one returned even with a non-retryable non-2xx) |
| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
++| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit`, `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |
ddiff --git a/STATUS.json b/STATUS.json
iindex b349d17..8c7d8be 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.46.0",
++ "version": "0.47.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@@ -15,7 +15,7 @@
"events: publish, pull and checkpoints by opaque cursor only, app events, webhook verification (v2), outbox and inbox",
"realtime SSE (resumes from the opaque cursor, dedupes by event_id)",
"media files and objects v2",
-- "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo and vip clients",
++ "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip and quest clients",
"bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing",
"per-actor limiter for services (openvibe-sdk/limits)",
"the OpenVibe Frame for any app",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 5f56634..fc054a6 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.46.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.47.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.46.0';
++const SDK_VERSION = '0.47.0';
module.exports = {
createClient,
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex f978db8..b11d979 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -7,7 +7,7 @@ export const {
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
community, bot, search, chat, ai, jobs,
-- tools, projects, resources, vip, openre, geo,
-- usage, commerce, telemetry,
++ tools, projects, resources, vip, quest, openre,
++ geo, usage, commerce, telemetry,
} = cjs;
export default cjs;
ddiff --git a/esm/quest.mjs b/esm/quest.mjs
nnew file mode 100644
iindex 0000000..b8cd0f6
---- /dev/null
++++ b/esm/quest.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/quest.js. Do not edit.
++import cjs from '../src/quest.js';
++
++export const {
++ createQuestClient, eventType, projectKey,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex 88de73b..6c2ddf2 100644
---- a/index.js
++++ b/index.js
@@@ -25,6 +25,7 @@ module.exports = {
projects: require('./src/projects'),
resources: require('./src/resources'),
vip: require('./src/vip'),
++ quest: require('./src/quest'),
openre: require('./src/openre'),
geo: require('./src/geo'),
usage: require('./src/usage'),
ddiff --git a/package-lock.json b/package-lock.json
iindex 804e1f9..2deb987 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.46.0",
++ "version": "0.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.46.0",
++ "version": "0.47.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 2e57cca..08cc4cb 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.46.0",
++ "version": "0.47.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -236,6 +236,12 @@
"import": "./esm/openre.mjs",
"default": "./src/openre.js"
},
++ "./quest": {
++ "types": "./types/quest.d.ts",
++ "browser": null,
++ "import": "./esm/quest.mjs",
++ "default": "./src/quest.js"
++ },
"./vip": {
"types": "./types/vip.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 998832b..a7fdd7f 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -52,6 +52,7 @@ const ENTRIES = {
'projects.mjs': '../src/projects.js',
'geo.mjs': '../src/geo.js',
'openre.mjs': '../src/openre.js',
++ 'quest.mjs': '../src/quest.js',
'vip.mjs': '../src/vip.js',
'frame.mjs': '../src/frame.js',
'service.mjs': '../src/service/index.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 679eb8b..8569f9b 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.46.0';
++const SDK_VERSION = '0.47.0';
module.exports = {
createClient,
ddiff --git a/src/quest.js b/src/quest.js
nnew file mode 100644
iindex 0000000..4bf4551
---- /dev/null
++++ b/src/quest.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * openvibe-sdk/quest — OpenVibe.Quest for developer apps: quests on your own events (contracts 0.193.0, capability
++ * quest.app_quest.manage). Your app publishes its events at OpenVibe.Events as app.<project_key>.<name> (on behalf of
++ * the person who did the thing); a quest counts them, and the person gets a badge named for your app. App quests never
++ * pay OpenCoins, count production events only, and a project keeps at most 20 active.
++ *
++ * const quest = createQuestClient(createClient({ tokenProvider: appTokens })); // an app token for openvibe.quest
++ * const type = quest.eventType('prj_01JAB…', 'gem.found'); // 'app.p01jab….gem.found'
++ * await quest.appQuests.define('collector', {
++ * title: 'Collector', why: 'Find five gems.',
++ * steps: [{ event_type: type, count: 5, label: 'Find a gem' }],
++ * badge: { name: 'Gem Collector' }, url: 'https://example.com/play',
++ * }); // quest.app-quest@1
++ * await quest.appQuests.list(); // { project_id, max, quests }
++ * await quest.appQuests.retire('collector'); // completions and badges stay
++ *
++ * Errors are OpenVibeError (status, code): quest.app_only (not an app token with a project), quest.foreign_event (a
++ * step names another project's events), quest.invalid, quest.limit, capability.denied.
++ */
++const enc = encodeURIComponent;
++const PROJECT_RE = /^prj_([0-9A-HJKMNP-TV-Z]{26})$/;
++const NAME_RE = /^[a-z0-9_]+(\.[a-z0-9_]+){0,3}$/;
++
++/** 'prj_01JAB…' → 'p01jab…': the middle segment of the project's own event types (OpenVibe.Events' project key). */
++function projectKey(projectId) {
++ const m = PROJECT_RE.exec(String(projectId || ''));
++ if (!m) throw new TypeError('projectKey: pass a prj_… project id');
++ return `p${m[1].toLowerCase()}`;
++}
++
++/** The event type an app publishes as `name` ("gem.found"): app.<project_key>.<name>. */
++function eventType(projectId, name) {
++ if (!NAME_RE.test(String(name || ''))) throw new TypeError('eventType: name is dot-separated segments of [a-z0-9_] (at most four)');
++ return `app.${projectKey(projectId)}.${name}`;
++}
++
++function createQuestClient(client, { baseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createQuestClient: pass an openvibe-sdk/core client');
++ const call = (opts) => client.json({ service: 'quest', baseUrl, audience: 'openvibe.quest', ...opts });
++ const appQuests = {
++ /** Create or replace one of your app's quests (quest.app-quest-request@1) → quest.app-quest@1. */
++ define: (slug, quest, { signal } = {}) => call({ method: 'PUT', path: `/api/v1/app-quests/${enc(slug)}`, json: quest, signal, idempotencyKey: false }),
++ /** → { project_id, max, quests: [quest.app-quest@1] } */
++ list: ({ signal } = {}) => call({ path: '/api/v1/app-quests', signal }),
++ /** Retire one: no longer counted or listed; its completions and badges stay. → quest.app-quest@1 */
++ retire: (slug, { signal } = {}) => call({ method: 'DELETE', path: `/api/v1/app-quests/${enc(slug)}`, signal }),
++ };
++ return { appQuests, eventType, projectKey };
++}
++
++module.exports = { createQuestClient, eventType, projectKey };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex e7e6221..e36975a 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './pubsub', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 37aebbc..45e6519 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 44);
++ assert.equal(subpaths.length, 45);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/quest.test.js b/test/quest.test.js
nnew file mode 100644
iindex 0000000..c3e14d6
---- /dev/null
++++ b/test/quest.test.js
@@@ -0,0 +1,30 @@
++'use strict';
++// openvibe-sdk/quest: app quests on OpenVibe.Quest (contracts 0.193.0): the routes, the audience, and the event types an
++// app publishes them under.
++const assert = require('assert');
++const { createQuestClient, eventType, projectKey } = require('../src/quest');
++
++(async () => {
++ const calls = [];
++ const client = { json: async (opts) => { calls.push(opts); return { ok: true }; } };
++ const quest = createQuestClient(client, { baseUrl: 'http://quest.test' });
++ const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++
++ assert.strictEqual(projectKey(PRJ), 'p01jab2c3d4e5f6g7h8j9k0mnpq');
++ assert.strictEqual(eventType(PRJ, 'gem.found'), 'app.p01jab2c3d4e5f6g7h8j9k0mnpq.gem.found');
++ assert.throws(() => eventType('prj_x', 'gem'), /project id/);
++ assert.throws(() => eventType(PRJ, 'Gem Found'), /segments/);
++
++ const def = { title: 'Collector', steps: [{ event_type: eventType(PRJ, 'gem.found'), count: 5, label: 'Find a gem' }], badge: { name: 'Gem Collector' } };
++ await quest.appQuests.define('collector', def);
++ await quest.appQuests.list();
++ await quest.appQuests.retire('collector');
++ assert.deepStrictEqual(calls.map((c) => [c.method || 'GET', c.path, c.audience, c.service, c.baseUrl]), [
++ ['PUT', '/api/v1/app-quests/collector', 'openvibe.quest', 'quest', 'http://quest.test'],
++ ['GET', '/api/v1/app-quests', 'openvibe.quest', 'quest', 'http://quest.test'],
++ ['DELETE', '/api/v1/app-quests/collector', 'openvibe.quest', 'quest', 'http://quest.test'],
++ ]);
++ assert.deepStrictEqual(calls[0].json, def);
++ assert.throws(() => createQuestClient({}), /core client/);
++ console.log('quest: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 91ca0f0..702b57a 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -16,6 +16,7 @@ export * as tools from './tools';
export * as projects from './projects';
export * as resources from './resources';
export * as vip from './vip';
++export * as quest from './quest';
export * as openre from './openre';
export * as geo from './geo';
export * as usage from './usage';
ddiff --git a/types/quest.d.ts b/types/quest.d.ts
nnew file mode 100644
iindex 0000000..a680ffb
---- /dev/null
++++ b/types/quest.d.ts
@@@ -0,0 +1,27 @@
++/** openvibe-sdk/quest: OpenVibe.Quest for developer apps — quests on your own events (contracts 0.193.0). */
++export interface AppQuestStep { event_type: string; count: number; label: string }
++export interface AppQuestRequest {
++ title: string;
++ why?: string;
++ steps: AppQuestStep[];
++ badge: { name: string };
++ url?: string;
++ status?: 'active' | 'retired';
++}
++export interface AppQuest {
++ id: string; slug: string; project_id: string; app: string; env: 'sandbox' | 'production';
++ title: string; why: string | null; steps: AppQuestStep[]; badge: { id: string; name: string }; url: string | null;
++ status: 'active' | 'retired'; created_at: string; updated_at: string; completions: number;
++}
++export interface QuestClient {
++ appQuests: {
++ define(slug: string, quest: AppQuestRequest, opts?: { signal?: AbortSignal }): Promise<AppQuest>;
++ list(opts?: { signal?: AbortSignal }): Promise<{ project_id: string; max: number; quests: AppQuest[] }>;
++ retire(slug: string, opts?: { signal?: AbortSignal }): Promise<AppQuest>;
++ };
++ eventType(projectId: string, name: string): string;
++ projectKey(projectId: string): string;
++}
++export function createQuestClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): QuestClient;
++export function eventType(projectId: string, name: string): string;
++export function projectKey(projectId: string): string;