openvibers/openvibe.sdk Commits

Add openvibe-sdk/commerce, the shared Billing client for Tips and VIP

OP OpenVibers committed 4 Oct 2026 · 836c0bc48d · 17 files, +361 −11

Asked: Part of a cross-repository initiative (init-t5-step-13-dossier-home-work-1004T2236): T5 step 13 (dossier /home/workstation/openvibe/agents/ds/prep/T5.md section "T5 step 13"; step 11 is merged: VIP #8, Billing #12/#13/#14, so VIP's Billing client surface is settled): ONE commerce module shared by T…
Harness job init-t5-step-13-dossi-2236-s1-223842 (plan T5).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 16f4898..73147f8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -7,6 +7,18 @@ release may change an API and says so here.
++## 0.28.0 (2026-10-04)
++
++- **`openvibe-sdk/commerce` (new, server; plan T5 step 13):** the Billing client Tips and VIP each carried a copy of.
++ `createCommerceClient(config, { caps, fetchImpl, tokenClients, timeoutMs })` with `createIntent` (purchase or
++ subscription), `createTransfer`, `refundTransfer`, `subscribeWithCredit`, `cancelSubscription`, `getSubscription`,
++ `listSubscriptions`, `entitlement`, `rates` and `baseUrl`. One call shape: an `Idempotency-Key` is sent verbatim, a
++ 401 invalidates the token and retries exactly once with the same key, and a problem+json answer becomes a
++ `CommerceError` (`status`, `code`, `body`; `retryable` for no status, 5xx, 429, 401 and `billing.frozen`).
++ `intentKey(prefix, parts)` derives a key and `receipt(kind, fields)` builds an outbox record (never posted).
++ `openvibe-contracts` is required lazily, only when a token client is created, so the tarball still loads with no
++ dependencies. No change to the other modules.
++
## 0.27.0 (2026-10-04)
- **`openvibe-sdk/cost` (new, server; plan T1):** `rateCard()` / `validateRateCard()` for `platform.rate-card@1`,
ddiff --git a/README.md b/README.md
iindex 11221e5..cea14ae 100644
---- a/README.md
++++ b/README.md
@@@ -182,6 +182,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRe.Stream, 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/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). `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/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 |
| `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 |
| `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 dc5ef48..078c712 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.23.1",
++ "version": "0.28.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",
@@@ -22,7 +22,8 @@
"ESM entries, TypeScript types and a browser bundle",
"db (ADR-035): the async PostgreSQL data layer (node-postgres through PgBouncer; PGlite in tests) with safe sql fragments, retrying transactions and savepoints, versioned expand/migrate/contract migrations, and importSqlite for the one-time SQLite move with verification",
"valkey, cache, queue, pubsub and a shared limits store on Valkey 9, each with an in-process twin for tests",
++ "commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",
"events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)"
],
-- "updated": "2026-09-28"
++ "updated": "2026-10-04"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex f2d2331..1edf09a 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.27.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.28.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, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -41,7 +41,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.27.0';
++const SDK_VERSION = '0.28.0';
module.exports = {
createClient,
ddiff --git a/esm/commerce.mjs b/esm/commerce.mjs
nnew file mode 100644
iindex 0000000..97f6257
---- /dev/null
++++ b/esm/commerce.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/commerce.js. Do not edit.
++import cjs from '../src/commerce.js';
++
++export const {
++ createCommerceClient, CommerceError, intentKey, receipt,
++} = cjs;
++export default cjs;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 662b135..87533ed 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -7,6 +7,7 @@ export const {
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
community, search, chat, ai, jobs, tools,
-- projects, vip, openre, geo, usage, telemetry,
++ projects, vip, openre, geo, usage, commerce,
++ telemetry,
} = cjs;
export default cjs;
ddiff --git a/index.js b/index.js
iindex 72fbf3a..6cff484 100644
---- a/index.js
++++ b/index.js
@@@ -26,5 +26,6 @@ module.exports = {
openre: require('./src/openre'),
geo: require('./src/geo'),
usage: require('./src/usage'),
++ commerce: require('./src/commerce'),
telemetry: require('./src/telemetry'),
};
ddiff --git a/package-lock.json b/package-lock.json
iindex 0cdc14d..e086bdc 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.27.0",
++ "version": "0.28.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.27.0",
++ "version": "0.28.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 99fa847..f306557 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.27.0",
++ "version": "0.28.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, 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",
@@@ -124,6 +124,12 @@
"import": "./esm/storage.mjs",
"default": "./src/storage.js"
},
++ "./commerce": {
++ "types": "./types/commerce.d.ts",
++ "browser": null,
++ "import": "./esm/commerce.mjs",
++ "default": "./src/commerce.js"
++ },
"./delivery": {
"types": "./types/delivery.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 3dd0209..5fab15b 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -33,6 +33,7 @@ const ENTRIES = {
'cost.mjs': '../src/cost.js',
'runtime.mjs': '../src/runtime.js',
'storage.mjs': '../src/storage.js',
++ 'commerce.mjs': '../src/commerce.js',
'delivery.mjs': '../src/delivery.js',
'node.mjs': '../src/node.js',
'db.mjs': '../src/db/index.js',
ddiff --git a/src/commerce.js b/src/commerce.js
nnew file mode 100644
iindex 0000000..71eeb66
---- /dev/null
++++ b/src/commerce.js
@@@ -0,0 +1,129 @@
++'use strict';
++/**
++ * openvibe-sdk/commerce (server): the Billing client Tips and VIP share (ADR-012, the only place money moves).
++ * One HTTP shape, one retry rule, one error type; the product supplies its capabilities and, in tests, its
++ * token clients. Every POST can carry an Idempotency-Key, sent verbatim and unchanged on the one retry after a
++ * 401, so a lost response never moves money twice.
++ *
++ * openvibe-contracts (serviceAuth) is required lazily, only when a token client has to be created, so this
++ * module loads from the zero-dependency tarball.
++ */
++
++class CommerceError extends Error {
++ constructor(message, { status = null, code = null, body = null } = {}) {
++ super(message);
++ this.name = 'CommerceError';
++ this.status = status;
++ this.code = code;
++ this.body = body;
++ }
++ /** No status (network, timeout, token), 5xx, 429, 401 and a frozen account are worth retrying; the rest are Billing's answer. */
++ get retryable() { return this.status == null || this.status >= 500 || this.status === 429 || this.status === 401 || this.code === 'billing.frozen'; }
++}
++
++/** A stable Idempotency-Key from a product record: the prefix, then each part that is set, joined with ':'. */
++function intentKey(prefix, parts) {
++ if (typeof prefix !== 'string' || prefix === '') throw new TypeError('intentKey: a non-empty prefix is required');
++ return `${prefix}:${(parts || []).filter((p) => p != null && p !== '').join(':')}`;
++}
++
++/** A receipt record for the product's outbox: { kind, at, ...fields } with null/undefined fields stripped. Constructed, never posted. */
++function receipt(kind, fields = {}) {
++ const out = { kind, at: new Date().toISOString() };
++ for (const [k, v] of Object.entries(fields || {})) if (v != null) out[k] = v;
++ return out;
++}
++
++const user = (id) => ({ type: 'user', id });
++const qs = (o) => {
++ const p = new URLSearchParams();
++ for (const [k, v] of Object.entries(o)) if (v != null && v !== '') p.set(k, v);
++ const s = p.toString();
++ return s ? `?${s}` : '';
++};
++
++/**
++ * `config`: { billing: { url, audience, timeoutMs = 10000 }, network: { internalUrl }, oauth: { clientId, clientSecret } }.
++ * `caps` maps the logical keys intent / transfer / subscription / entitlement (and optionally rates, default
++ * entitlement) to capability strings; a client is created per distinct capability string, so two keys that map to
++ * one string (or a space-separated scope) share one token. `tokenClients` may be keyed by logical key or by
++ * capability string; any other capability gets a serviceAuth token client with scope = the capability.
++ */
++function createCommerceClient(config, { caps, fetchImpl = globalThis.fetch, tokenClients = null, timeoutMs } = {}) {
++ if (!caps || typeof caps !== 'object') throw new TypeError('createCommerceClient: caps is required');
++ const base = config.billing.url;
++ const deadline = timeoutMs || config.billing.timeoutMs || 10000;
++ const capFor = (logical) => caps[logical] || logical;
++ const clients = new Map();
++ const tokensFor = (logical) => {
++ const cap = capFor(logical);
++ if (tokenClients && tokenClients[logical]) return tokenClients[logical];
++ if (tokenClients && tokenClients[cap]) return tokenClients[cap];
++ if (!clients.has(cap)) {
++ const { serviceAuth } = require('openvibe-contracts');
++ clients.set(cap, serviceAuth.createTokenClient({
++ tokenUrl: `${config.network.internalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: config.billing.audience,
++ scope: cap,
++ fetchImpl,
++ }));
++ }
++ return clients.get(cap);
++ };
++
++ async function call(logical, method, path, { body, key, traceparent, retried = false } = {}) {
++ let auth;
++ try { auth = await tokensFor(logical).authHeaders(); } catch (e) { throw new CommerceError(`token (${capFor(logical)}): ${e.message}`); }
++ const headers = { Accept: 'application/json', ...auth };
++ if (body !== undefined) headers['Content-Type'] = 'application/json';
++ if (key) headers['Idempotency-Key'] = key;
++ if (traceparent) headers.traceparent = traceparent;
++ let res;
++ try {
++ res = await fetchImpl(`${base}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(deadline) });
++ } catch (e) {
++ throw new CommerceError(`Billing unreachable: ${e.message}`);
++ }
++ if (res.status === 401 && !retried) { tokensFor(logical).invalidate(); return await call(logical, method, path, { body, key, traceparent, retried: true }); }
++ const data = await res.json().catch(() => null);
++ if (!res.ok) {
++ const code = (data && (data.code || data.error)) || `http_${res.status}`;
++ throw new CommerceError(`Billing ${res.status} ${code}: ${(data && data.detail) || ''}`.trim(), { status: res.status, code, body: data });
++ }
++ return data;
++ }
++
++ return {
++ /** { intent, checkout_url }: kind purchase buys `bits` of credit; kind subscription is a checkout for `subject` to `creator`. */
++ createIntent: async ({ provider, kind = 'purchase', subject, bits, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => await call('intent', 'POST', '/api/v1/intents', {
++ body: {
++ provider, kind, subject: user(subject),
++ ...(kind === 'subscription' ? { streamer: user(creator), auto_renew: !!autoRenew } : { bits }),
++ success_url: successUrl, cancel_url: cancelUrl,
++ },
++ key, traceparent,
++ }),
++ /** { transaction }: credit of `from` to payable of `to`, tagged with the interaction. */
++ createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) => await call('transfer', 'POST', '/api/v1/transfers', {
++ body: { from: user(from), to: user(to), amount, kind, target, message: message || undefined },
++ key, traceparent,
++ }),
++ refundTransfer: async ({ txnId, amount, reason, key }) => await call('transfer', 'POST', `/api/v1/transfers/${encodeURIComponent(txnId)}/refund`, { body: { amount, reason }, key }),
++ /** { subscription, entitlement, transaction }: one period paid from the member's credit. */
++ subscribeWithCredit: async ({ subscriber, creator, autoRenew, key, traceparent }) => await call('subscription', 'POST', '/api/v1/subscriptions', {
++ body: { subscriber: user(subscriber), streamer: user(creator), source: 'credit', auto_renew: autoRenew !== false },
++ key, traceparent,
++ }),
++ cancelSubscription: async ({ id, key, traceparent }) => await call('subscription', 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent }),
++ getSubscription: async (id) => await call('entitlement', 'GET', `/api/v1/subscriptions/${encodeURIComponent(id)}`),
++ listSubscriptions: async ({ streamer, subscriber, status } = {}) => await call('entitlement', 'GET', `/api/v1/subscriptions${qs({ streamer, subscriber, status })}`),
++ /** { active, expires_at, subscription }: the authoritative answer. */
++ entitlement: async (subject, creator) => await call('entitlement', 'GET', `/api/v1/entitlements/${encodeURIComponent(subject)}${qs({ streamer: creator })}`),
++ rates: async () => await call(caps.rates ? 'rates' : 'entitlement', 'GET', '/api/v1/rates'),
++ baseUrl: () => base,
++ };
++}
++
++module.exports = { createCommerceClient, CommerceError, intentKey, receipt };
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 0d982bc..d94ccf8 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.27.0';
++const SDK_VERSION = '0.28.0';
module.exports = {
createClient,
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 58fd979..19d1d7d 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(), ['./cache', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
++ assert.deepEqual(blocked.sort(), ['./cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './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/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/commerce.test.js b/test/commerce.test.js
nnew file mode 100644
iindex 0000000..7e80f76
---- /dev/null
++++ b/test/commerce.test.js
@@@ -0,0 +1,149 @@
++'use strict';
++/** openvibe-sdk/commerce: the shared Billing client (key derivation, retry once on 401, problem+json, retryable). */
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const { createCommerceClient, CommerceError, intentKey, receipt } = require('../src/commerce');
++
++const CONFIG = { billing: { url: 'http://billing.test', audience: 'openvibe.billing', timeoutMs: 5000 }, network: { internalUrl: 'http://net.test' }, oauth: { clientId: 'c', clientSecret: 's' } };
++const CAPS = { intent: 'billing.intent.create', transfer: 'billing.transfer.create', subscription: 'billing.subscription.manage', entitlement: 'billing.entitlement.check' };
++const json = (status, body) => ({ status, ok: status >= 200 && status < 300, json: async () => body });
++
++function rig(responses) {
++ const calls = [];
++ const invalidated = [];
++ const fetchImpl = async (url, init) => {
++ calls.push({ url, init });
++ const r = responses.shift();
++ if (r instanceof Error) throw r;
++ return r;
++ };
++ const mk = (name) => ({ authHeaders: async () => ({ Authorization: `Bearer ${name}` }), invalidate: () => invalidated.push(name) });
++ const tokenClients = { intent: mk('intent'), transfer: mk('transfer'), subscription: mk('subscription'), entitlement: mk('entitlement') };
++ return { calls, invalidated, client: createCommerceClient(CONFIG, { caps: CAPS, fetchImpl, tokenClients }) };
++}
++
++run([
++ ['intentKey joins the parts that are set and needs a prefix', async () => {
++ assert.equal(intentKey('tip', ['a', null, '', 7, undefined, 'b']), 'tip:a:7:b');
++ assert.equal(intentKey('tip', []), 'tip:');
++ assert.throws(() => intentKey('', ['a']), TypeError);
++ assert.throws(() => intentKey(undefined, ['a']), TypeError);
++ }],
++ ['receipt stamps the time and strips null fields', async () => {
++ const r = receipt('tip.paid', { id: 'x', note: null, n: 0, gone: undefined });
++ assert.deepEqual(Object.keys(r), ['kind', 'at', 'id', 'n']);
++ assert.equal(r.kind, 'tip.paid');
++ assert.equal(new Date(r.at).toISOString(), r.at);
++ }],
++ ['retryable: no status, 5xx, 429, 401 and billing.frozen; not the other 4xx', async () => {
++ const cases = [[{}, true], [{ status: 500 }, true], [{ status: 503 }, true], [{ status: 429 }, true], [{ status: 401 }, true],
++ [{ status: 409, code: 'billing.frozen' }, true], [{ status: 400 }, false], [{ status: 402, code: 'billing.insufficient_funds' }, false], [{ status: 404 }, false]];
++ for (const [init, want] of cases) assert.equal(new CommerceError('x', init).retryable, want, JSON.stringify(init));
++ assert.equal(new CommerceError('x').name, 'CommerceError');
++ }],
++ ['createIntent builds the purchase and the subscription bodies', async () => {
++ const { client, calls } = rig([json(200, { intent: { id: 'i1' }, checkout_url: 'u' }), json(200, { intent: { id: 'i2' } })]);
++ assert.deepEqual(await client.createIntent({ provider: 'square', subject: 'u1', bits: 500, successUrl: 's', cancelUrl: 'c', key: 'k1', traceparent: 'tp' }), { intent: { id: 'i1' }, checkout_url: 'u' });
++ await client.createIntent({ provider: 'square', kind: 'subscription', subject: 'u1', creator: 'u2', autoRenew: 1, successUrl: 's', cancelUrl: 'c', key: 'k2' });
++ assert.equal(calls[0].url, 'http://billing.test/api/v1/intents');
++ assert.equal(calls[0].init.method, 'POST');
++ assert.deepEqual(JSON.parse(calls[0].init.body), { provider: 'square', kind: 'purchase', subject: { type: 'user', id: 'u1' }, bits: 500, success_url: 's', cancel_url: 'c' });
++ assert.equal(calls[0].init.headers['Idempotency-Key'], 'k1');
++ assert.equal(calls[0].init.headers.traceparent, 'tp');
++ assert.equal(calls[0].init.headers['Content-Type'], 'application/json');
++ assert.equal(calls[0].init.headers.Authorization, 'Bearer intent');
++ assert.deepEqual(JSON.parse(calls[1].init.body), { provider: 'square', kind: 'subscription', subject: { type: 'user', id: 'u1' }, streamer: { type: 'user', id: 'u2' }, auto_renew: true, success_url: 's', cancel_url: 'c' });
++ assert.equal(calls[1].init.headers.traceparent, undefined);
++ }],
++ ['transfer, refund, subscribe, cancel and reads use their capability, path and body', async () => {
++ const { client, calls } = rig(Array.from({ length: 8 }, () => json(200, { ok: true })));
++ await client.createTransfer({ from: 'a', to: 'b', amount: 5, kind: 'tip', target: { t: 1 }, message: '', key: 'k' });
++ await client.refundTransfer({ txnId: 'txn/1', amount: 5, reason: 'unplayed', key: 'r' });
++ await client.subscribeWithCredit({ subscriber: 'a', creator: 'b', key: 's' });
++ await client.cancelSubscription({ id: 'sub 1', key: 'c' });
++ await client.getSubscription('sub 1');
++ await client.listSubscriptions({ streamer: 'b', status: 'active', subscriber: '' });
++ await client.entitlement('a b', 'c');
++ await client.rates();
++ const seen = calls.map((c) => [c.init.method, c.url.replace('http://billing.test', ''), c.init.headers.Authorization]);
++ assert.deepEqual(seen, [
++ ['POST', '/api/v1/transfers', 'Bearer transfer'],
++ ['POST', '/api/v1/transfers/txn%2F1/refund', 'Bearer transfer'],
++ ['POST', '/api/v1/subscriptions', 'Bearer subscription'],
++ ['POST', '/api/v1/subscriptions/sub%201/cancel', 'Bearer subscription'],
++ ['GET', '/api/v1/subscriptions/sub%201', 'Bearer entitlement'],
++ ['GET', '/api/v1/subscriptions?streamer=b&status=active', 'Bearer entitlement'],
++ ['GET', '/api/v1/entitlements/a%20b?streamer=c', 'Bearer entitlement'],
++ ['GET', '/api/v1/rates', 'Bearer entitlement'],
++ ]);
++ assert.deepEqual(JSON.parse(calls[0].init.body), { from: { type: 'user', id: 'a' }, to: { type: 'user', id: 'b' }, amount: 5, kind: 'tip', target: { t: 1 } });
++ assert.deepEqual(JSON.parse(calls[1].init.body), { amount: 5, reason: 'unplayed' });
++ assert.deepEqual(JSON.parse(calls[2].init.body), { subscriber: { type: 'user', id: 'a' }, streamer: { type: 'user', id: 'b' }, source: 'credit', auto_renew: true });
++ assert.deepEqual(JSON.parse(calls[3].init.body), {});
++ for (const i of [4, 5, 6, 7]) { assert.equal(calls[i].init.body, undefined); assert.equal(calls[i].init.headers['Content-Type'], undefined); }
++ assert.equal(calls[2].init.headers['Idempotency-Key'], 's');
++ assert.equal(calls[4].init.headers['Idempotency-Key'], undefined);
++ assert.equal(client.baseUrl(), 'http://billing.test');
++ }],
++ ['a 401 invalidates the token and retries once with the same Idempotency-Key', async () => {
++ const { client, calls, invalidated } = rig([json(401, { code: 'unauthorized' }), json(200, { transaction: { id: 't' } })]);
++ const out = await client.createTransfer({ from: 'a', to: 'b', amount: 1, kind: 'tip', key: 'tip:i1' });
++ assert.deepEqual(out, { transaction: { id: 't' } });
++ assert.equal(calls.length, 2);
++ assert.deepEqual(invalidated, ['transfer']);
++ assert.equal(calls[0].init.headers['Idempotency-Key'], 'tip:i1');
++ assert.equal(calls[1].init.headers['Idempotency-Key'], 'tip:i1');
++ assert.equal(calls[1].init.body, calls[0].init.body);
++ }],
++ ['a second 401 is an error: exactly one retry', async () => {
++ const { client, calls } = rig([json(401, { code: 'unauthorized' }), json(401, { code: 'unauthorized', detail: 'still' }), json(200, {})]);
++ await assert.rejects(client.rates(), (e) => e instanceof CommerceError && e.status === 401 && e.retryable);
++ assert.equal(calls.length, 2);
++ }],
++ ['problem+json maps to a CommerceError with status, code, body and message', async () => {
++ const body = { code: 'billing.insufficient_funds', detail: 'need more', title: 't' };
++ const { client } = rig([json(402, body), json(500, { error: 'boom' }), json(404, null)]);
++ await assert.rejects(client.rates(), (e) => e instanceof CommerceError && e.status === 402 && e.code === 'billing.insufficient_funds' && e.body === body
++ && e.message === 'Billing 402 billing.insufficient_funds: need more' && !e.retryable);
++ await assert.rejects(client.rates(), (e) => e.status === 500 && e.code === 'boom' && e.retryable);
++ await assert.rejects(client.rates(), (e) => e.status === 404 && e.code === 'http_404' && e.body === null && e.message === 'Billing 404 http_404:');
++ }],
++ ['a network failure or a token failure has no status and is retryable', async () => {
++ const { client } = rig([new Error('ECONNRESET')]);
++ await assert.rejects(client.rates(), (e) => e instanceof CommerceError && e.status === null && /unreachable: ECONNRESET/.test(e.message) && e.retryable);
++ const bad = createCommerceClient(CONFIG, { caps: CAPS, fetchImpl: async () => json(200, {}), tokenClients: { entitlement: { authHeaders: async () => { throw new Error('no grant'); }, invalidate() {} } } });
++ await assert.rejects(bad.rates(), (e) => e.status === null && e.message === 'token (billing.entitlement.check): no grant' && e.retryable);
++ }],
++ ['tokenClients may be keyed by capability string, and rates can have its own capability', async () => {
++ const seen = [];
++ const tc = (n) => ({ authHeaders: async () => ({ Authorization: n }), invalidate() {} });
++ const client = createCommerceClient(CONFIG, {
++ caps: { ...CAPS, rates: 'billing.rates.read' },
++ fetchImpl: async (url, init) => { seen.push(init.headers.Authorization); return json(200, {}); },
++ tokenClients: { 'billing.rates.read': tc('rates'), 'billing.transfer.create': tc('xfer') },
++ });
++ await client.rates();
++ await client.refundTransfer({ txnId: 't', amount: 1, reason: 'r' });
++ assert.deepEqual(seen, ['rates', 'xfer']);
++ }],
++ ['without tokenClients a serviceAuth client is created lazily per capability with scope = the capability', async () => {
++ let contracts = null;
++ try { contracts = require('openvibe-contracts'); } catch { /* skipped */ }
++ if (!contracts || !contracts.serviceAuth) { console.log('lazy token clients: skipped (openvibe-contracts unavailable)'); return; }
++ const scopes = [];
++ const fetchImpl = async (url, init) => {
++ if (url === 'http://net.test/oauth/token') { scopes.push(String(init.body).match(/scope=([^&]*)/)?.[1]); return json(200, { access_token: 'tok', token_type: 'Bearer', expires_in: 300 }); }
++ return json(200, { ok: true });
++ };
++ const client = createCommerceClient(CONFIG, { caps: CAPS, fetchImpl });
++ await client.entitlement('a');
++ await client.entitlement('b');
++ await client.createTransfer({ from: 'a', to: 'b', amount: 1, kind: 'tip' });
++ assert.deepEqual(scopes.map((s) => decodeURIComponent(s).replace(/\+/g, ' ')), ['billing.entitlement.check', 'billing.transfer.create']);
++ }],
++ ['the module loads without openvibe-contracts, and caps is required', async () => {
++ assert.throws(() => createCommerceClient(CONFIG, {}), TypeError);
++ const src = require('node:fs').readFileSync(require.resolve('../src/commerce'), 'utf8');
++ assert.ok(!/^const .*require\('openvibe-contracts'\)/m.test(src), 'no top-level require of openvibe-contracts');
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex c12794d..fad0281 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, 39);
++ assert.equal(subpaths.length, 40);
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/types/commerce.d.ts b/types/commerce.d.ts
nnew file mode 100644
iindex 0000000..d4f27f9
---- /dev/null
++++ b/types/commerce.d.ts
@@@ -0,0 +1,41 @@
++/** The Billing client Tips and VIP share (server only). openvibe-contracts is required lazily, when a token client is created. */
++export declare class CommerceError 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, 401 or code 'billing.frozen'. */
++ readonly retryable: boolean;
++}
++/** `prefix` then each part that is neither null/undefined nor '', joined with ':'; throws TypeError on an empty prefix. */
++export declare function intentKey(prefix: string, parts: (string | number | null | undefined)[]): string;
++/** { kind, at: now ISO, ...fields } with null/undefined fields stripped; constructed, never posted. */
++export declare function receipt(kind: string, fields?: Record<string, unknown>): { kind: string; at: string; [field: string]: unknown };
++export type CommerceConfig = {
++ billing: { url: string; audience?: string; timeoutMs?: number };
++ network?: { internalUrl: string };
++ oauth?: { clientId: string; clientSecret: string };
++};
++export type CommerceCaps = { intent?: string; transfer?: string; subscription?: string; entitlement?: string; rates?: string };
++export type CommerceTokenClient = { authHeaders(): Promise<Record<string, string>>; invalidate(): void };
++export type CommerceOptions = {
++ caps: CommerceCaps;
++ fetchImpl?: typeof fetch;
++ /** Keyed by logical key (intent, transfer, …) or by capability string. */
++ tokenClients?: Record<string, CommerceTokenClient> | null;
++ timeoutMs?: number;
++};
++export type CommerceCall = { key?: string; traceparent?: string };
++export type CommerceClient = {
++ createIntent(a: { provider: string; kind?: 'purchase' | 'subscription'; subject: string; bits?: number; creator?: string; autoRenew?: boolean; successUrl?: string; cancelUrl?: string } & CommerceCall): Promise<any>;
++ createTransfer(a: { from: string; to: string; amount: number; kind?: string; target?: unknown; message?: string } & CommerceCall): Promise<any>;
++ refundTransfer(a: { txnId: string; amount?: number; reason?: string; key?: string }): Promise<any>;
++ subscribeWithCredit(a: { subscriber: string; creator: string; autoRenew?: boolean } & CommerceCall): Promise<any>;
++ cancelSubscription(a: { id: string } & CommerceCall): Promise<any>;
++ getSubscription(id: string): Promise<any>;
++ listSubscriptions(a?: { streamer?: string; subscriber?: string; status?: string }): Promise<any>;
++ entitlement(subject: string, creator?: string): Promise<any>;
++ rates(): Promise<any>;
++ baseUrl(): string;
++};
++export declare function createCommerceClient(config: CommerceConfig, options: CommerceOptions): CommerceClient;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 0413f56..3c8629e 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -17,4 +17,5 @@ export * as vip from './vip';
export * as openre from './openre';
export * as geo from './geo';
export * as usage from './usage';
++export * as commerce from './commerce';
export * as telemetry from './telemetry';