openvibers/openvibe.sdk Commits
SDK 0.47.0: openvibe-sdk/quest (app quests) (#51)
Changes
@@@ -3,6 +3,13 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease 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() }`:@@@ -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 |@@@ -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",@@@ -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,@@@ -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;nnew file mode 100644@@@ -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;@@@ -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'),@@@ -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",@@@ -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,@@@ -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',@@@ -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,nnew file mode 100644@@@ -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 };@@@ -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`);@@@ -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 });nnew file mode 100644@@@ -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); });@@@ -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';nnew file mode 100644@@@ -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;
@@ -3,6 +3,13 @@
@@ -3,6 +3,13 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
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.
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)
## 0.46.0 (2026-10-13)
- `startTelemetryRetention({ db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log? })` → `{ run(), stop() }`:
- `startTelemetryRetention({ db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log? })` → `{ run(), stop() }`:
@@ -184,6 +184,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -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/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/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/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/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/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 |
| `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 |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"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",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"events: publish, pull and checkpoints by opaque cursor only, app events, webhook verification (v2), outbox and inbox",
"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)",
"realtime SSE (resumes from the opaque cursor, dedupes by event_id)",
"media files and objects v2",
"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",
"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)",
"per-actor limiter for services (openvibe-sdk/limits)",
"the OpenVibe Frame for any app",
"the OpenVibe Frame for any app",
@@ -1,4 +1,4 @@
@@ -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
// 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.
// (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 */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
@@ -7,7 +7,7 @@ export const {
@@ -7,7 +7,7 @@ export const {
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
registry, identity, modules, events, realtime, media,
community, bot, search, chat, ai, jobs,
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;
} = cjs;
export default cjs;
export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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;
@@ -25,6 +25,7 @@ module.exports = {
@@ -25,6 +25,7 @@ module.exports = {
projects: require('./src/projects'),
projects: require('./src/projects'),
resources: require('./src/resources'),
resources: require('./src/resources'),
vip: require('./src/vip'),
vip: require('./src/vip'),
+ quest: require('./src/quest'),
openre: require('./src/openre'),
openre: require('./src/openre'),
geo: require('./src/geo'),
geo: require('./src/geo'),
usage: require('./src/usage'),
usage: require('./src/usage'),
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.46.0",
+ "version": "0.47.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.46.0",
+ "version": "0.47.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -236,6 +236,12 @@
@@ -236,6 +236,12 @@
"import": "./esm/openre.mjs",
"import": "./esm/openre.mjs",
"default": "./src/openre.js"
"default": "./src/openre.js"
},
},
+ "./quest": {
+ "types": "./types/quest.d.ts",
+ "browser": null,
+ "import": "./esm/quest.mjs",
+ "default": "./src/quest.js"
+ },
"./vip": {
"./vip": {
"types": "./types/vip.d.ts",
"types": "./types/vip.d.ts",
"browser": null,
"browser": null,
@@ -52,6 +52,7 @@ const ENTRIES = {
@@ -52,6 +52,7 @@ const ENTRIES = {
'projects.mjs': '../src/projects.js',
'projects.mjs': '../src/projects.js',
'geo.mjs': '../src/geo.js',
'geo.mjs': '../src/geo.js',
'openre.mjs': '../src/openre.js',
'openre.mjs': '../src/openre.js',
+ 'quest.mjs': '../src/quest.js',
'vip.mjs': '../src/vip.js',
'vip.mjs': '../src/vip.js',
'frame.mjs': '../src/frame.js',
'frame.mjs': '../src/frame.js',
'service.mjs': '../src/service/index.js',
'service.mjs': '../src/service/index.js',
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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 };
@@ -76,7 +76,7 @@ run([
@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
}
assert.deepEqual(problems, []);
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));
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']) {
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`);
assert.ok(rel.includes(must), `${must} is scanned`);
@@ -66,7 +66,7 @@ run([
@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
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.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');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -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); });
@@ -16,6 +16,7 @@ export * as tools from './tools';
@@ -16,6 +16,7 @@ export * as tools from './tools';
export * as projects from './projects';
export * as projects from './projects';
export * as resources from './resources';
export * as resources from './resources';
export * as vip from './vip';
export * as vip from './vip';
+export * as quest from './quest';
export * as openre from './openre';
export * as openre from './openre';
export * as geo from './geo';
export * as geo from './geo';
export * as usage from './usage';
export * as usage from './usage';
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -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;