openvibers/openvibe.sdk Commits

0.84.0: Vibes Pay for developer apps (openvibe-sdk/vibes) (#116)

OP OpenVibers committed 9 days ago · f9ac83abd7 · 15 files, +466 −15

Adds createVibesClient({ appToken | tokenProvider, billingUrl }) with charges.create/get/refund,
the reference as the Idempotency-Key and whole-Vibes validation, and webhooks.verify for the raw
body (X-OpenVibe-Signature-V2, constant time, 300 s replay window, legacy sha256 accepted).
Exports, types, ESM, pack test, STATUS, README and CHANGELOG are updated; no tag.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 242d9ce..9827e5e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,20 @@
# Changelog
++## 0.84.0 (2026-11-04)
++
++- **`openvibe-sdk/vibes`: Vibes Pay for developer apps** (MONEY.md, D12). A server creates a charge with its app
++ token (`billing.vibes.charge`), the person pays on OpenVibe's pay sheet in whole Vibes, and Billing moves them
++ once to the project's Vibes wallet.
++ - `createVibesClient({appToken | tokenProvider, billingUrl})` with `charges.create({amount, description, reference,
++ return_url})`, `charges.get(id)` and `charges.refund(id, {reason?})`. `amount` is a positive whole number of Vibes;
++ a float, zero or negative amount is a `TypeError` before any request. The app's `reference` is the
++ `Idempotency-Key`, so a retried create is the same charge. A token provider may be a function, an object with
++ `getToken()`/`invalidate()`, or one with `authHeaders()`; a 401 invalidates it and retries once with the same key.
++ problem+json becomes `VibesError` with `status`, `code`, `body` and `retryable`.
++ - `webhooks.verify(rawBody, signatureHeader, secret)` checks the raw body in constant time against
++ `X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">`, and rejects a timestamp
++ more than 300 s from the server's clock either way. The legacy `sha256=<hex>` (or bare hex) body-only form is
++ accepted. Bad input is `false`, never an exception. The same function is exported as `verifyWebhook`.
## 0.83.0 (2026-11-03)
- **`openvibe-sdk/placement`: `planAi()`, the AI Exchange face of the ONE planner** (plan-state 2026-10-22 §E,
ddiff --git a/README.md b/README.md
iindex f1ba4ae..186f317 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.83.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.84.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.83.0 is tagged:
++After v0.84.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.83.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@@ -219,6 +219,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `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`/`tier`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit` (`tier`), `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `listTiers(creator)` and `setTier({creator, tier, priceCents, status?, key})` (a creator's tier prices, kept by Billing; optional `caps.tier` = `billing.tier.manage`, which `setTier` needs), `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/vibes` | server | `createVibesClient({appToken \| tokenProvider, billingUrl, fetchImpl?, timeoutMs?})`: Vibes Pay for developer apps (D12). `charges.create({amount, description, reference, return_url})` sends a whole number of Vibes with the reference as the `Idempotency-Key`, → `{id, pay_url, status, expires_at}`; `charges.get(id)`, `charges.refund(id, {reason?})`. `webhooks.verify(rawBody, signatureHeader, secret, {toleranceSec = 300, now?})` checks `X-OpenVibe-Signature-V2` (`t=<unix>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">`, constant time, replay window) and the legacy `sha256=<hex>` body-only form; also exported as `verifyWebhook`. A float, zero or negative amount is refused before any request; problem+json becomes `VibesError` (`status`, `code`, `body`, `retryable`), a 401 invalidates a token provider and retries once with the same key |
| `openvibe-sdk/telemetry` | server | `telemetrySample(fields)` / `validateTelemetrySample(record)` for `platform.telemetry-sample@1`; `createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000})` → `{record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}`: buffered samples flushed to `await sink(samples)` on an unref'd interval and on `stop()` (a `gracefulStop` stop step); `record` builds `{service, instance, operation, at, latency_ms, ...labels}` (a non-number value goes to `extra[name]`); a rejecting sink is logged once and its samples kept; past `maxBuffered` the oldest sample is dropped, with a warning at most once a minute; the per-service PostgreSQL sink: `telemetrySchema(table = 'telemetry_samples')` (migration DDL), `createPgTelemetrySink({db, table?, log?})` (one multi-row INSERT per flush, malformed samples dropped with a warning), `pruneTelemetry({db, table?, olderThanDays = 30})`, `startTelemetryRetention({db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log?})` → `{run(), stop()}` (prunes soon after start and then daily on unref'd timers, so a service redeployed more than daily still prunes) |
| `openvibe-sdk/cost` | server | Build and validate `platform.rate-card@1` and `platform.cost-snapshot@1` records with `rateCard()`, `costSnapshot()`, `validateRateCard()`, and `validateCostSnapshot()`; `estimate()` calculates a charge from a rate card; the AI Exchange money math in integer nano-USD with `nanoUsd()`, `chargeTokens()`, `expectedCostNano()`, `costPerSuccessNano()`, `formatNanoUsd()` |
| `openvibe-sdk/runtime` | server | Build and validate `platform.runtime-offer@1` records with `runtimeOffer()` and `validateRuntimeOffer()`; `runtimeClass()`, `RUNTIME_CLASSES`, and `validateRuntimeClass()` handle known runtime classes |
ddiff --git a/STATUS.json b/STATUS.json
iindex 5a3eded..099f254 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.83.0",
++ "version": "0.84.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",
@@@ -36,7 +36,8 @@
"placement v1 (openvibe-sdk/placement): place() with the person's rules and ownership, the quality bar, cost per success, learn/successOf, escalate and the one-line reason; plan() takes the optional success map and never defaults to user-owned",
"the AI Exchange face of the one planner (openvibe-sdk/placement planAi, off by default): the hard gates (exact artifact, commercial right, privacy class, trust tier, capabilities, context, health, cost ceiling), the expected cost per success in integer nano-USD, sparse quality evidence marked and not trusted, and an explanation that never carries a secret or a host; the money math in openvibe-sdk/cost (nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano)",
"consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side",
-- "openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to"
++ "openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to",
++ "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps"
],
-- "updated": "2026-10-31"
++ "updated": "2026-11-04"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 0cb8caa..294aeb0 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.83.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.84.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, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -47,7 +47,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.83.0';
++const SDK_VERSION = '0.84.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex d8d8a0f..a1dc3b7 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -870,6 +870,12 @@
"openvibe-sdk/valkey": {
"createValkey": "function"
},
++ "openvibe-sdk/vibes": {
++ "SIGNATURE_TOLERANCE_SEC": "number",
++ "VibesError": "class",
++ "createVibesClient": "function",
++ "verifyWebhook": "function"
++ },
"openvibe-sdk/video": {
"createVideoClient": "function"
},
ddiff --git a/esm/vibes.mjs b/esm/vibes.mjs
nnew file mode 100644
iindex 0000000..bf2708a
---- /dev/null
++++ b/esm/vibes.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/vibes.js. Do not edit.
++import cjs from '../src/vibes.js';
++
++export const {
++ createVibesClient, VibesError, verifyWebhook, SIGNATURE_TOLERANCE_SEC,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex e1c8804..db91d10 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.83.0",
++ "version": "0.84.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.83.0",
++ "version": "0.84.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 9b2190b..09c7233 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.83.0",
++ "version": "0.84.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",
@@@ -174,6 +174,12 @@
"import": "./esm/commerce.mjs",
"default": "./src/commerce.js"
},
++ "./vibes": {
++ "types": "./types/vibes.d.ts",
++ "browser": null,
++ "import": "./esm/vibes.mjs",
++ "default": "./src/vibes.js"
++ },
"./delivery": {
"types": "./types/delivery.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 1d0d277..17b1664 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -43,6 +43,7 @@ const ENTRIES = {
'runtime.mjs': '../src/runtime.js',
'storage.mjs': '../src/storage.js',
'commerce.mjs': '../src/commerce.js',
++ 'vibes.mjs': '../src/vibes.js',
'delivery.mjs': '../src/delivery.js',
'node.mjs': '../src/node.js',
'db.mjs': '../src/db/index.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex ce13d23..ed9f9db 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.83.0';
++const SDK_VERSION = '0.84.0';
module.exports = {
createClient,
ddiff --git a/src/vibes.js b/src/vibes.js
nnew file mode 100644
iindex 0000000..d42d611
---- /dev/null
++++ b/src/vibes.js
@@@ -0,0 +1,227 @@
++'use strict';
++/**
++ * openvibe-sdk/vibes (server): Vibes Pay for developer apps (MONEY.md, D12).
++ *
++ * An app creates a charge from its server with its app token (capability billing.vibes.charge); the
++ * person pays on OpenVibe's pay sheet in whole Vibes; Billing moves them once to the project's Vibes
++ * wallet and tells the app by a signed webhook and by GET /api/v1/vibes/charges/:id.
++ *
++ * const { createVibesClient } = require('openvibe-sdk/vibes');
++ * const vibes = createVibesClient({ appToken: process.env.OPENVIBE_APP_TOKEN, billingUrl: 'https://billing.openvibe.network' });
++ * const charge = await vibes.charges.create({ amount: 50, description: 'Sticker pack: Neon cats',
++ * reference: 'order_123', return_url: 'https://myapp.example/thanks' });
++ * // { id, pay_url, status: 'pending', expires_at }
++ * const paid = await vibes.charges.get(charge.id);
++ * await vibes.charges.refund(charge.id, { reason: 'requested by the buyer' });
++ *
++ * Webhooks carry X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">.
++ * webhooks.verify checks it in constant time and rejects a timestamp more than 300 s either way;
++ * it also accepts the platform's legacy sha256=<hex> of the body alone (no timestamp, replayable).
++ *
++ * Money is whole Vibes, an integer. A float is refused before any request is sent.
++ */
++
++const crypto = require('node:crypto');
++
++/** How far a signed webhook's timestamp may be from this server's clock, in seconds. */
++const SIGNATURE_TOLERANCE_SEC = 300;
++
++class VibesError extends Error {
++ constructor(message, { status = null, code = null, body = null } = {}) {
++ super(message);
++ this.name = 'VibesError';
++ this.status = status;
++ this.code = code;
++ this.body = body;
++ }
++ /** No status (network, timeout, token), 5xx, 429 and 401 are worth retrying; a refusal is not. */
++ get retryable() { return this.status == null || this.status >= 500 || this.status === 429 || this.status === 401; }
++}
++
++function toBuffer(raw) {
++ if (Buffer.isBuffer(raw)) return raw;
++ if (raw instanceof Uint8Array) return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
++ return Buffer.from(String(raw));
++}
++
++function hmac(secret, ...parts) {
++ const h = crypto.createHmac('sha256', String(secret));
++ for (const p of parts) h.update(p);
++ return h.digest('hex');
++}
++
++/** Constant-time compare of two lower-case hex digests of the same length. */
++function sameHex(given, expected) {
++ if (typeof given !== 'string' || !/^[0-9a-f]{64}$/i.test(given)) return false;
++ const a = Buffer.from(given, 'hex');
++ const b = Buffer.from(expected, 'hex');
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++/**
++ * Parse a signature header into `{ t, digests[] }` (timestamped) or `{ t: null, digests[] }`
++ * (body only). Unknown fields are ignored; a malformed or empty header returns null.
++ */
++function parseSignature(header) {
++ const s = String(header || '').trim();
++ if (!s) return null;
++ const bare = s.match(/^(?:sha256=)?([0-9a-f]{64})$/i);
++ if (bare) return { t: null, digests: [bare[1].toLowerCase()] };
++ let t = null;
++ const digests = [];
++ for (const part of s.split(',')) {
++ const i = part.indexOf('=');
++ if (i < 0) return null;
++ const key = part.slice(0, i).trim();
++ const value = part.slice(i + 1).trim();
++ if (key === 't') {
++ if (t !== null || !/^\d{1,12}$/.test(value)) return null;
++ t = Number(value);
++ } else if (key === 'v1' || key === 'v2') {
++ if (!/^[0-9a-f]{64}$/i.test(value)) return null;
++ digests.push(value.toLowerCase());
++ }
++ }
++ if (t === null || !digests.length) return null;
++ return { t, digests };
++}
++
++/**
++ * Verify a Vibes Pay webhook against the RAW request body (never a re-serialised object).
++ *
++ * verifyWebhook(rawBody, signatureHeader, secret)
++ * verifyWebhook(rawBody, signatureHeader, secret, { toleranceSec: 300, now: Date.now() })
++ *
++ * Returns true only for a valid signature; malformed input is false, never an exception.
++ */
++function verifyWebhook(rawBody, signatureHeader, secret, { toleranceSec = SIGNATURE_TOLERANCE_SEC, now = Date.now() } = {}) {
++ if (!Number.isFinite(toleranceSec) || toleranceSec < 0) throw new TypeError('verifyWebhook: toleranceSec must be a number of seconds >= 0');
++ if (rawBody == null || typeof signatureHeader !== 'string' || !secret) return false;
++ const parsed = parseSignature(signatureHeader);
++ if (!parsed) return false;
++ let expected;
++ if (parsed.t !== null) {
++ if (Math.abs(Number(now) / 1000 - parsed.t) > toleranceSec) return false;
++ expected = hmac(secret, `${parsed.t}.`, toBuffer(rawBody));
++ } else {
++ expected = hmac(secret, toBuffer(rawBody));
++ }
++ let ok = false;
++ for (const digest of parsed.digests) if (sameHex(digest, expected)) ok = true;
++ return ok;
++}
++
++function trimSlash(s) { return String(s).replace(/\/+$/, ''); }
++
++function createVibesClient({ appToken, tokenProvider, billingUrl, fetchImpl = globalThis.fetch, timeoutMs = 10000 } = {}) {
++ if (!billingUrl) throw new TypeError('createVibesClient: billingUrl is required');
++ if (appToken == null && !tokenProvider) throw new TypeError('createVibesClient: appToken or tokenProvider is required');
++ if (appToken != null && (typeof appToken !== 'string' || !appToken)) throw new TypeError('createVibesClient: appToken is a non-empty string');
++ if (typeof fetchImpl !== 'function') throw new TypeError('createVibesClient: no fetch available; pass fetchImpl');
++ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError('createVibesClient: timeoutMs must be a positive number');
++ const base = trimSlash(billingUrl);
++ if (!/^https?:\/\/[^/]+/i.test(base)) throw new TypeError('createVibesClient: billingUrl is an http(s) origin');
++
++ async function authHeaders() {
++ if (appToken) return { Authorization: `Bearer ${appToken}` };
++ const ctx = { service: 'billing', audience: 'openvibe.billing' };
++ let out;
++ try {
++ if (typeof tokenProvider === 'function') out = await tokenProvider(ctx);
++ else if (typeof tokenProvider.authHeaders === 'function') out = await tokenProvider.authHeaders(ctx);
++ else if (typeof tokenProvider.getToken === 'function') out = await tokenProvider.getToken(ctx);
++ else throw new TypeError('tokenProvider needs getToken() or authHeaders()');
++ } catch (e) {
++ throw new VibesError(`token: ${e.message || e}`);
++ }
++ if (typeof out === 'string' && out) return { Authorization: `Bearer ${out}` };
++ if (out && typeof out === 'object') {
++ if (typeof out.Authorization === 'string' || typeof out.authorization === 'string') return out;
++ if (typeof out.token === 'string' && out.token) return { Authorization: `Bearer ${out.token}` };
++ }
++ throw new VibesError('token: the token provider answered no token');
++ }
++
++ function invalidate() {
++ try {
++ if (tokenProvider && typeof tokenProvider.invalidate === 'function') tokenProvider.invalidate({ service: 'billing', audience: 'openvibe.billing' });
++ } catch { /* the next call asks for a fresh token anyway */ }
++ }
++
++ async function call(method, path, { body, key, retried = false } = {}) {
++ let auth;
++ try { auth = await authHeaders(); } catch (e) { throw e instanceof VibesError ? e : new VibesError(String(e.message || e)); }
++ const headers = { Accept: 'application/json', ...auth };
++ if (body !== undefined) headers['Content-Type'] = 'application/json';
++ if (key) headers['Idempotency-Key'] = key;
++ let res;
++ try {
++ res = await fetchImpl(`${base}${path}`, {
++ method, headers,
++ body: body !== undefined ? JSON.stringify(body) : undefined,
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (e) {
++ throw new VibesError(`Billing unreachable: ${e.message}`);
++ }
++ if (res.status === 401 && !retried && tokenProvider) {
++ invalidate();
++ return call(method, path, { body, key, retried: true });
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok) {
++ const code = (data && (data.code || data.error)) || `http_${res.status}`;
++ throw new VibesError(`Billing ${res.status} ${code}: ${(data && data.detail) || ''}`.trim(), { status: res.status, code, body: data });
++ }
++ return data;
++ }
++
++ const id = (value, label) => {
++ const s = String(value == null ? '' : value).trim();
++ if (!s) throw new TypeError(`charges.${label}: a charge id is required`);
++ return encodeURIComponent(s);
++ };
++
++ return {
++ charges: {
++ /**
++ * Create a charge. `amount` is a whole number of Vibes; `reference` is the app's order id
++ * and the idempotency key; `return_url` is where the pay sheet sends the person afterwards.
++ * Resolves { id, pay_url, status: 'pending', expires_at }.
++ */
++ async create({ amount, description, reference, return_url } = {}) {
++ if (!Number.isSafeInteger(amount) || amount <= 0) throw new TypeError('charges.create: amount is a positive whole number of Vibes');
++ const desc = String(description == null ? '' : description).trim();
++ if (!desc) throw new TypeError('charges.create: description is required');
++ const ref = String(reference == null ? '' : reference).trim();
++ if (!ref) throw new TypeError('charges.create: reference is required');
++ let returnUrl;
++ if (return_url != null) {
++ returnUrl = String(return_url).trim();
++ let u;
++ try { u = new URL(returnUrl); } catch { throw new TypeError('charges.create: return_url is a URL'); }
++ if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new TypeError('charges.create: return_url is an http(s) URL');
++ }
++ return await call('POST', '/api/v1/vibes/charges', {
++ body: { amount, description: desc, reference: ref, ...(returnUrl ? { return_url: returnUrl } : {}) },
++ key: ref,
++ });
++ },
++ /** The charge as Billing has it: { id, amount, status, reference, ... }. */
++ async get(chargeId) {
++ return await call('GET', `/api/v1/vibes/charges/${id(chargeId, 'get')}`);
++ },
++ /** Refund a paid charge: its Vibes return to the person from the project's wallet. */
++ async refund(chargeId, { reason } = {}) {
++ const text = reason == null ? '' : String(reason).trim();
++ return await call('POST', `/api/v1/vibes/charges/${id(chargeId, 'refund')}/refund`, {
++ body: text ? { reason: text.slice(0, 500) } : {},
++ });
++ },
++ },
++ webhooks: { verify: verifyWebhook },
++ baseUrl: () => base,
++ };
++}
++
++module.exports = { createVibesClient, VibesError, verifyWebhook, SIGNATURE_TOLERANCE_SEC };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 5c1ee83..54b3df5 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', './actor', './app-signin', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
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 3fbc7e4..0d613b6 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -24,7 +24,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@@ -68,7 +68,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, 76); // 0.81.0 adds openvibe-sdk/app-signin and openvibe-sdk/meme
++ assert.equal(subpaths.length, 77); // 0.81.0 adds openvibe-sdk/app-signin and openvibe-sdk/meme; 0.84.0 adds openvibe-sdk/vibes
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/vibes.test.js b/test/vibes.test.js
nnew file mode 100644
iindex 0000000..a60e08f
---- /dev/null
++++ b/test/vibes.test.js
@@@ -0,0 +1,117 @@
++'use strict';
++// openvibe-sdk/vibes: Vibes Pay charges through a local Billing stub, whole-Vibes validation, one
++// idempotency key per reference, a token provider that can refresh, and webhook signatures (v2 with
++// a replay window, the legacy body-only sha256, constant-time and never throwing on bad input).
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const { stubServer, send, problem, run } = require('./helpers');
++const { createVibesClient, VibesError, verifyWebhook, SIGNATURE_TOLERANCE_SEC } = require('../src/vibes');
++
++const hmac = (secret, text) => crypto.createHmac('sha256', String(secret)).update(text).digest('hex');
++
++(async () => {
++ let mode = 'ok';
++ let tokenN = 0;
++ const stub = await stubServer((req, res, body) => {
++ if (mode === 'unauthorized' && req.headers.authorization === 'Bearer tok-1') return problem(res, 401, 'token.expired', 'expired');
++ if (mode === 'insufficient') return problem(res, 402, 'billing.insufficient_funds', 'not enough Vibes');
++ if (mode === 'unavailable') return send(res, 503, { code: 'billing.unavailable' });
++ if (req.url === '/api/v1/vibes/charges' && req.method === 'POST') {
++ return send(res, 201, { id: 'vch_01TEST', pay_url: 'https://openvibe.network/pay/vch_01TEST', status: 'pending', expires_at: '2030-01-01T00:00:00Z' });
++ }
++ if (req.url === '/api/v1/vibes/charges/vch_01TEST' && req.method === 'GET') return send(res, 200, { id: 'vch_01TEST', status: 'paid' });
++ if (req.url === '/api/v1/vibes/charges/vch_01TEST/refund' && req.method === 'POST') return send(res, 200, { id: 'vch_01TEST', status: 'refunded' });
++ return problem(res, 404, 'not_found', 'not found');
++ });
++
++ try {
++ const vibes = createVibesClient({ appToken: 'app-token', billingUrl: `${stub.url}/` });
++
++ await run([
++ ['create: a whole-Vibes charge, the reference as the idempotency key, the app token', async () => {
++ const out = await vibes.charges.create({ amount: 50, description: 'Sticker pack: Neon cats', reference: 'order_123', return_url: 'https://myapp.example/thanks' });
++ assert.equal(out.id, 'vch_01TEST');
++ const call = stub.requests.at(-1);
++ assert.equal(call.method, 'POST');
++ assert.equal(call.url, '/api/v1/vibes/charges');
++ assert.equal(call.headers.authorization, 'Bearer app-token');
++ assert.equal(call.headers['idempotency-key'], 'order_123');
++ assert.deepEqual(JSON.parse(call.body), { amount: 50, description: 'Sticker pack: Neon cats', reference: 'order_123', return_url: 'https://myapp.example/thanks' });
++ }],
++
++ ['get and refund: the id in the path, a reason only when given', async () => {
++ assert.equal((await vibes.charges.get('vch_01TEST')).status, 'paid');
++ assert.equal(stub.requests.at(-1).url, '/api/v1/vibes/charges/vch_01TEST');
++ assert.equal((await vibes.charges.refund('vch_01TEST')).status, 'refunded');
++ assert.equal(stub.requests.at(-1).url, '/api/v1/vibes/charges/vch_01TEST/refund');
++ assert.deepEqual(JSON.parse(stub.requests.at(-1).body), {});
++ await vibes.charges.refund('vch_01TEST', { reason: 'bought twice' });
++ assert.deepEqual(JSON.parse(stub.requests.at(-1).body), { reason: 'bought twice' });
++ }],
++
++ ['money is an integer: a float, zero or a negative amount never reaches Billing', async () => {
++ for (const amount of [1.5, 0, -1, '50', Number.MAX_SAFE_INTEGER + 2]) {
++ await assert.rejects(vibes.charges.create({ amount, description: 'x', reference: 'r' }), TypeError);
++ }
++ await assert.rejects(vibes.charges.create({ amount: 50, description: ' ', reference: 'r' }), TypeError);
++ await assert.rejects(vibes.charges.create({ amount: 50, description: 'x', reference: '' }), TypeError);
++ await assert.rejects(vibes.charges.create({ amount: 50, description: 'x', reference: 'r', return_url: 'javascript:alert(1)' }), TypeError);
++ assert.equal(stub.requests.filter((r) => r.method === 'POST').length, 3, 'only the three valid calls above were sent');
++ }],
++
++ ['a 402 is Billing\'s answer: VibesError with status, code, body and not retryable', async () => {
++ mode = 'insufficient';
++ await assert.rejects(vibes.charges.create({ amount: 50, description: 'x', reference: 'order_2' }), (err) => err instanceof VibesError
++ && err.status === 402 && err.code === 'billing.insufficient_funds' && err.retryable === false && err.body.code === 'billing.insufficient_funds');
++ mode = 'ok';
++ }],
++
++ ['a token provider: minted per call (or its headers), and a 401 invalidates and retries once with the same key', async () => {
++ tokenN = 0;
++ const invalidated = [];
++ const provider = {
++ async getToken() { return `tok-${++tokenN}`; },
++ invalidate() { invalidated.push(1); },
++ };
++ const p = createVibesClient({ tokenProvider: provider, billingUrl: stub.url });
++ mode = 'unauthorized';
++ await p.charges.create({ amount: 10, description: 'x', reference: 'order_401' });
++ const calls = stub.requests.slice(-2);
++ assert.deepEqual(calls.map((c) => c.headers.authorization), ['Bearer tok-1', 'Bearer tok-2']);
++ assert.deepEqual(calls.map((c) => c.headers['idempotency-key']), ['order_401', 'order_401']);
++ assert.equal(invalidated.length, 1);
++ mode = 'ok';
++ const headers = createVibesClient({ tokenProvider: { authHeaders: async () => ({ Authorization: 'Bearer hdr-token' }) }, billingUrl: stub.url });
++ await headers.charges.get('vch_01TEST');
++ assert.equal(stub.requests.at(-1).headers.authorization, 'Bearer hdr-token');
++ await assert.rejects(createVibesClient({ tokenProvider: { getToken: async () => null }, billingUrl: stub.url }).charges.get('vch_01TEST'), /no token/);
++ }],
++
++ ['webhooks.verify: v2 is timestamped and replay-limited; legacy sha256 covers the body alone', async () => {
++ const secret = 'whsec_test_not_real';
++ const body = JSON.stringify({ type: 'billing.vibes.charge.paid', data: { id: 'vch_01TEST', status: 'paid', reference: 'order_123' } });
++ const now = Date.UTC(2026, 10, 4, 12, 0, 0);
++ const t = Math.floor(now / 1000);
++ const header = `t=${t},v2=${hmac(secret, `${t}.${body}`)}`;
++ assert.equal(vibes.webhooks.verify(body, header, secret, { now }), true);
++ assert.equal(verifyWebhook(Buffer.from(body), header, secret, { now }), true, 'a Buffer body');
++ assert.equal(vibes.webhooks.verify(body, header, secret, { now: now + SIGNATURE_TOLERANCE_SEC * 1000 * 2 }), false, 'stale');
++ assert.equal(vibes.webhooks.verify(body, header, secret, { now: now - SIGNATURE_TOLERANCE_SEC * 1000 * 2 }), false, 'from the future');
++ assert.equal(vibes.webhooks.verify(`${body} `, header, secret, { now }), false, 'one byte changed');
++ assert.equal(vibes.webhooks.verify(body, header, 'another-secret', { now }), false);
++ assert.equal(vibes.webhooks.verify(body, `t=${t},v2=${'0'.repeat(64)}`, secret, { now }), false);
++ assert.equal(vibes.webhooks.verify(body, '', secret, { now }), false);
++ assert.equal(vibes.webhooks.verify(body, 't=x,v2=abc', secret, { now }), false);
++ assert.equal(vibes.webhooks.verify(body, null, secret, { now }), false);
++ assert.equal(vibes.webhooks.verify(null, header, secret, { now }), false);
++ assert.equal(vibes.webhooks.verify(body, header, '', { now }), false);
++ assert.equal(vibes.webhooks.verify(body, `sha256=${hmac(secret, body)}`, secret), true, 'the legacy body-only form');
++ assert.equal(vibes.webhooks.verify(body, hmac(secret, body), secret), true, 'bare hex');
++ assert.throws(() => vibes.webhooks.verify(body, header, secret, { toleranceSec: -1 }), TypeError);
++ }],
++ ]);
++ } finally {
++ await stub.close();
++ }
++ console.log('vibes: ok');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/types/vibes.d.ts b/types/vibes.d.ts
nnew file mode 100644
iindex 0000000..3e9f34e
---- /dev/null
++++ b/types/vibes.d.ts
@@@ -0,0 +1,70 @@
++/** Vibes Pay for developer apps (server only). Money is whole Vibes; a float is refused. */
++export declare class VibesError extends Error {
++ constructor(message: string, init?: { status?: number | null; code?: string | null; body?: unknown });
++ status: number | null;
++ code: string | null;
++ body: unknown;
++ /** No status, 5xx, 429 or 401. */
++ readonly retryable: boolean;
++}
++
++export type VibesTokenContext = { service: 'billing'; audience: 'openvibe.billing' };
++export type VibesToken = string | Record<string, string>;
++export type VibesTokenProvider =
++ | ((ctx: VibesTokenContext) => VibesToken | Promise<VibesToken>)
++ | {
++ getToken?(ctx: VibesTokenContext): VibesToken | Promise<VibesToken>;
++ authHeaders?(ctx: VibesTokenContext): VibesToken | Promise<VibesToken>;
++ invalidate?(ctx?: VibesTokenContext): void | Promise<void>;
++ };
++
++export type VibesOptions = {
++ /** The app's token (capability billing.vibes.charge). One of appToken or tokenProvider. */
++ appToken?: string;
++ /** A function, or an SDK token client (getToken / invalidate), for a token that expires. */
++ tokenProvider?: VibesTokenProvider;
++ /** Billing's origin, e.g. https://billing.openvibe.network. */
++ billingUrl: string;
++ fetchImpl?: typeof fetch;
++ /** Per request, default 10000. */
++ timeoutMs?: number;
++};
++
++export type VibesChargeCreate = {
++ /** A positive whole number of Vibes. */
++ amount: number;
++ description: string;
++ /** The app's order id; also the idempotency key, so a retry never charges twice. */
++ reference: string;
++ /** Where the pay sheet sends the person afterwards (http or https). */
++ return_url?: string;
++};
++
++export type VibesCharge = {
++ id: string;
++ pay_url: string;
++ status: 'pending' | 'paid' | 'refunded' | 'cancelled' | 'expired' | string;
++ expires_at?: string;
++ amount?: number;
++ description?: string;
++ reference?: string;
++ [field: string]: unknown;
++};
++
++export type VibesClient = {
++ charges: {
++ create(input: VibesChargeCreate): Promise<VibesCharge>;
++ get(chargeId: string): Promise<VibesCharge>;
++ refund(chargeId: string, opts?: { reason?: string }): Promise<VibesCharge>;
++ };
++ webhooks: {
++ /** Constant-time check of the RAW body against X-OpenVibe-Signature-V2 (or a legacy sha256= header). */
++ verify(rawBody: string | Uint8Array | ArrayBufferView, signatureHeader: string, secret: string, opts?: { toleranceSec?: number; now?: number }): boolean;
++ };
++ baseUrl(): string;
++};
++
++export declare function createVibesClient(options: VibesOptions): VibesClient;
++/** The same check as vibes.webhooks.verify, for a receiver that only verifies webhooks. */
++export declare function verifyWebhook(rawBody: string | Uint8Array | ArrayBufferView, signatureHeader: string, secret: string, opts?: { toleranceSec?: number; now?: number }): boolean;
++export declare const SIGNATURE_TOLERANCE_SEC: number;