openvibers/openvibe.sdk Commits

openvibe-sdk 0.51.0: commerce tiers (tier on subscription checkouts and credit periods, listTiers, setTier) (#59)

OP OpenVibers committed 27 days ago · c4a8f9a426 · 10 files, +60 −18

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex fcb9c47..59d65a5 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,13 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.51.0 (2026-10-17)
++
++- `openvibe-sdk/commerce`: subscription tiers priced by OpenVibe.Billing (openvibe-contracts 0.201.0). `createIntent`
++ (kind subscription) and `subscribeWithCredit` take an optional `tier`; `listTiers(creator)` reads a creator's tier
++ prices and `setTier({ creator, tier, priceCents, status?, key })` sets one (`caps.tier` = `billing.tier.manage`;
++ `listTiers` reads with the intent capability when `caps.tier` is not given). Without a tier nothing changes.
++
## 0.50.1 (2026-10-17)
- `openvibe-sdk/db`: a nested `db.tx` (a savepoint) marks its scope done when it ends, like the outermost one. Async
ddiff --git a/README.md b/README.md
iindex 477cf6a..831758b 100644
---- a/README.md
++++ b/README.md
@@@ -189,7 +189,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `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 |
++| `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/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 |
| `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 4942e38..979ce12 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.50.1",
++ "version": "0.51.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",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex d185642..e48d1b4 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.50.1, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.51.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.50.1';
++const SDK_VERSION = '0.51.0';
module.exports = {
createClient,
ddiff --git a/package-lock.json b/package-lock.json
iindex 3c1546a..ab5f465 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.50.1",
++ "version": "0.51.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.50.1",
++ "version": "0.51.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex d21043a..e287d71 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.50.1",
++ "version": "0.51.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",
ddiff --git a/src/commerce.js b/src/commerce.js
iindex 71eeb66..df7ff45 100644
---- a/src/commerce.js
++++ b/src/commerce.js
@@@ -45,7 +45,7 @@ const qs = (o) => {
/**
* `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
++ * entitlement, and tier: billing.tier.manage, which setTier needs and listTiers prefers over intent) 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.
*/
@@@ -96,11 +96,11 @@ function createCommerceClient(config, { caps, fetchImpl = globalThis.fetch, toke
}
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', {
++ /** { intent, checkout_url }: kind purchase buys `bits` of credit; kind subscription is a checkout for `subject` to `creator` (at `tier`'s price, 1 by default). */
++ createIntent: async ({ provider, kind = 'purchase', subject, bits, creator, tier, 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 }),
++ ...(kind === 'subscription' ? { streamer: user(creator), auto_renew: !!autoRenew, ...(tier != null ? { tier } : {}) } : { bits }),
success_url: successUrl, cancel_url: cancelUrl,
},
key, traceparent,
@@@ -111,9 +111,9 @@ function createCommerceClient(config, { caps, fetchImpl = globalThis.fetch, toke
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 },
++ /** { subscription, entitlement, transaction }: one period paid from the member's credit (at `tier`'s price, 1 by default). */
++ subscribeWithCredit: async ({ subscriber, creator, tier, autoRenew, key, traceparent }) => await call('subscription', 'POST', '/api/v1/subscriptions', {
++ body: { subscriber: user(subscriber), streamer: user(creator), source: 'credit', auto_renew: autoRenew !== false, ...(tier != null ? { tier } : {}) },
key, traceparent,
}),
cancelSubscription: async ({ id, key, traceparent }) => await call('subscription', 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent }),
@@@ -122,6 +122,12 @@ function createCommerceClient(config, { caps, fetchImpl = globalThis.fetch, toke
/** { 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'),
++ /** { streamer, tiers: [{ tier, price_cents, status }], default_price_cents, min_price_cents, max_price_cents, max_tiers }: Billing prices a creator's tiers. */
++ listTiers: async (creator) => await call(caps.tier ? 'tier' : 'intent', 'GET', `/api/v1/tiers/${encodeURIComponent(creator)}`),
++ /** { tier }: set the monthly price (USD cents) of one of `creator`'s tiers (billing.tier.manage; status active or archived). */
++ setTier: async ({ creator, tier, priceCents, status, key, traceparent }) => await call('tier', 'PUT', `/api/v1/tiers/${encodeURIComponent(creator)}/${encodeURIComponent(tier)}`, {
++ body: { price_cents: priceCents, ...(status ? { status } : {}) }, key, traceparent,
++ }),
baseUrl: () => base,
};
}
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 90f8b8e..eb170a1 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.50.1';
++const SDK_VERSION = '0.51.0';
module.exports = {
createClient,
ddiff --git a/test/commerce.test.js b/test/commerce.test.js
iindex 7e80f76..76b2a11 100644
---- a/test/commerce.test.js
++++ b/test/commerce.test.js
@@@ -85,6 +85,30 @@ run([
assert.equal(calls[4].init.headers['Idempotency-Key'], undefined);
assert.equal(client.baseUrl(), 'http://billing.test');
}],
++ ['tiers: the tier on a subscription checkout and a credit period; listTiers and setTier use billing.tier.manage', async () => {
++ const { client: noTierCap, calls: readCalls } = rig([json(200, { tiers: [] })]);
++ await noTierCap.listTiers('u2');
++ assert.equal(readCalls[0].init.headers.Authorization, 'Bearer intent', 'listTiers without caps.tier reads with the intent capability');
++ const calls = [];
++ const fetchImpl = async (url, init) => { calls.push({ url, init }); return json(200, { ok: true }); };
++ const mk = (name) => ({ authHeaders: async () => ({ Authorization: `Bearer ${name}` }), invalidate: () => {} });
++ const client = createCommerceClient(CONFIG, { caps: { ...CAPS, tier: 'billing.tier.manage' }, fetchImpl, tokenClients: { intent: mk('intent'), subscription: mk('subscription'), tier: mk('tier') } });
++ await client.createIntent({ provider: 'powerchat', kind: 'subscription', subject: 'u1', creator: 'u2', tier: 2, key: 'k' });
++ await client.subscribeWithCredit({ subscriber: 'a', creator: 'b', tier: 3, key: 's' });
++ await client.listTiers('u 2');
++ await client.setTier({ creator: 'u2', tier: 2, priceCents: 1500, key: 't' });
++ await client.setTier({ creator: 'u2', tier: 2, priceCents: 1500, status: 'archived', key: 't2' });
++ assert.equal(JSON.parse(calls[0].init.body).tier, 2);
++ assert.equal(JSON.parse(calls[1].init.body).tier, 3);
++ assert.deepEqual(calls.slice(2, 5).map((c) => [c.init.method, c.url.replace('http://billing.test', '')]), [['GET', '/api/v1/tiers/u%202'], ['PUT', '/api/v1/tiers/u2/2'], ['PUT', '/api/v1/tiers/u2/2']]);
++ assert.deepEqual(calls.slice(2, 5).map((c) => c.init.headers.Authorization), ['Bearer tier', 'Bearer tier', 'Bearer tier']);
++ assert.deepEqual(JSON.parse(calls[3].init.body), { price_cents: 1500 });
++ assert.deepEqual(JSON.parse(calls[4].init.body), { price_cents: 1500, status: 'archived' });
++ assert.equal(calls[3].init.headers['Idempotency-Key'], 't');
++ const plain = rig([json(200, {})]);
++ await plain.client.subscribeWithCredit({ subscriber: 'a', creator: 'b' });
++ assert.equal('tier' in JSON.parse(plain.calls[0].init.body), false, 'no tier unless asked: Billing sells tier 1');
++ }],
['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' });
ddiff --git a/types/commerce.d.ts b/types/commerce.d.ts
iindex d4f27f9..38b7da3 100644
---- a/types/commerce.d.ts
++++ b/types/commerce.d.ts
@@@ -16,7 +16,8 @@ export type CommerceConfig = {
network?: { internalUrl: string };
oauth?: { clientId: string; clientSecret: string };
};
--export type CommerceCaps = { intent?: string; transfer?: string; subscription?: string; entitlement?: string; rates?: string };
++export type CommerceCaps = { intent?: string; transfer?: string; subscription?: string; entitlement?: string; rates?: string; tier?: string };
++export type CommerceTier = { tier: number; price_cents: number; status: 'active' | 'archived'; updated_at?: string };
export type CommerceTokenClient = { authHeaders(): Promise<Record<string, string>>; invalidate(): void };
export type CommerceOptions = {
caps: CommerceCaps;
@@@ -27,15 +28,19 @@ export type CommerceOptions = {
};
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>;
++ createIntent(a: { provider: string; kind?: 'purchase' | 'subscription'; subject: string; bits?: number; creator?: string; tier?: number; 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>;
++ subscribeWithCredit(a: { subscriber: string; creator: string; tier?: number; 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>;
++ /** A creator's tier prices (Billing, contracts 0.201.0); uses caps.tier, else caps.intent. */
++ listTiers(creator: string): Promise<{ streamer: { type: 'user'; id: string }; tiers: CommerceTier[]; default_price_cents: number; min_price_cents: number; max_price_cents: number; max_tiers: number }>;
++ /** Set one tier's monthly price (billing.tier.manage). */
++ setTier(a: { creator: string; tier: number; priceCents: number; status?: 'active' | 'archived' } & CommerceCall): Promise<{ tier: CommerceTier }>;
baseUrl(): string;
};
export declare function createCommerceClient(config: CommerceConfig, options: CommerceOptions): CommerceClient;